Skip to content

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

@proggeramlug

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_initsa 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."

Two hypotheses that are DEAD — do not repeat them

  1. "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.
  2. "--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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions