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, vc
Roughly 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 bump
match_packed_f64_versioned_loop declines matmul for two structural reasons:
- Single array. It carries one
hoist.arr_id. Matmul's inner loop reads a and b.
- Bare-counter index only. It requires
arr[counter]; matmul has a[i*size + k] and b[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 | 0 in 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.
benchmarks/suite/16_matrix_multiply.tsruns 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
matmulitself, 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]:Roughly 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]:match_packed_f64_versioned_loopdeclines matmul for two structural reasons:hoist.arr_id. Matmul's inner loop readsaandb.arr[counter]; matmul hasa[i*size + k]andb[k*size + j].Measured separately, both matter:
a[k]*b[k])s += a[k])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. Fora[base + stride*k]withkin[0, n), the accessed range is computable in the preheader from values already loop-invariant there.So the change is codegen-side only:
inv,inv + counter,inv + counter*inv— rather than only the bare counter.arr_id.[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
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.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.