Repository navigation
16_matrix_multiply: the inline array guard is loop-invariant but re-run every iteration (97% of time in generated code) #9253
Description
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:
- the bound is a parameter (
k < size), notarr.length, soclassify_for_length_hoist_impldeclines. (classify_for_local_boundexists 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.) - 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. - there are three receivers (
a,bread;cstored), 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.
Activity
codegen: affine indices earn the range clone's hoisted receiver guard (…