^bytes store fast path, direct (byte x), and a core name a namespace can own - #887
Merged
Conversation
added 2 commits
September 7, 2026 15:47
bench/byte-arrays' bfill phase was the largest single jolt/JVM ratio in the suite, and both halves of its one hot line were paying to get to work that costs a few nanoseconds. ^bytes was absent from the :v-aset fast path ^longs/^ints/^objects take, because a byte array narrows its value to signed 8 bits at the store and that narrowing lived on the generic path — so the hint bought a byte store nothing at all, and it emitted the same jolt-aset3 an unhinted one does. Per element: the array test, the kind read and an eq? on it, na-byte-of's truncate/exact/bitwise-and over the generic tower, (exact (na-idx i)) to coerce an index that was already a fixnum, then ja-set!'s second read of the backing, a bounds pre-check and a four-way cond — 34.7ns to reach a bytevector-s8-set!, against 5.9ns for the matching (aget ^bytes a i). jolt-baset is the byte kind's own store target, split from jolt-vaset rather than folded into it because jolt-vaset answers its argument and a byte store has to answer what was stored. A fixnum already inside -128..127 is by construction what na-byte-of would answer for it, so it goes straight into the bytevector; everything else — a flonum, a bignum, an out-of-range value, a non-fixnum index, a boxed backing, a lying ^bytes hint on another kind — falls through to the generic seam unchanged. 34.7ns -> 7.1ns. The other half was (byte x). double/long/int/float lower to a :coerce node carrying their checked runtime helper; byte and short were the two casts missing from that table, so (byte v) stayed a var-deref plus a jolt-invoke1 around jolt-byte-cast's two fixnum compares — 17.9ns an element, more than the store it fed. They join it with kind :long, sound for the reason int takes it: jolt-checked-cast answers a value inside [lo, hi] or throws, and both ranges are fixnums on every tower jolt has. The checked semantics are untouched. bfill 185.0ms -> 41.0ms (4.5x) at 400 passes, 66x the JVM down to 14.6x, with every other phase flat to within 1%. na-byte-of and na-array-set! took the same fixnum-first shape while they were open, so the generic seam is faster too. Gates: run-flarr.ss pins the emission for each array kind and the byte-fill loop; array-backing-test.ss pins the narrowing, the bounds class, the flonum index, the boxed backing and the lying hint against the generic store; numeric-test.ss pins the byte/short lowering and their JVM-checked semantics.
jolt has two layers that rewrite a clojure.core call into something cheaper, and they disagreed about who owns a name. The op-registry lowering resolves the head and checks the resolved var's namespace (backend_scheme/native-op), so a user-defined `first` was always called. The analyzer's numeric-cast and *unchecked-math* rewrites matched on the bare source name behind a `shadowed` guard that only sees LOCALS, so a namespace-level definition — with or without :refer-clojure :exclude — was silently ignored in call position: (ns shadow (:refer-clojure :exclude [double first])) (defn double [x] :my-double) (defn first [x] :my-first) (double 5) ; jolt: 5.0 JVM Clojure: :my-double (first [1 2]) ; jolt: :my-first JVM Clojure: :my-first The same held for long/int/float (and byte/short), and for every name *unchecked-math* rewrites — a user's + became unchecked-add. Both rewrites now ask the question the op-registry layer already asked, so the two agree and all of it matches the reference. A clojure.core/-qualified head names its ns outright and still lowers, in the very ns that redefined the bare name. Resolution runs only once unchecked-arith / num-cast have matched a name AND an arity, so an ordinary call — every other list head in the program — pays nothing for it: bench/byte-arrays bfill is 40.6/41.3ms with the check in, against 41.0ms without. make test green, corpus certify unmoved (5004 certified, 109/109 known divergences, 0 NEW, 0 stale), seed at the byte fixpoint.
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.
bench/byte-arrays'bfillphase was the largest single jolt/JVM ratio in the suite (159× on the release machine, 66× here). Both halves of its one hot line —(aset a i (byte (bit-and i 127)))— were paying to get to work that costs a few nanoseconds. Fixing the second half surfaced a correctness bug in the lowering it goes through, so there are three commits' worth of change here in two commits.1. The store
^byteswas absent from the:v-asetfast path^longs/^ints/^objectstake, because a byte array narrows its value to signed 8 bits at the store and that narrowing lived on the generic path. So the hint bought a byte store nothing at all — it emitted the samejolt-aset3an unhinted one does. Per element:jolt-aset3's array test, the kind read and aneq?on itna-byte-of'struncate/exact/bitwise-and, all generic (bignum-capable) — 14.5ns(exact (na-idx i))to coerce an index that was already a fixnum — 9.1nsja-set!'s second read of the backing,ja-check, and a four-waycond— 11.2ns34.7ns to reach a
bytevector-s8-set!, against 5.9ns for the matching(aget ^bytes a i), which does take the direct backing read.jolt-basetis the byte kind's own store target, split fromjolt-vasetrather than folded into it becausejolt-vasetanswers its argument and a byte store has to answer what was stored (so a followingagetagrees). A fixnum already inside-128..127— what a byte-filling loop hands it — is by construction whatna-byte-ofwould answer for it, so it goes straight to the bytevector. Everything else falls through to the generic seam: a flonum, a bignum, an out-of-range value, a non-fixnum index, a byte array whose backing an older image left boxed, and a lying^byteshint on an array of any other kind. That fallback is not a slow path added for the helper — it is the path every unhintedasettakes, which is why the narrowing contract is unchanged.34.7ns → 7.1ns.
2. The cast
double,long,intandfloatlower to a:coercenode carrying their checked runtime helper.byteandshortwere the two casts missing from that table, so(byte v)stayed a var-deref plus ajolt-invoke1aroundjolt-byte-cast's two fixnum compares — 17.9ns an element, more than the store it fed.They join it with kind
:long, sound for the reasoninttakes it:jolt-checked-castanswers a value inside[lo, hi]or throws, and both ranges are fixnums on every tower jolt has. Checked semantics untouched —(byte 200)is stillIllegalArgumentExceptionand not a wrap to -56,(byte 127.000001)still throws where(byte 1.9)is 1,bytein value position is still the var. Cross-checked row by row against JVM Clojure 1.12.3. Who owns a core name (a pre-existing bug this surfaced)
jolt has two layers that rewrite a
clojure.corecall into something cheaper, and they disagreed. The op-registry lowering resolves the head and checks the resolved var's namespace (backend_scheme/native-op), so a user-definedfirstwas always called. The analyzer's numeric-cast and*unchecked-math*rewrites matched on the bare source name behind ashadowedguard that only sees locals — so a namespace-level definition was silently ignored in call position:This predates the PR and affected
double/long/int/float(and would have affected thebyte/shortadded above), plus every name*unchecked-math*rewrites — a user's+silently becameunchecked-add. Both rewrites now ask the question the op-registry layer already asked, so the two agree and all of it matches the reference. Aclojure.core/-qualified head names its namespace outright and still lowers, in the very namespace that redefined the bare name.Resolution runs only once a name and an arity have matched, so an ordinary call — every other list head in the program — pays nothing for it. Measured:
bfill40.6/41.3ms with the check in, against 41.0ms without.Numbers
Per phase, 400 passes, same box and sitting,
--opt --direct-linkbinaries either side:copy-fullcopy-regiondrainround-tripbfillbsumbfill4.5×; 66× the JVM down to 14.6×. Every other phase flat to within 1%, which is what says the change is where it claims to be. Whole benchmark 611ms → 484ms.na-byte-ofandna-array-set!took the same fixnum-first shape while they were open, so the generic seam is faster too — the untypedaset, and every other door into a byte array (into-array,Arrays/fill,na-list->backing).Verification
jolt-basetvsna-array-set!over 600+ value shapes (every integer in ±300, flonums either side of each bound, bignums, ratios), every index shape, and all five backing kinds — 0 mismatches.run-flarr.ss(+11) — emission per array kind:^bytesstore →jolt-basetand notjolt-aset3/jolt-vaset;^longs/^objectsstilljolt-vaset; unhinted stilljolt-aset3; the byte-fill loop with no var-deref invoke left in it.array-backing-test.ss(+7) — the narrowing, that the hinted store agrees with the unhinted one value for value, the flonum index, theArrayIndexOutOfBoundsExceptionclass both directions, the boxed backing, the lying hint.numeric-test.ss(+30) — thebyte/shortlowering and their JVM-checked semantics; and for §3, that an ns-leveldouble/byte/+is neither lowered nor rewritten whileclojure.core/doublestill is in that same namespace, at both emission and runtime.make testgreen (102 CI targets +selfhost). Corpus certify unmoved: 5004 certified, 109/109 known divergences, 0 NEW, 0 stale — the analyzer change did not move a single row.make remint+make gambitseed).Still open
drain— an 8KBInputStream/readcosts 1.57µs against the JVM's 0.23µs. The transfer is already a block move; what is left is per-call overhead on the way to it. Untouched here, and the bench README still watches it.