Skip to content

16_matrix_multiply: the inline array guard is loop-invariant but re-run every iteration (97% of time in generated code) #9253

Description

@proggeramlug

benchmarks/suite/16_matrix_multiply.ts is 3.1× Node on the Mac mini (100 ms vs 32) and 4.2× on the Linux box (96 vs 23). The interesting part is where the time is not.

There are no runtime calls to remove

97.33%  perry_fn__16_matrix_multiply_ts__matmul$spec_b_b_b_i32
 0.96%  [k] clear_page_erms
 0.29%  js_array_grow

Everything is inside the generated function. The typed-feedback guard calls visible in the IR are cold-arm bookkeeping; the steady state already takes the inline read tier (arr.guard.deref / .live / .range). So this is a code-quality problem, not a call-elimination one — worth stating because the IR call counts suggest otherwise at a glance.

What the inner loop actually executes

perf annotate on that symbol, hottest instructions:

2.32  cmp    $0xf42400,%r9d      ; 16,000,000 — the length_sane bound
2.08  cmp    $0xf42400,%r9d      ; …and capacity_sane
1.82  mov    (%rax),%eax         ; header length load
1.32  movzwl -0x6(%rax),%ecx     ; _reserved flags word
1.31  movabs $0x7ffd000000000000 ; NaN-box tag mask
1.56  test   %r8b,%r8b           ; integrity / prototype flags
1.56  vcvttsd2si %xmm0,%r14      ; index double→i32

The vmulsd/vaddsd doing the actual work are nowhere near the top. Per iteration, for both a and b, the code re-derives: pointer tag check, handle-band check, header dereference, _reserved flag tests, the two 16M sanity comparisons, and the bounds check — for receivers that are loop-invariant parameters whose headers cannot change inside the loop.

The IR tells the same story: the specialised function has 27 shadow-root stores and 5 atomic PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT loads to accomplish 2 load doubles and the multiply-add.

LLVM cannot hoist any of it: the guard reloads the header through a pointer it cannot prove unaliased, and the atomic read is a motion barrier.

Why the existing hoisting machinery does not apply

The versioned packed clone exists to do exactly this — guard once at loop entry, raw loads inside — but matmul's inner loop is not admitted, for three independent reasons:

  1. the bound is a parameter (k < size), not arr.length, so classify_for_length_hoist_impl declines. (classify_for_local_bound exists but serves a different optimisation — making the loop condition an i32 compare, issue i32 loop counter not specialized when loop bound is a 'number' parameter — blocks LoopVectorizer on Buffer-read hot paths #168 — not clone admission.)
  2. the indices are compound and unrelated to the counter (a[i * size + k]), so the clone's counter-keyed read fact does not fit; these need a per-read bounds check, the mechanism perf(codegen): packed clones read a foreign counter inline (10_nested_loops 51 → 17 ms, Node parity) #9161 introduced for foreign counters.
  3. there are three receivers (a, b read; c stored), where the clone guards one.

Shape of the fix

Generalise the versioned clone so a counted loop over loop-invariant receivers can hoist their guards into the preheader and keep only a per-read icmp ult idx, len inside — the structure #9161 already emits for a foreign counter, extended to (1) a loop-invariant local/param bound and (2) arbitrary proven-i32 index expressions, over (3) a receiver set.

That is a real piece of work rather than a tweak, which is why this is an issue and not a PR. Filing it with the measurement so the next attempt starts from the instruction-level cause rather than from the IR's misleading call counts.

https://claude.ai/code/session_012Ys25ni6VwDKE71o1NTYAT

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