Skip to content

Perf audit 2026-09: the five general defects behind the worst scorecard rows - #823

Merged
yogthos merged 11 commits into
mainfrom
perf/audit-2026-09
Sep 2, 2026
Merged

yogthos merged 11 commits into
mainfrom
perf/audit-2026-09

Conversation

@yogthos

@yogthos yogthos commented Sep 2, 2026 •

Copy link
Copy Markdown
Member

"Epic jolt-ewxj. An audit measured the worst scorecard rows per operation inside one --opt binary 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; case lowers 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 test fixnum? first (unchecked-add 12 -> 3ns), jolt->idx passes a fixnum through, str has 2/3-arg entries that render strings/keywords/fixnums without the printer (56 -> 19ns), pvec-nth! reads the tail without pv-leaf-for's two-value return, jolt-invoke builds its arity-error name only on the error path.\n\nEmitter (rounds 2-3, remint). The unchecked-* family is native (2-arg helpers directly; :lng/:dbl on the wrapping/flonum ops), unchecked-long/-int are 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) with record-method-dispatch as 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, and jolt run never direct-links. A ^doubles param'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 checkcast ClassCastException on entry). case compares keyword/nil/boolean constants with identical?. JOLT_WP_TRACE=1 prints both fixpoints' iteration counts.\n\nRuntime (round 4). first answers a cell or a vector from the backing store with no seq cell (45 -> 12ns); host-table's sorted first and nio-file's dir-stream/path seq are registry arms instead of set!-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.ss still passed ffi/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.sh counted the Chez runtime/app compile rows as jolt passes (fixed). examples/ring-app did not build (its time/db/crypto pins predated the :jolt/provides migration; jolt-lang/examples#35, merged).\n\nGates. New run-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 test green (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 under TZ=UTC) — core's SimpleDateFormat renders UTC while jolt.time now applies the system zone, filed as jolt-6wm4.\n\nScorecard (same machine, same session, bench/run.sh before 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 in bench/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 now PersistentArrayMap'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/vals are vector-backed cells (O(1) count, chunk-loop reduce) instead of a cons chain built up front; reduce-kv is native and folds in place; assoc of the value already held and dissoc of an absent key answer the same map, as both JVM map classes do; into {} from a map and zipmap over 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 redefines vector-copy at top level as a one-argument loop, and on the Gambit host records are vectors, so the mode test is hnode?, never vector? (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, 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, {: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 red test job was one unit row: telemetry read jolt.host/bytes-allocated before 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. New make 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\ncollections and transients are hash-mode maps: they move because every trie node copy and transient buffer growth is a bulk move now.\n"

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
yogthos merged commit dd44c7f into main Sep 2, 2026
6 checks passed
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
@yogthos
yogthos deleted the perf/audit-2026-09 branch September 3, 2026 20:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant