Repository navigation
matmul is 3.3-4.5x node: the packed-f64 range guard never engages for affine indices or a second array, so every element read carries ~40 instructions of guard #9248
Description
benchmarks/suite/16_matrix_multiply.ts runs 1006 ms against node's 304 ms (3.31×) on the quiet host (256×256, 10 reps, best of 3, stable to 0 ms across runs). Root-caused to codegen, with the runtime side already providing what a fix needs.
Where the time goes
A profile is unambiguous: 4653 of 4655 samples are inside matmul itself, with no runtime helper on the hot path. So this is not dispatch overhead — it is what the loop body compiles to.
Disassembling one element read of a[i * size + k]:
fadd d0, d13, d10 ; index computed in FLOATING POINT
fcvtzs x26, d0 ; ...then converted to integer
mov w8, #0x7ffd ; POINTER_TAG check
cmp x8, x22, lsr #48
b.ne <slow>
and x8, x22, #0xffffffffffff
lsr x9, x8, #20
cbz x9, <slow>
ldurb w9, [x8, #-0x8] ; header byte
ldursb w10,[x8, #-0x7]
ldr x9, [x8] ; forwarding indirection
csel x8, x9, x8, eq
sub x9, x8, #0x100, lsl #12 ; address-range check
cmp x9, x10
b.hi <slow>
ldurb w12, [x8, #-0x8] ; header bytes RELOADED
ldursb w11,[x8, #-0x7]
ldurh w9, [x8, #-0x6]
adrp x10, ... ; global flag load
ldrb w13, [x10]
ldp w10, w14, [x8] ; length + capacity
... 8 further compare/branch guards ...
cmp w10, w26 ; bounds check
b.ls <slow>
ubfiz x9, x26, #3, #32 ; the actual load, at last
add x8, x9, x8
ldr d0, [x8, #0x8]
fcmp d0, d0 ; NaN canonicalisation
fcsel d8, d0, d15, vcRoughly 40 instructions around one ldr d0, twice per inner iteration, none of it hoisted. Measured: 167M inner iterations, 8.2 ns/iter (~26 cycles) against node's 1.8 ns (~6 cycles).
The machinery already exists and works — it just does not engage here
The same compiler emits an excellent loop for for (k) s += a[k]:
bl _js_typed_feedback_packed_f64_range_loop_guard ; ONCE, in the preheader
cbz w0, <slow>
and x8, x20, #0xffffffffffff
add x20, x8, #0x8
loop:
ldr d10, [x20] ; raw load
fmov x8, d10
cmp x8, x21 ; hole check
b.eq <hole>
tst x19, #0x3f ; safepoint poll every 64th
b.ne 1f
1: fadd d9, d9, d10
add x19, x19, #0x1
add x20, x20, #0x8 ; pointer bumpmatch_packed_f64_versioned_loop declines matmul for two structural reasons:
- Single array. It carries one
hoist.arr_id. Matmul's inner loop readsaandb. - Bare-counter index only. It requires
arr[counter]; matmul hasa[i*size + k]andb[k*size + j].
Measured separately, both matter:
| variant | perry | node | ratio |
|---|---|---|---|
| params + computed index (as written) | 1374 | 304 | 4.52× |
| module-level arrays + computed index | 1611 | 299 | 5.39× |
params + bare counter (a[k]*b[k]) |
668 | 175 | 3.82× |
| module-level + bare counter | 671 | 176 | 3.81× |
one array, one read (s += a[k]) |
29 | 9 | 3.2× |
So the computed index costs ~2× on its own, a second array costs ~2.5× (60 vs 24 ms in isolation), and they compound. Parameter-vs-module-global is not a factor.
Why this should be tractable
The runtime guard is already range-based. js_typed_feedback_packed_f64_range_loop_guard_dense(site_id, receiver, min_idx, max_idx_exclusive) validates an index range, not a single index — which is exactly what an affine access pattern needs. For a[base + stride*k] with k in [0, n), the accessed range is computable in the preheader from values already loop-invariant there.
So the change is codegen-side only:
- Accept index expressions affine in the counter —
inv,inv + counter,inv + counter*inv— rather than only the bare counter. - Allow multiple arrays per loop: emit one range guard per distinct array in the preheader, instead of one
arr_id. - Derive each array's
[min, max)from its affine form and the loop bounds.
That turns the inner loop into two raw loads, a multiply and an add.
Two smaller findings, recorded separately
- The index is computed in f64 and converted (
fadd/fcvtzs) even though the loop maintains i32 counters (w25/w8/w27) in parallel. Forcing| 0in the source changes nothing, so this is not reachable from user code. - Even the good path is ~3× node on
s += a[k]— the residual is a per-iteration hole check and a safepoint poll that node does not emit, plus no unrolling. Worth its own look once the guard is hoisted, since it becomes the dominant term.
Hand-hoisting the row base in source (const rowA = i*size; … a[rowA + k]) makes perry worse (1795 ms), so there is no source-level workaround to recommend.
Activity
proggeramlug commentedon Aug 31, 2026
Correction to my own analysis above, and a better lead.
I implemented the affine-index widening (loop-invariant base + counter) on the #6011 range matcher. It is correct — 7 adversarial cases node-identical, including a base assigned mid-loop, a negative base, a base that pushes the window past
i32::MAX, and two different bases for one array — but it is inert: the benchmark does not move (hoisted_base1795 ms before and after). I am not proposing it.The reason is more interesting than the change. Measuring three index forms against the same module-level array, in one binary, on the quiet host:
s = s + a[k]s = s + a[k + 8]s = s + a[base + k]And what each compiles to:
a[k]js_typed_feedback_packed_f64_range_loop_guardfadda[k + 8]js_dynamic_string_or_number_adda[base + k]js_typed_feedback_array_index_get_fallback_boxeda[k ± c]is the form the range matcher documents as supported — "every array access in the body isa[i]/a[i ± c](|c| ≤ 64)" — andc = 8is well inside the cap. It is not reaching the fast path, and its accumulator is degrading all the way to a dynamic string-or-number add, which is why it costs 4× whata[k]does rather than the one extraaddthe index implies.So the affine widening was the wrong end to start. Something rejects the already-supported constant-offset form before any of my new code runs, and finding that is both smaller and strictly prerequisite: an affine base cannot help while
a[k + 8]itself falls off. It also means the "multiple arrays already supported" claim in my analysis above is unverified in practice — I confirmed it in the source, not in emitted code.Next step for whoever picks this up: determine why
match_packed_f64_range_loopdecliness = s + a[k + 8]. Thepacked_loop_rejecttrace (#9204) coversmatch_packed_f64_versioned_looponly and prints nothing for the range matcher, so that matcher needs the same treatment first — three separate attempts on #9151 were lost to guessing which gate declined a loop, which is exactly what the trace was added to stop.test(codegen): pin matrix multiply affine fast path (#9248)