Skip to content

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

@proggeramlug

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:

  1. Single array. It carries one hoist.arr_id. Matmul's inner loop reads a and b.
  2. 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:

  1. Accept index expressions affine in the counterinv, inv + counter, inv + counter*inv — rather than only the bare counter.
  2. Allow multiple arrays per loop: emit one range guard per distinct array in the preheader, instead of one arr_id.
  3. 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.

Activity

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