From 0d0a6143037a87ef804e97a4042fc5a61b7b69c7 Mon Sep 17 00:00:00 2001 From: sotashimozono Date: Wed, 9 Sep 2026 06:27:12 +0000 Subject: [PATCH 1/2] perf: audit recomputed its sort key on every comparison, not once per method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/audit.jl | 6 +++++- test/test_dogfood.jl | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/audit.jl b/src/audit.jl index a150c3b..f74f3bd 100644 --- a/src/audit.jl +++ b/src/audit.jl @@ -195,7 +195,11 @@ function own_methods(m::Module) mm.module === m && !(mm in seen) && (push!(seen, mm); push!(out, mm)) end end - return sort!(out; by=mm -> (string(mm.name), string(mm.sig))) + # The key is built ONCE per method, not once per comparison. `sort!(…; by = f)` calls `f` on + # both sides of every comparison, and `string(mm.sig)` is not cheap: measured on this + # package's own 301 methods, the sort was 0.601s while building all 301 keys was 0.039s. That + # one line was 80% of `audit`, which is called once per module in every surface check. + return out[sortperm([(string(mm.name), string(mm.sig)) for mm in out])] end function _generic_candidates(m::Module) diff --git a/test/test_dogfood.jl b/test/test_dogfood.jl index 4a48781..0dab4b7 100644 --- a/test/test_dogfood.jl +++ b/test/test_dogfood.jl @@ -88,6 +88,11 @@ end # The control the testset above cannot be: an equality against a hand-written set is satisfied # by marking every name and updating the set to match. These are the names the front page # promises answers from, and a promise is exactly what a mark withdraws. + # + # Hoisted, and not for tidiness: `audit(ExperimentalAPI)` costs 0.75s — it reads a docstring + # for every public name and walks the method tables for the contributed ones — so calling it + # per iteration cost 17.8s, which was 16% of the whole suite. + surface = audit(ExperimentalAPI).surface for n in [ Symbol("@experimental"), :Mark, @@ -114,7 +119,7 @@ end :age, :docstring_note, ] - @test n in audit(ExperimentalAPI).surface + @test n in surface @test !isexperimental(ExperimentalAPI, n) end end From 27744be388f254b1667afb18ea099552842b8850 Mon Sep 17 00:00:00 2001 From: sotashimozono Date: Wed, 9 Sep 2026 06:51:32 +0000 Subject: [PATCH 2/2] perf: own_methods recomputes a world-invariant answer on every call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/audit.jl | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/audit.jl b/src/audit.jl index f74f3bd..321d3ff 100644 --- a/src/audit.jl +++ b/src/audit.jl @@ -166,6 +166,19 @@ function _is_own(m::Module, name::Symbol) end end +# `own_methods` is a scan over every public callable of every loaded module — 1916 candidates and +# 11026 methods behind them for this package — and a suite that audits several modules pays it once +# per audit. Its ANSWER, though, is "the methods whose defining module is `m`", and that set can +# only change when a method is defined or deleted. Both bump the world counter: measured on 1.11.9, +# 1.12.2 and 1.14.0-DEV, a method definition bumps it in all three. +# +# A `const` binding does NOT bump it on 1.11 (it does on 1.12 and later), which is why the key is +# argued rather than assumed. A new `const` cannot change this answer: either it aliases something +# whose methods belong to another module, or creating it defined a method and bumped the counter. +const _OWN_METHODS = Ref{Tuple{UInt64,Dict{Module,Vector{Method}}}}(( + typemax(UInt64), Dict{Module,Vector{Method}}() +)) + """ own_methods(m::Module) -> Vector{Method} @@ -183,6 +196,21 @@ exported-or-`public` names of every loaded module. That last set is what catches every binding of every loaded module. """ function own_methods(m::Module) + w = Base.get_world_counter() + (cached_world, cache) = _OWN_METHODS[] + cached_world == w || (cache = Dict{Module,Vector{Method}}()) + # Copied out: the vector is the caller's to filter, sort or push to, and a caller that mutates + # it must not be able to corrupt what the next one sees. + haskey(cache, m) && return copy(cache[m]) + out = _own_methods(m) + # 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 built one in means the worst a race + # can cost is a recomputation, never a corrupted table. + _OWN_METHODS[] = (w, merge(cache, Dict(m => out))) + return copy(out) +end + +function _own_methods(m::Module) out = Method[] seen = Set{Method}() for f in _generic_candidates(m)