What
(reduce + 0 (range 1000000)) runs 1.75x slower on main than before 428ce9e3 (#639, "hoist 222 clojure.core primitives to named //lg:native decls"). darwin/arm64, go1.26.3, hyperfine 20 runs:
| build |
mean |
GC cycles |
CPU / wall |
862c7cec (#622, the parent) |
33.1 ms ± 0.9 |
6 |
33.8 / 33.1 |
428ce9e3 (#639) |
57.7 ms ± 0.8 |
30 |
72.5 / 57.7 |
Allocation rather than compute: five times the GC cycles for identical work, and CPU exceeding wall time where the parent's was flat. Confirmed in both benchmark orders, so it isn't thermal drift.
Blast radius: reduce only
Same two binaries across the whole suite:
| workload |
ratio |
| fib |
1.03x |
| tak |
1.07x |
| loop-recur |
0.96x |
| map-filter |
0.95x |
| persistent-map |
1.04x |
| transducers |
1.06x |
| reduce |
1.77x |
Everything but reduce is inside run-to-run noise.
Plausible mechanism
reduce invokes its reducing fn once per element, a million times in this fixture, through ec.Invoke(mfn, []vm.Value{acc, elem}) in reduceColl (pkg/rt/native_prims.go). + is one of the 222 prims #639 hoisted, so anything the hoist added to a core fn's per-call path gets multiplied by the element count. That matches both the allocation signature and why only reduce moved.
map-filter also calls reduce and is unaffected, which fits: its reduce walks 100 elements, not 1,000,000.
I have the commit, not the line: which of the hoisted prims regressed, and what allocates on each call, are both still open.
Repro
cat > reduce-in-main.clj <<'EOF'
(defn -main [] (reduce + 0 (range 1000000)))
(-main)
EOF
for sha in 862c7cec 428ce9e3; do
git checkout -f -q $sha && git checkout -q -- . && git clean -xqfd
make build && cp lg /tmp/lg-$sha
done
hyperfine --warmup 5 --runs 20 "/tmp/lg-862c7cec reduce-in-main.clj" \
"/tmp/lg-428ce9e3 reduce-in-main.clj"
GODEBUG=gctrace=1 /tmp/lg-428ce9e3 reduce-in-main.clj 2>&1 | grep -c '^gc '
The git clean -xqfd is required, and -fd alone is not enough. make regenerates pkg/rt/core_compiled.lgb on timestamp rules and git checkout rewrites mtimes, so an incremental build across a checkout can pair a stale core bundle with freshly compiled Go. Those binaries mismeasure by ~1.8x and do it consistently: the error survives repeated runs and survives reversed-order A/B, so it reads as a real regression. Bisecting this without -x produced three different "first bad commits", including one whose diff only touches pkg/api.
Why the benchmark suite didn't catch it
benchmark/run.sh compares the VM leg against the AOT leg, both built from the working tree. A regression in shared runtime code moves both, the ratio between them stays flat, and the suite reports parity. On current main it prints let-go 69.1 ms / let-go AOT 69.1 ms and reports nothing unusual. #655 adds a pinned-release baseline leg so this class of regression is visible; with it, reduce reports 1.66x REGRESSION and the other six workloads stay quiet.
Suggestion
Unreleased, so no user impact yet, but it's on main today, and worth finding which prim regressed before the next release cut. Happy to keep going on it; whoever knows what the hoist changed about native dispatch will likely get there faster.
What
(reduce + 0 (range 1000000))runs 1.75x slower onmainthan before428ce9e3(#639, "hoist 222 clojure.core primitives to named//lg:nativedecls"). darwin/arm64, go1.26.3, hyperfine 20 runs:862c7cec(#622, the parent)428ce9e3(#639)Allocation rather than compute: five times the GC cycles for identical work, and CPU exceeding wall time where the parent's was flat. Confirmed in both benchmark orders, so it isn't thermal drift.
Blast radius:
reduceonlySame two binaries across the whole suite:
Everything but
reduceis inside run-to-run noise.Plausible mechanism
reduceinvokes its reducing fn once per element, a million times in this fixture, throughec.Invoke(mfn, []vm.Value{acc, elem})inreduceColl(pkg/rt/native_prims.go).+is one of the 222 prims #639 hoisted, so anything the hoist added to a core fn's per-call path gets multiplied by the element count. That matches both the allocation signature and why onlyreducemoved.map-filteralso callsreduceand is unaffected, which fits: its reduce walks 100 elements, not 1,000,000.I have the commit, not the line: which of the hoisted prims regressed, and what allocates on each call, are both still open.
Repro
The
git clean -xqfdis required, and-fdalone is not enough.makeregeneratespkg/rt/core_compiled.lgbon timestamp rules andgit checkoutrewrites mtimes, so an incremental build across a checkout can pair a stale core bundle with freshly compiled Go. Those binaries mismeasure by ~1.8x and do it consistently: the error survives repeated runs and survives reversed-order A/B, so it reads as a real regression. Bisecting this without-xproduced three different "first bad commits", including one whose diff only touchespkg/api.Why the benchmark suite didn't catch it
benchmark/run.shcompares the VM leg against the AOT leg, both built from the working tree. A regression in shared runtime code moves both, the ratio between them stays flat, and the suite reports parity. On current main it prints let-go 69.1 ms / let-go AOT 69.1 ms and reports nothing unusual. #655 adds a pinned-release baseline leg so this class of regression is visible; with it,reducereports1.66x REGRESSIONand the other six workloads stay quiet.Suggestion
Unreleased, so no user impact yet, but it's on
maintoday, and worth finding which prim regressed before the next release cut. Happy to keep going on it; whoever knows what the hoist changed about native dispatch will likely get there faster.