Skip to content

reduce is 1.75x slower than v1.12.2 — bisected to #639 #656

Description

@mparrett

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions