You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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's Add arm therefore fails on the accumulator operand;
loop_purity::loop_may_allocate answers true, so the inner loop keeps a per-iteration load volatile @PERRY_GC_POLL_ARMED + branch;
that volatile load blocks vectorization AND pins the accumulator in memory (load double / store double every iteration);
and the + itself lowers to a guarded_add.numeric / guarded_add.dynamic diamond with with_operands_rooted, adding a GC shadow-frame load + store + js_write_barrier_root_nanbox per 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."
"--fast-math doesn'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.
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, andbench_buffer_readwrite.Measurements
Same loop, only the receiver's binding form differs:
const bufat module scope)const b = new Uint8Array(n)--fast-mathFor reference, the same loop written
s = (s + buf[i]) \| 0runs 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'snumeric_view_value_or_undefinedprovesview[i]is Number-or-undefinedfrom eithernumeric_ta_views(spec-provenTaPtrparameters) orconst_local_inits— a compiler-visibleconstinit in the scanned body. A module-global receiver has neither, so: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;load double/store doubleevery iteration);+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 isload i8; uitofp; fadd, whileviaGlobal'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/
Uint8Arraykind, the waynumeric_ta_viewsalready 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; faddand runs at ~3 cycles/element: it is serialized on fadd latency.--fast-mathbreaks the chain into parallel partial sums: 94 -> 32 ms.--fast-mathis 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 most255 * 2^32 ~= 1.1e12, far below2^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
reassocon the accumulator'sfaddwhen 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
--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_readwriteflips 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.