Skip to content

perf: audit recomputed its sort key on every comparison, not once per method - #31

Merged
sotashimozono merged 2 commits into
mainfrom
perf/audit-recomputed-its-sort-key-per-comparison
Sep 9, 2026
Merged

perf: audit recomputed its sort key on every comparison, not once per method#31
sotashimozono merged 2 commits into
mainfrom
perf/audit-recomputed-its-sort-key-per-comparison

Conversation

@sotashimozono

Copy link
Copy Markdown
Member

Asked whether @experimental is really this expensive. It is not — the measurement that
suggested it was measuring something else.

The mark is free at call time

10M calls of a numeric body, best of five, 36 threads:

plain 9.7 ms
marked 9.6 ms — ratio 0.988, per-call overhead −0.01 ns

(My first attempt reported a ratio of 143808, because the result never escaped and the optimiser
deleted the loop. It needs a sink. Reporting that number would have been worse than useless.)

What actually cost 0.74s

One line of audit, with nothing to do with marks:

sort!(out; by = mm -> (string(mm.name), string(mm.sig)))

sort!(…; by = f) calls f on both sides of every comparison. For this package's own 301
methods that is ~5000 string(mm.sig) calls instead of 301.

iterate every methods(f) behind 1916 candidates — 11026 methods 0.033s
build all 301 sort keys once 0.039s
the sort 0.601s

Computing the keys once and taking sortperm:

audit(ExperimentalAPI): 0.740s → 0.082s (9×) — same 301 own / 22 contributed / 22
unaccounted, same order. sortperm is stable where sort!'s default is not, and the keys are
unique per method, so nothing depends on ties.

The same shape elsewhere, left alone — measured, not assumed

record has two sort!(hits; by = h -> (string(h.mod), string(h.name))). At 100 entered marks the
sort is 0.051 ms of a 1.17 ms record — 4% of something already cheap. Not worth the churn.

And one test that called it in a loop

test_dogfood.jl called audit(ExperimentalAPI) inside a 24-iteration loop.

before 18.8s
hoisted 3.2s
hoisted + the sort fix 0.9s

Suite wall time 110.6s → ~90s. The rest of the per-file movement is run-to-run noise on a shared
machine and should not be read as signal.

🤖 Generated with Claude Code

… method

Asked whether `@experimental` is really this expensive. It is not — the measurement that suggested
it was measuring something else.

The mark itself is free at call time. 10M calls of a numeric body, best of five, 36 threads:

    plain    9.7 ms
    marked   9.6 ms      ratio 0.988,  per-call overhead -0.01 ns

(The first attempt at this reported a ratio of 143808 because the result never escaped and the
optimiser deleted the whole loop. It needs a sink.)

What cost 0.74s was one line of `audit`, and it had nothing to do with marks:

    sort!(out; by = mm -> (string(mm.name), string(mm.sig)))

`sort!(…; by = f)` calls `f` on **both sides of every comparison**. For this package's own 301
methods that is roughly five thousand `string(mm.sig)` calls instead of three hundred. Measured:

| | |
|---|---|
| iterate every `methods(f)` behind 1916 candidates — 11026 methods | 0.033s |
| build all 301 sort keys once | 0.039s |
| **the sort** | **0.601s** |

Computing the keys once and taking `sortperm` gives `audit(ExperimentalAPI)` **0.740s → 0.082s**,
with the same 301 own / 22 contributed / 22 unaccounted and the same order. `sortperm` is stable
where `sort!`'s default is not, and the keys are unique per method, so nothing depends on ties.

The same shape is in `record`'s two `sort!(hits; by = h -> (string(h.mod), string(h.name)))`. Left
alone, measured rather than assumed: at 100 entered marks the sort is 0.051 ms of a 1.17 ms
`record`, which is 4% of something already cheap.

Second, from the same investigation: `test_dogfood.jl` called `audit(ExperimentalAPI)` inside a
24-iteration loop. Hoisted.

    test_dogfood.jl    18.8s  →  3.2s (hoist)  →  0.9s (both)

Suite wall time 110.6s → ~90s; the rest of the per-file movement is run-to-run noise on a shared
machine and should not be read as signal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📚 Docs preview: https://codes.sota-shimozono.com/ExperimentalAPI.jl/previews/PR31/

(updates on each push to this PR)

@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Follow-up measurement to the sort fix. What remains of `audit`'s cost is the search itself: Julia
indexes methods by generic, not by module, so "the methods `m` defines" means scanning every public
callable of every loaded module — 1916 candidates and 11026 methods behind them here.

That answer cannot change without a method being defined or deleted, and both bump the world
counter. So the result is cached under `(world, module)`, the same key `_public_generics` already
uses two functions above.

The key is argued from measurement rather than assumed, because the obvious version of the argument
is false: a `const` binding does **not** bump the world counter on 1.11.9 (it does on 1.12.2 and
1.14.0-DEV). It cannot change this answer either — a new `const` either aliases something whose
methods belong to another module, or creating it defined a method and bumped the counter — but
"world age covers every input" would have been the wrong reason.

    audit(ExperimentalAPI), repeated in one world:  0.082s -> 0.0025s

Copy-on-write rather than `cache[m] = out`: the suite runs on four threads and a `Dict` is not safe
under concurrent `setindex!`. Swapping a freshly merged table in means the worst a race can cost is
a recomputation. Hammered with 200 concurrent calls across two modules: no corruption. The vector
is also copied out, so a caller that filters or empties it cannot poison the next one.

Honest about the size of it: across a full suite run the cache takes 31 hits to 36 misses, which is
about 2.5s of ~93s — below the run-to-run variance of this machine, and not visible in a wall-clock
total. The 32x is real for the pattern a user actually has, which is `audit` then `test_surface`
then `contributed_methods` on the same module.

One trap on the way in: putting the cache `const` between the docstring and `function own_methods`
detached the docstring onto the `const`, and `test_surface(ExperimentalAPI)` caught it — "every
public name has a docstring" failed on `own_methods`. The `const` now sits above the docstring.

1236 assertions, green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sotashimozono
sotashimozono merged commit 0c126ff into main Sep 9, 2026
14 checks passed
@sotashimozono
sotashimozono deleted the perf/audit-recomputed-its-sort-key-per-comparison branch September 9, 2026 06:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant