Repository navigation
Perf audit 2026-09: the five general defects behind the worst scorecard rows - #823
Merged
Merged
Conversation
A compare of two base scalars of different kinds, or of anything against nil, walked every registered eq arm before jolt=2-base got to answer it: 145-240ns per miss with the 17 arms a bare runtime registers, and `case` lowers to a chain of exactly those compares. Answered ahead of the walk now; the JVM's Util.equiv has no extension point for such a pair either. The cross-kind pairs join eq-fast-probes so the registry refuses an arm that would claim one, and values-test asserts a registered arm is never consulted for them. Same treatment for the small things sitting on hot paths: jolt-wrap64 and the unchecked-* helpers test fixnum? before comparing against bignum bounds (unchecked-add 12 -> 3ns), jolt->idx passes a fixnum through, str has fixed 2/3-arg entries that skip the rest list and render strings/keywords/fixnums without the printer (56 -> 19ns), pvec-nth! reads the tail without going through pv-leaf-for's two-value return, and jolt-invoke builds its arity-error name only on the error path. bench/build-phases.sh counted the Chez runtime/app compile rows as jolt passes and summed the emit sub-rows on top of their parent; the split now names the rows build.ss actually emits and skips sub-rows. Corpus rows pin the cross-kind equality answers, the 64-bit unchecked wrap boundaries, str's small arities and nth on both sides of the tail boundary. Claude-Session: https://claude.ai/code/session_019tGgRzPx3vtr5siPkKjjLE
…vars Three emitter changes from the perf audit, each measured per operation inside one --opt binary. unchecked-add/-subtract/-multiply/-inc/-dec/-negate are native ops now (the 2-arg helpers directly; :lng on the same wrapping helpers, :dbl on the flonum ops), and unchecked-long/-int are casts that type their result :long, so a loop in the hinted-Clojure idiom keeps fixnum counters instead of paying a var call per cast and per add. The long casts pass a fixnum through inline. An interop call on an unproven receiver whose method has a string or keyword direct form tests the receiver at the site and takes it, with record-method-dispatch as the slow arm; the generic path reads its rest args off the vector's tail instead of seq->list. Under direct-link, a call to a seed var (a namespace the runtime image boots with) binds the var's root procedure once at load and calls it directly, where it paid var-cell-deref + jolt-invokeN per call. jolt.host/seed-callable? applies the closed-world rule: procedure root, arity admitted, not ^:dynamic/^:redef, not redefined by the app. Same semantics as the JVM's direct linking; jolt run never direct-links. Also: a ^doubles param's backing flvector is bound once at the arity's entry (a non-array argument raises ClassCastException there, as a checkcast does); case compares keyword/nil/boolean constants with identical?; JOLT_WP_TRACE=1 prints the whole-program and inline fixpoint iteration counts. New gate run-directcall.ss pins the emission and the guards; flarr and traceemit follow the hoisted array shape; corpus rows pin the answers. Claude-Session: https://claude.ai/code/session_019tGgRzPx3vtr5siPkKjjLE
…not wrappers (first v) built a seq cell, ran jolt-seq's dispatch, and passed through two set!-wrappers on the way: host-table.ss wrapped jolt-first for sorted collections and nio-file.ss wrapped jolt-seq for directory streams and paths, so every seq in the program paid their type tests first. first reads a cell's head or a vector's element 0 directly now (45 -> 12ns), and both wrappers are registry arms consulted after the fast clauses, with the same reject-fast-type guard the other registries use. Claude-Session: https://claude.ai/code/session_019tGgRzPx3vtr5siPkKjjLE
The JVM certification of the new corpus rows caught it: only the int cast has a char overload, so (unchecked-long \a) is a ClassCastException there and was 97 here. The deftype row now implements a protocol so the JVM accepts the method, and the one-operand unchecked-subtract row goes: that arity is an already documented divergence. Claude-Session: https://claude.ai/code/session_019tGgRzPx3vtr5siPkKjjLE
…fset The socket-accept row still passed ffi/write's arguments offset-first from before the argument-order change, so (ffi/write lenp :int 0 16) stored a zero sixteen bytes past a four-byte buffer. Whether that landed on something that mattered depended on heap layout: the gate passed in two full runs tonight and then died with SIGKILL in macOS's allocator on the third, and a runtime edit elsewhere flipped it deterministically. Guard Malloc names the overrun exactly; with the call corrected it is clean. Claude-Session: https://claude.ai/code/session_019tGgRzPx3vtr5siPkKjjLE
hc-install! snapshotted the var table itself, and the Gambit host has no var-table-cells, so its boot died in hc-install!. loader.ss already supplies the image set for the CLI and jolt build; gate-boot.ss now snapshots it after the image loads for the pass gates, and a boot that installs nothing direct-links no seed var. Claude-Session: https://claude.ai/code/session_019tGgRzPx3vtr5siPkKjjLE
Since jolt #813 a host class is provided only through a library's :jolt/provides declaration, which jolt.deps reads from the library's deps.edn. Nine manifest entries put time's src directory on the path instead, so DateTimeFormatter and friends were unprovided and seven suites failed to load. The same :local-deps mechanism the crypto and yaml providers already use resolves time through its deps.edn. Claude-Session: https://claude.ai/code/session_019tGgRzPx3vtr5siPkKjjLE
One same-sitting MODE_A=1 bench/run.sh on an Apple Silicon host replaces the x86 table wholesale. The rows the audit targeted, measured on this machine before and after in the same session: char-scan 24.2x -> 6.7x, literals 7.5x -> 2.1x, string-ops 6.9x -> 3.7x, arrays 6.4x -> 3.9x, keyed-lookup 5.7x -> 3.5x, string-build 5.3x -> 4.5x, hash-eq 3.0x -> 2.6x; the rows without a same-session baseline were A/B'd against a main binary and sit within 0.97-1.02 of it. The arrays paragraph no longer blames flonum boxing (the accumulator is unboxed; the accessor was the cost), and a section records the five general defects the audit found behind the worst rows. Claude-Session: https://claude.ai/code/session_019tGgRzPx3vtr5siPkKjjLE
An array-mode map (a literal of up to 8 entries, up to 64 keyword-keyed,
and whatever assoc keeps under those limits) was a hash trie with an
insertion-order list beside it, so a 3-entry map was a record, a node, a
leaf pair per entry and an order pair per entry, and every lookup hashed
the key and walked the trie. It is now PersistentArrayMap's shape: a
record over #(k0 v0 k1 v1 ...). Lookup scans with a compare picked once
from the probe key's kind (keyword by identity, fixnum/string/symbol by
their own equality, the rest by =), assoc/dissoc copy the slots, the
transient is a slot buffer of max(16, len) slots with the reference's
swap-on-remove, and reduce-kv, =, into {} and zipmap read the slots
directly. seq/keys/vals of a map are vector-backed cells now (O(1)
count, chunk-loop reduce) instead of a cons chain built up front.
reduce-kv is native. assoc of the value already held and dissoc of an
absent key answer the same map, as both JVM map classes do.
The record tag is chez-pmap-v5 and the image format 7; a chez-pmap-v4 in
an older image re-mints through a legacy arm in state-image.ss (the
v0.6.5 fixture holds one). The mode test is hnode?, never vector?: on
the Gambit host records are vectors.
Under it, the runtime half of a built binary compiles at Chez
optimize-level 2, where a checked scan of a 64-keyword map costs three
times the unchecked one and a 64-slot copy loop 390ns against 62. A new
adapter tier (capability-unchecked: sa-ufx+, sa-uvector-ref ..., #3%
forms on Chez, the checked primitives on Gambit) carries the hot loops
whose bounds are proven by construction, and every vector copy under a
trie node update, a tail append or a transient's growth is a bulk move.
Not vector-copy: the vendored irregex redefines it as a one-arg loop.
Per operation in one --opt binary, before -> after: (:k m) on 3 entries
34 -> 12ns, on 8 entries 25 -> 15, contains? 31 -> 11, assoc replace 116
-> 64, dissoc 111 -> 59, {:a x :b y} at run time 100 -> 17, (= m m') 230
-> 84, (into {} m) 1045 -> 205, zipmap 400 -> 201, reduce-kv on 8 entries
583 -> 50 and on 64 keywords 4259 -> 324. Slower: the 64th key of a
64-keyword map 38 -> 53 (linear, as on the JVM) and a symbol probed
against symbol keys and missing 20 -> 38. Bench rows A/B/A against a
worktree of the previous commit: keyed-lookup 97/91/98, collections
19.3/17.0/19.3, transients 205/147/203, hash-eq 461/443/458, literals
53/54/53, seqs 358/340/357.
The CI failure was the telemetry unit row: bytes-allocated reads live
data plus uncollected garbage, so a collection between its two readings
shrinks the second one. It asserts the lower bound now.
Gates: make arraymap (new), transient rows, 21 JVM-certified corpus rows,
unit rows, adaptercheck/portcheck/gambitcheck for the tier, gambitboot.
Closes jolt-mw44.33.
Claude-Session: https://claude.ai/code/session_019tGgRzPx3vtr5siPkKjjLE
yogthos
added a commit
that referenced
this pull request
Sep 3, 2026
Perf audit 2026-09: the five general defects behind the worst scorecard rows
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
"Epic jolt-ewxj. An audit measured the worst scorecard rows per operation inside one
--optbinary and found five general mechanisms behind them, none of them the pass the row is named for. This lands the rounds that fix them, with the gates that pin each one.\n\nRuntime (round 1). A compare of two base scalars of different kinds, or of anything against nil, walked every registered eq arm before it was answered (145-240ns per miss;caselowers to that chain). It is answered ahead of the walk now, the cross-kind pairs joined the fast probes so the registry refuses an arm claiming one, and values-test asserts a registered arm is never consulted for them.jolt-wrap64/the unchecked helpers testfixnum?first (unchecked-add 12 -> 3ns),jolt->idxpasses a fixnum through,strhas 2/3-arg entries that render strings/keywords/fixnums without the printer (56 -> 19ns),pvec-nth!reads the tail withoutpv-leaf-for's two-value return,jolt-invokebuilds its arity-error name only on the error path.\n\nEmitter (rounds 2-3, remint). Theunchecked-*family is native (2-arg helpers directly;:lng/:dblon the wrapping/flonum ops),unchecked-long/-intare casts typing their result:long, and the long casts pass a fixnum through inline: char-scan's loop counters are fixnums now. An interop call on an unproven receiver whose method has a string/keyword direct form tests the receiver at the site (receiver and args bound once, in order) withrecord-method-dispatchas the slow arm. Under direct-link a call to a seed var binds the root procedure once at load and calls it directly, where it paid var-cell-deref + jolt-invokeN per call;jolt.host/seed-callable?applies the closed-world rule (procedure root, arity admitted, not^:dynamic/^:redef, not redefined by the app), same semantics as JVM direct linking, andjolt runnever direct-links. A^doublesparam's backing flvector is bound once at the arity's entry (shadowing by let/loop/nested params/catch tracked; a non-array argument raises the checkcastClassCastExceptionon entry).casecompares keyword/nil/boolean constants withidentical?.JOLT_WP_TRACE=1prints both fixpoints' iteration counts.\n\nRuntime (round 4).firstanswers a cell or a vector from the backing store with no seq cell (45 -> 12ns); host-table's sortedfirstand nio-file's dir-stream/pathseqare registry arms instead ofset!-wrappers in front of every seq in the program.\n\nRound 5, measured instead of built. With the new tracing, ring-app's whole-program fixpoint runs 5 priming + 2 main iterations + 1 field round: 7 walks of 3360 nodes, nowhere near the caps bead jolt-tg2s assumed, so a changed-defs worklist would save a few seconds of a ~35s build at the cost of the byte-identical acceptance criterion. Recorded on the bead; the lever is per-walk cost.\n\nFound along the way.(unchecked-long \\a)answered 97 where the JVM raises (caught by certifying the new corpus rows; fixed).test/chez/ffi-native-error-test.ssstill passedffi/write's arguments offset-first from before the argument-order change, writing a zero sixteen bytes past a four-byte buffer; the gate passed or died with SIGKILL in macOS's allocator depending on heap layout, and Guard Malloc named the overrun (fixed).bench/build-phases.shcounted the Chez runtime/app compile rows as jolt passes (fixed). examples/ring-app did not build (its time/db/crypto pins predated the:jolt/providesmigration; jolt-lang/examples#35, merged).\n\nGates. Newrun-directcall.ss(seed direct-link, the interop guard, the unchecked lowering, case identity), flarr/traceemit follow the hoisted array shape, values-test gates the eq/first registries, ~100 corpus rows certified against JVM Clojure 1.12.5 (0 new divergences),make testgreen (95 CI targets, receipt on the pushed tree).make libconformance: 47 of 50 suites match their recorded tallies after the manifest fix; data.json, Selmer and ring-core differ from theirs only in a non-UTC zone (identical on a pristine-main binary, exact underTZ=UTC) — core'sSimpleDateFormatrenders UTC while jolt.time now applies the system zone, filed as jolt-6wm4.\n\nScorecard (same machine, same session,bench/run.shbefore and after; opt ratio vs JVM Clojure):\n\n| row | before | after | jolt ms before → after |\n|---|---:|---:|---:|\n| char-scan | 24.2× | 6.7× | 363 → 94 |\n| literals | 7.5× | 2.1× | 195 → 53 |\n| string-ops | 6.9× | 3.7× | 511 → 250 |\n| arrays | 6.4× | 3.9× | 240 → 141 |\n| keyed-lookup | 5.7× | 3.5× | 147 → 95 |\n| string-build | 5.3× | 4.5× | 214 → 170 |\n| hash-eq | 3.0× | 2.6× | 537 → 467 |\n| transients | 3.8× | 3.5× | 222 → 201 |\n| seqs | 2.7× | 2.6× | 412 → 358 |\n| transducers | 4.2× | 4.2× | 139 → 128 |\n| sorted-access | 2.6× | 2.4× | 35 → 31 |\n| nth-access | 2.4× | 2.9× | 64 → 63 (the JVM column moved, jolt did not) |\n| mono-dispatch | 2.6× | 2.7× | 36 → 36 |\n\nThe ten rows without a same-session baseline (fib, tak, loop-recur, mandelbrot, mathfns, collections, vecops, dispatch, binary-trees, executors) were A/B'd against a pristine-main binary: every one within noise (branch/main 0.97-1.02×: fib 0.97, tak 0.97, loop-recur 1.00, mandelbrot 1.00, mathfns 1.00, collections 0.97, vecops 0.98, dispatch 1.02, binary-trees 0.98, executors 0.99). The full refreshed table is inbench/README.md.\n\nRound 6 (jolt-mw44.33): a small map is one flat slot vector. An array-mode map was a hash trie with an insertion-order list beside it — record, node, leaf pair per entry, order pair per entry — and every lookup hashed the key and descended the trie. It is nowPersistentArrayMap's shape: a record over#(k0 v0 k1 v1 …)(chez-pmap-v5, image format 7; a chez-pmap-v4 in an old image re-mints through a legacy arm, and the v0.6.5 fixture exercises it). Lookup scans with a compare chosen once from the probe key's kind (keyword by identity, fixnum/string/symbol by their own equality, the rest by=); assoc/dissoc copy the slots; the transient is a slot buffer of max(16, len) slots with the reference's swap-on-remove;seq/keys/valsare vector-backed cells (O(1) count, chunk-loop reduce) instead of a cons chain built up front;reduce-kvis native and folds in place;assocof the value already held anddissocof an absent key answer the same map, as both JVM map classes do;into {}from a map andzipmapover vectors skip the seq entirely; two-entry dynamic literals build without a rest list.\n\nUnder it, a general fix: the runtime half of a binary compiles at Chez optimize-level 2 (level 3 is unsafe mode), so a checked scan of a 64-keyword map cost 3x the unchecked one and a 64-slot copy loop 390ns against 62. A new adapter tier (capability-unchecked:sa-ufx+,sa-uvector-ref…, Chez#3%forms, Gambit the checked primitives) carries the hot loops whose bounds are proven by construction, and every vector copy under a trie node update, tail append or transient growth is a bulk move. Two traps: irregex redefinesvector-copyat top level as a one-argument loop, and on the Gambit host records are vectors, so the mode test ishnode?, nevervector?(gambitboot caught it).\n\nSame-session probe, ns/op before → after:(:k m)3-entry 34 → 12, 8-entry 25 → 15,(get m 20)43 → 16,contains?31 → 11,assocreplace 116 → 64,dissoc111 → 59,{:a x :b y}at run time 100 → 17,(= m m')230 → 84,(into {} m)1045 → 205,zipmap400 → 201,reduce-kvon 8 entries 583 → 50 and on 64 keywords 4259 → 324,{:keys [..]}destructure 96 → 48. Slower, and called out: the 64th key of a 64-keyword map 38 → 53 (the 30th 38 → 29; the scan is linear as PersistentArrayMap's is) and a symbol probed against symbol keys and missing 20 → 38 (a checked record predicate + accessor per slot at level 2; a symbol probed against keyword keys, honeysql's shape, is faster than before). Hash-mode maps are unchanged.\n\nCI. The redtestjob was one unit row:telemetryreadjolt.host/bytes-allocatedbefore and after building 100k strings and expected growth, but that reading is live data plus uncollected garbage, so a collection landing between the two reads makes it shrink (reproduced in bare Chez). The row now asserts the lower bound that holds whatever the collector does.\n\nGates. Newmake arraymap(representation per mode, thresholds, transient buffer, vector-backed views, identity), transient rows (200k zipmap linear, array-map source), 21 corpus rows certified against the JVM oracle (0 new divergences), unit rows for order/identity/lookup-by-kind/reduce-kv, adaptercheck/portcheck for the new tier, gambitboot.\n\nBench rows, A/B/A in one sitting against a worktree of the commit before (a jolt binary assembles a built app's runtime from the checkout's sources, so a "before" binary run inside this tree measures nothing — the first attempt came back flat on every row for exactly that reason):\n\n| row | before | after | before again | jolt/JVM now |\n|---|---:|---:|---:|---:|\n| keyed-lookup | 97 | 91 | 98 | 3.5× |\n| collections | 19.3 | 17.0 | 19.3 | 1.5× |\n| transients | 205 | 147 | 203 | 2.5× |\n| hash-eq | 461 | 443 | 458 | 2.4× |\n| literals | 53 | 54 | 53 | 2.3× |\n| seqs | 358 | 340 | 357 | 2.4× |\n\ncollectionsandtransientsare hash-mode maps: they move because every trie node copy and transient buffer growth is a bulk move now.\n"