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

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.

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