Skip to content

^bytes store fast path, direct (byte x), and a core name a namespace can own - #887

Merged
yogthos merged 2 commits into
mainfrom
perf/bytes-aset-fast-path
Sep 7, 2026
Merged

^bytes store fast path, direct (byte x), and a core name a namespace can own#887
yogthos merged 2 commits into
mainfrom
perf/bytes-aset-fast-path

Conversation

@yogthos

@yogthos yogthos commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

bench/byte-arrays' bfill phase 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

^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 — it emitted the same jolt-aset3 an unhinted one does. Per element:

  • jolt-aset3's array test, the kind read and an eq? on it
  • na-byte-of's truncate/exact/bitwise-and, all generic (bignum-capable) — 14.5ns
  • (exact (na-idx i)) to coerce an index that was already a fixnum — 9.1ns
  • ja-set!'s second read of the backing, ja-check, and a four-way cond11.2ns

34.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-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 (so a following aget agrees). A fixnum already inside -128..127 — what a byte-filling loop hands it — is by construction what na-byte-of would 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 ^bytes hint on an array of any other kind. That fallback is not a slow path added for the helper — it is the path every unhinted aset takes, which is why the narrowing contract is unchanged.

34.7ns → 7.1ns.

2. The cast

double, long, int and 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. Checked semantics untouched — (byte 200) is still IllegalArgumentException and not a wrap to -56, (byte 127.000001) still throws where (byte 1.9) is 1, byte in 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.core call 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-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 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   <- wrong
(first [1 2]) ; jolt: :my-first  JVM Clojure: :my-first    <- right

This predates the PR and affected double/long/int/float (and would have affected the byte/short added above), plus every name *unchecked-math* rewrites — a user's + silently 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 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: bfill 40.6/41.3ms with the check in, against 41.0ms without.


Numbers

Per phase, 400 passes, same box and sitting, --opt --direct-link binaries either side:

phase main this branch JVM (21.0.12)
copy-full 24.6 24.6 31.9
copy-region 25.3 25.3 34.1
drain 221.1 223.8 12.8
round-trip 38.7 38.6 12.4
bfill 185.0 40.6 2.8
bsum 38.1 37.4 2.6

bfill 4.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-of and na-array-set! took the same fixnum-first shape while they were open, so the generic seam is faster too — the untyped aset, and every other door into a byte array (into-array, Arrays/fill, na-list->backing).

Verification

  • Differential: jolt-baset vs na-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: ^bytes store → jolt-baset and not jolt-aset3/jolt-vaset; ^longs/^objects still jolt-vaset; unhinted still jolt-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, the ArrayIndexOutOfBoundsException class both directions, the boxed backing, the lying hint.
  • numeric-test.ss (+30) — the byte/short lowering and their JVM-checked semantics; and for §3, that an ns-level double/byte/+ is neither lowered nor rewritten while clojure.core/double still is in that same namespace, at both emission and runtime.
  • make test green (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.
  • Seed at the byte fixpoint; both seeds re-minted (make remint + make gambitseed).

Still open

drain — an 8KB InputStream/read costs 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.

Yogthos 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.
@yogthos yogthos changed the title Give the hinted ^bytes store a fast path, and lower (byte x) directly ^bytes store fast path, direct (byte x), and a core name a namespace can own Sep 7, 2026
@yogthos
yogthos merged commit 749a9a9 into main Sep 7, 2026
8 checks passed
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