Repository navigation
perf: byte-sum reduction loops — module-global receiver loses the numeric proof (4.7x), and the f64 accumulator chain is reassociable-by-proof (2.9x) #9363
Description
Two independent, individually-sound wins on the same shape, both measured on the quiet mini (min-of-3, SIZE=1e6 x ITER=100). The shape is acc += buf[i] — byte checksums, parsers, image loops, and bench_buffer_readwrite.
Measurements
Same loop, only the receiver's binding form differs:
| receiver | perry | node | |
|---|---|---|---|
module global (const buf at module scope) |
444 | 79 | 5.6x slower |
function-local const b = new Uint8Array(n) |
94 | 81 | 1.16x slower |
local const + --fast-math |
32 | 81 | 2.5x faster |
For reference, the same loop written s = (s + buf[i]) \| 0 runs in 3 ms vs node's 67 — so nothing about the memory traffic is slow; it is entirely what the accumulator's arithmetic is allowed to become.
(A) The module-global receiver loses the numeric proof — 444 vs 94
collectors/ptr_shape_numeric.rs's numeric_view_value_or_undefined proves view[i] is Number-or-undefined from either numeric_ta_views (spec-proven TaPtr parameters) or const_local_inits — a compiler-visible const init in the scanned body. A module-global receiver has neither, so:
- the accumulator never enters
number_by_construction_locals; expr_is_inert_primitive'sAddarm therefore fails on the accumulator operand;loop_purity::loop_may_allocateanswerstrue, so the inner loop keeps a per-iterationload volatile @PERRY_GC_POLL_ARMED+ branch;- that volatile load blocks vectorization AND pins the accumulator in memory (
load double/store doubleevery iteration); - and the
+itself lowers to aguarded_add.numeric/guarded_add.dynamicdiamond withwith_operands_rooted, adding a GC shadow-frame load + store +js_write_barrier_root_nanboxper element.
The IR difference is stark — viaLocalConst's inner loop is load i8; uitofp; fadd, while viaGlobal's carries the poll, the rooting pair, the barrier and the diamond.
Fix: admit a module-global receiver whose proven type is a numeric typed-array/Uint8Array kind, the way numeric_ta_views already admits a proven parameter. This is the same root-cause class as #9342 (module-global u8 receivers unproven) one level up — the read lane there, the numeric-proof lane here.
(B) The f64 accumulator chain is reassociable BY PROOF — 94 vs 32
With the poll gone, the clean loop is load i8; uitofp; fadd and runs at ~3 cycles/element: it is serialized on fadd latency. --fast-math breaks the chain into parallel partial sums: 94 -> 32 ms.
--fast-math is globally unsound and correctly off by default — but for this shape reassociation is exact, not approximate: every addend is a byte in [0, 255], a counted loop's trip count is bounded by its i32/u32 counter range, so the running sum is at most 255 * 2^32 ~= 1.1e12, far below 2^53. Every partial sum is an exactly-representable integer, so FP addition is associative here and any grouping yields bit-identical results. That is a proof about the value range, not a tolerance argument.
Fix: emit per-instruction reassoc on the accumulator's fadd when the reduction is proven integer-valued and trip-count-bounded. Same family as #9307's "an all-or-nothing contract can be STRONGER than source semantics require, and observably identical."
Two hypotheses that are DEAD — do not repeat them
- "The emitted read guard costs per element." Forcing fix(runtime,codegen): module-global Uint8Array receivers — recover elements on registry miss, inline the read, and restore the numeric proof (#9342, #9363) #9360's guard to always hit: 216 -> 218 ms. Free.
- "
--fast-mathdoesn't help this shape." It appears not to (131 -> 133) while the poll is in the loop — the volatile load blocks vectorization, so reassociation has nothing to work with. Only after establishing that the local-const loop has no inner poll does the 94 -> 32 show up. Measuring (B) before (A) is unblocked gives a false negative.
Combined
Module-global byte-sum loop: 444 -> ~32 ms against node's 79 — from 5.6x slower to 2.5x faster; bench_buffer_readwrite flips from the board's last read-side loser to a win.
Fixtures: recv_ab.ts (receiver A/B), acc_rep.ts (f64 / i32 / xor accumulator discriminator), both described in #9342.
Activity
perf(codegen): a module-global typed-array receiver earns the numeric…
proggeramlug commentedon Sep 1, 2026
(A) implemented and measured — plus a correction to this issue's own attribution
Shipped in #9360 (second commit): module-scope bindings whose CONSTRUCTION proves a number-valued typed-array kind now feed the same fixpoint slot as spec-proven
TaPtrparameters. Measured 444 -> 94 ms, exactly matching the body-local receiver, against node's 79. The receiver's binding form is no longer observable in the emitted loop.Correction: the GC poll was NOT part of the win
The issue text above attributed part of the cost to the per-iteration
load volatile @PERRY_GC_POLL_ARMEDblocking vectorization and pinning the accumulator in memory. That was wrong, and I only found out by building it.I implemented the poll removal too — admitting the read as inert under the same construction proof, so
loop_may_allocateanswersfalseand the poll leaves the loop. Result:--fast-math, the body-local receiver goes 94 -> 32 while the module-global one stays at 94, poll or no poll. The residual blocker is fix(runtime,codegen): module-global Uint8Array receivers — recover elements on registry miss, inline the read, and restore the numeric proof (#9342, #9363) #9360's per-element admission-cache probe — a control-flow diamond in the loop body.So the entire 4.7x is the rooting diamond:
guarded_add's shadow-frame load + store +js_write_barrier_root_nanboxper element, plus the dynamic-add cold arm. The poll is a passenger.That change is therefore not shipped.
expr_is_inert_primitivealso governs rooting decisions, and an unmeasured widening of a rooting-sensitive predicate is not worth carrying — the same rule that killed the guard hoist in #9342. It is written up in the test file so the next person doesn't rebuild it.What this changes about (B)
(B) is still real — 94 -> 32 is measured — but its precondition is now known to be hoisting #9360's admission-cache probe out of the loop, not removing the poll. That hoist measured flat on its own (216 -> 218) when I tried it as a scalar optimization; it only pays as a vectorization enabler, and only once the numeric proof from (A) is in place. Three interventions that are each worthless alone and worth 3x composed — worth stating plainly for whoever picks this up.
One more soundness obligation for (B), found while scoping it
collectors/loop_bounded_i32.rs's bounded-accumulator proof (#7123) is the natural home: it already proves|acc| <= |A0| + sum(T * M)and admitsMfor integer literals,x % literal,x & literal. A byte read has the tightest possible bound,M = 255, which would makesum += buf[i]over a constant-bounded loop an i32 accumulator outright — the 3 ms path, not merely the 32 ms one.But an out-of-range read yields
undefined, andundefinedin arithmetic is NaN, which an i32 accumulator cannot represent. So that admission needs an in-bounds proof for the read (or a deopt on the first OOB), not just a magnitude bound. That obligation does not exist for any of the currently admittedMforms, which is presumably why the list stops where it does.perf(codegen): vectorize bounded byte-sum reductions (bench_buffer_re…
perf(codegen): three independent commits from #9395 (#9433)
fix(runtime): module-global Uint8Array receivers, with the view-cache…