Skip to content

perf: accumulate-over-reads loops pay a per-element rooting diamond when the read isn't served by a proven tier #9361

Open

Description

@proggeramlug

Split out of #9342, where it is measured and the wrong attribution is ruled out.

The shape

const buf = new Uint8Array(N);          // module-global
function f(): number {
    let s = 0;
    for (let i = 0; i < N; i++) s += buf[i];
    return s;
}

After #9360 the read itself is an inline guarded byte load, and the guard is free — forcing it to always hit moves 216 ms to 218 ms. The per-element cost that remains is the accumulator:

%acc.rs4p = load ptr addrspace(1), ptr %slot   ; accumulator, from the GC shadow frame
...  guarded_add.numeric / guarded_add.dynamic diamond ...
store ptr addrspace(1) null, ptr %slot

plus a js_write_barrier_root_nanbox per iteration. The equivalent top-level loop, whose receiver IS a tracked view, has none of it: bare fadd, register accumulator, 49 ms vs node's 44.

Why

lower_guarded_numeric_add roots every leaf that expr_produces_canonical_raw_f64 will not vouch for, because the cold arm can call js_dynamic_string_or_number_add. That predicate admits IndexGet under exactly one condition — stable_packed_loop::has_numeric_index_fact — and does not match Uint8ArrayGet at all. It cannot: the node's value is a byte or undefined for an out-of-range index (#6884, correct ECMAScript semantics — not the registry-miss bug #9342 fixed; those are two different undefineds that happen to share a tag).

So the read has to become vouchable, which needs both halves:

  1. A numeric fact for the region, the way the stable-packed tier already produces one — that is what makes the read vouchable and, inductively, the accumulator's Binary node numeric.
  2. Number-context OOB canonicalization — in arithmetic context the OOB value must already become canonical NaN (number + typedArray[oob] yields undefined instead of NaN (ToNumber(undefined) not applied in + ) #6884), so a number-context variant of the read lane can emit NaN instead of the undefined box and yield a genuine canonical raw f64. Value context keeps undefined.

With both, the diamond disappears: no rooting, no shadow-frame traffic, no per-element barrier, plain fadd.

Why this is worth more than one row

This is a statement about a predicate, not about Uint8Array. Any accumulate-over-reads loop whose read is not served by a proven tier pays the same per-element shadow-frame load + store + write barrier + tag-test diamond. The u8 case is just where it was measured.

Also here

function f(b: Uint8Array) — a typed-parameter receiver — never admits the inline lane at all (576 ms, unchanged by #9360): the class proof comes back empty for a declared-type parameter, where a module-global gets one from module_global_proven_types.

Caution

expr_produces_canonical_raw_f64's soundness contract is load-bearing and scarred: its doc records a case where skipping the accumulator leaf summed 16zw1113151719 down to 16zw. Any widening needs the #9303 treatment — peel-as-contract at match time, then post-verify every write with the lowering's own collector — not a static type check.

Fixture and harness: buf_ctx.ts shape in #9342; measurement discipline and the forced-guard discriminator are recorded there.

Activity

  1. proggeramlug commented on Sep 1, 2026

    @proggeramlug
    ContributorAuthor

    Confirmed on a second, independent row — and with a soundness asymmetry the buffer case didn't have

    bench_typed_array_untyped_access is the board's last row above node, and after fixing its read lane the residual is exactly this issue.

    What its hot body still contains (encipherTyped, S and P declared Int32Array):

    32  js_write_barrier_root_nanbox
     6  js_dynamic_string_or_number_add
     6  js_dynamic_bitxor
    10  js_number_coerce
    

    The reads themselves are now inline (a separate fix let a declared typed-array parameter reach the checked load — receiver_class_name reads only proven_local_types, which is always empty for a parameter, so 0 inline blocks vs 66 for a module-global receiver). That change fires here and is flat: 1216 ms before, 1216 after. On a fixture where reads dominate, the same change is worth 2.4x (576 -> 235 ms). The reads were never this row's cost.

    The cost is n = S[...]; n += S[...]; n ^= S[...] — an accumulator fed by typed-array reads, with no numeric proof, so every operator is a dynamic helper and the local is shadow-frame rooted per iteration. Same mechanism as the buffer row, second workload.

    The asymmetry that blocks the obvious fix

    For the buffer row, admitting a module-scope construction proof was safe because module_global_proven_types is derived from the initializer of a never-reassigned binding. For the read lane on parameters, admitting the DECLARED type is safe for a different reason: the emitted guard re-derives the truth, so a lying annotation costs a missed speedup.

    Neither argument transfers to the numeric proof. A numeric proof licenses removing rooting and treating the value as a raw f64. If a declared Int32Array parameter actually holds something else, the read can fall through to [[Get]] and return a pointer — and an unrooted pointer treated as a double is a collected live object, not a slow path. So the guard that makes the read lane safe does not make the numeric lane safe, and this must not be fixed by reaching for the same trick twice.

    A sound formulation likely exists via the number-context path (#6884): in arithmetic context the slow arm already applies js_number_coerce, so the value genuinely cannot be a pointer there. That would make the proof conditional on the consuming context rather than on the declaration — which is a different and larger change than the one this row first appears to need.

    Recording this here rather than attempting it, so the next attempt starts from the obstacle instead of rediscovering it.

  2. proggeramlug commented on Sep 1, 2026

    @proggeramlug
    ContributorAuthor

    bench_typed_array_untyped_access — what is now ruled IN and OUT

    The row is the board's last above node (perry 1216 typed / 1254 untyped vs node 290 / 299). Its ratio metric is already at parity (1.031 vs node's 1.031 — #5525's gap is closed); the remaining problem is a flat ~4.2x on both paths, which that metric structurally cannot express (see #9374).

    Ruled OUT — do not repeat these

    1. The reads are not the cost. They are now inline: the specialized clone's loop does load i32 + llvm.assume(in_bounds) + native index math with zero guard calls, and after the declared-parameter fix the generic body has 132 inline ctaf.get blocks where it had 0. That fix FIRED and measured flat (1216 -> 1216) here, while being worth 2.4x on a fixture where reads dominate (576 -> 235 ms).
    2. The wrong function was analysed first. perry_fn_..._encipherTyped is the GENERIC clone; the hot path calls encipherTyped$spec_b_i32_ta4x18_ta4x1024. All the dynamic add / bitxor / write-barrier counts people (including me) have quoted from the generic body describe code that does not run. The spec clone is clean.
    3. The spec-ABI proof is not missing. PERRY_REPSEL_DEBUG=1 shows spec entry 'encipherTyped' tuple=[b,i32,ta4x18,ta4x1024] and canonical-I32 promotion for l, r, i. S/P are call-site-proven TaPtr params, so their reads are soundly Number-or-undefined — no declaration is being trusted.

    The remaining candidate, and why it is NOT yet tested

    In the spec clone the accumulator n is an f64 slot holding only int32 values, so each S-box step emits sitofp in and llvm.aarch64.fjcvtzs out around the fadd. collectors/int_valued_ta_locals.rs exists for exactly this function (its module doc is bcryptjs _encipher) but rejects n: its wrap-i32 additive arm is admitted only for a STRAIGHT-LINE (never in-loop) Add/Sub tree, and n += S[...] is inside the while.

    The exclusion's stated reason is that an unbounded in-loop chain lets the true f64 value pass 2^53, where it rounds while an i32 slot wraps — and rule (2) only guarantees the ToInt32 image is observed. But _encipher re-seeds n every round (n = S[l >>> 24], then at most two += before a bitwise write), so its chain is bounded at ~2^33 and the two representations stay identical. A refined admission — an in-loop additive write is admissible when a non-additive i32-producing write to the same local dominates it within the loop body, with a bounded number of additive writes between re-seeds — would cover this shape soundly.

    I did not confirm this pays, and one obvious test does not. Rewriting the source to n = (n + S[...]) | 0 made it slower (1216 -> 1859), but that is not a test of the hypothesis: |0 in source adds ToInt32 operations, while the proposal changes the representation and removes conversions. The proxy differs in exactly the variable under test. Confirming it requires building the representation change.

    Profile

    sample on the running binary: 832 of 1362 samples in a single ~1.4 KB function with time spread across its instructions and no dominant helper call. Consistent with "the loop does uniformly more work per element", not with one fixable hotspot.

  3. proggeramlug commented on Sep 1, 2026

    @proggeramlug
    ContributorAuthor

    Follow-up on the proposed number-context proof. Outcome: I do not think that widening expr_produces_canonical_raw_f64 from this fact is sound, so I am not posting a patch.

    I verified both premises:

    1. collectors/number_by_construction.rs does not derive its read fact from lowering context. It seeds numeric typed-array receivers from spec_ta_lens / SpecParamRep::TaPtr and compiler-visible construction and length facts. Declared types are explicitly not evidence, and there is no number_context input to this collector.

    2. In js_number_coerce, I found no path where a recognized Perry POINTER_TAG input is returned as a pointer: the pointer arm converts recursively to a primitive Number or throws. That answers the GC-corruption question for ordinary Perry pointers. It still is not a universal canonical-raw-f64 function, however. The final else { value } leaves unrecognized tags unchanged; notably JS_HANDLE_TAG is not handled and an ordinary [[Get]] can produce one. That violates the stronger contract of expr_produces_canonical_raw_f64, even though it is not a Perry-GC pointer.

    The decisive blocker is earlier: JS + is not a Number context. lower_guarded_numeric_add deliberately preserves that by lowering every leaf once through plain lower_expr, testing the resulting tags, then choosing either the fadd tree or the exact dynamic-add tree. If a declared Int32Array receiver is actually an array containing "5", let n = 0; n += a[0] must produce the string "05". Node confirms the oracle: the actual expression is {type:"string", value:"05"}, while coercing the read first gives numeric 5.

    Therefore the two possible local changes are both wrong:

    The accumulator whole-write proof cannot admit the dynamic slow arm either: that arm can produce a GC-managed string/BigInt, so removing its shadow root remains unsafe.

    A sound shape needs versioning: guard the actual receiver kind and required bounds before entering a numeric fast loop/region, keep the generic clone byte-for-byte semantic, and after peeling verify the fast body with the same accumulator collector used by lowering. The existing stable-packed/range machinery demonstrates the required peel-as-contract plus post-verification discipline. A read-site guard whose miss rejoins the same loop is not enough, because a later miss can change the accumulator representation.

    Audit basis: main dcf1ec0fbc24197b85215e7074c59f14e6910ace, plus the relevant declared-read / registry-miss changes in local refs 2dc10260f5 and aed730eb66. No source files were changed; in particular none of the #9360/#9395 files were touched.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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