Repository navigation
Description
Summary
16_matrix_multiply (19.3× vs node) and 11_prime_sieve (17.8× vs node) share one cause, and it is not representation selection in the sense the repsel campaign has been measuring. It is a single codegen eligibility predicate:
crates/perry-codegen/src/expr/index_get.rs
fn numeric_index_needs_runtime_key(ctx: &FnCtx<'_>, object: &Expr, index: &Expr) -> bool {
is_numeric_expr(ctx, index)
&& !numeric_index_has_integer_array_index_proof(ctx, index)
&& !numeric_index_has_loop_array_index_proof(ctx, object, index)
}When this returns true, the entire access — read and write — lowers to the fully opaque js_array_get_index_or_string(i64, double) / js_typed_feedback_array_set_index_or_string(...) helpers. No inline header test, no inline load, nothing LLVM can see through. When it returns false, the access lowers to an inline guarded diamond with zero runtime calls on the fast path.
The proof required is int_range_expr(index) with min >= 0 && max <= i32::MAX (or the packed-loop fact matcher, which only admits i, i + c, c + i, i - c). A single numeric function parameter anywhere in the index expression has no range, so the whole access is demoted.
Evidence — 16_matrix_multiply
matmul(a: number[], b: number[], c: number[], size: number). Innermost k loop, --trace llvm, for.body.23:
%r103 = call double @js_array_get_index_or_string(i64 %r102, double %r100) ; a[i*size+k]
%r104 = call double @js_number_coerce(double %r103)
%r115 = call double @js_array_get_index_or_string(i64 %r114, double %r112) ; b[k*size+j]
%r116 = call double @js_number_coerce(double %r115)
%r117 = fmul double %r104, %r116
%r118 = fadd double %r92, %r1174 opaque calls per innermost iteration × 256³ = 67.1M calls.
The index itself is computed in double (sitofp i32 %i → fmul by the boxed size → fadd) and handed to the helper as a double, so the helper re-derives the element index at runtime on every access.
Evidence — 11_prime_sieve
Everything is top-level. The hot store sieve[j] = false (j = i*i; j = j + i, ~2.12M executions in the timed region) is for.body.44:
%r264 = call i64 @js_typed_feedback_array_set_index_or_string(i64 ..., i64 %r263, double %r259, double %r261)
%r266 = bitcast i64 (or %r264, 0x7FFE...) to double
store double %r266, ptr %r8 ; re-anchor the (possibly moved) arrayNote the contrast inside the same program: the initialisation loop sieve[i] = true and the count loop if (sieve[i]) both take the inline idxset.guarded.* / arr.guard.deref path, because i is a 0..LIMIT counter with a non-negative range fact. Only the j = i*i; j += i counter lacks one, and only that access is opaque. The mechanism is visible as a within-program A/B.
The count loop is not free either — per element it runs a 14-term guard including a load volatile i8 @PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED (LICM can never hoist it) plus three header byte loads at -8/-7/-6 and two length loads, and then calls js_is_truthy(double) once per element.
Measured levers (all in-repo compile, auto-optimize on, release, checksums verified identical)
| variant | perry ms | node ms | vs baseline |
|---|---|---|---|
16_matrix_multiply baseline |
631 | 32 | — |
stride is a module const (i * SIZE + k) |
75 | 32 | 8.4× |
index masked (i*size+k) & 0x7fffffff |
59 | 30 | 10.7× |
| same math written at module top level | 58 | 33 | 10.9× |
index | 0 — (i*size+k) | 0 |
659 | 29 | none |
const s = size | 0 then i*s+k |
662 | 33 | none |
Float64Array params, size param |
499 | 29 | 1.3× |
11_prime_sieve baseline |
107 | 5 | — |
store index masked sieve[j & 0x7fffffff] |
27 | 5 | 4.0× |
Uint8Array instead of boolean[] |
52 | 5 | 2.1× |
So the available win is 631 → 59 ms on matrix_multiply (19.3× behind node → 1.8×) and 107 → 27 ms on prime_sieve (21× behind → 5.4×). Neither requires a new representation, a new ABI, or unboxing anything — only a range fact.
IR confirmation for the masked matmul: the innermost body contains zero js_* calls; every js_typed_feedback_* call sits on a cold/fallback edge. 631 − 59 = 572 ms over 67.1M removed calls ≈ 8.5 ns (~27 cycles) per opaque call, which is what a non-inlinable helper that re-derives a double index and re-checks the array header costs.
The nuance that matters for #7244's design
It is not about the index being an i32. (i*size+k) | 0 is an i32 and buys nothing, because | 0 is ToInt32 with range [-2^31, 2^31-1] whose min < 0 fails min >= 0. Likewise const s = size | 0 produces a genuine i32 slot (%r11 = alloca i32 in the IR) and the access is still opaque.
What is missing is non-negativity and an upper bound, not integerness. Concretely:
int_range_exprhas no interprocedural range for numeric parameters.matmul(..., size: number)arrives asdouble %arg11with no fact attached, and one unbounded leaf poisonsi * size + k.numeric_index_has_integer_array_index_proofspecial-casesBitAndmasks only (bitand_has_nonnegative_i32_mask);>>> 0(range[0, 2^32-1], max >i32::MAX) and| 0both fail.- The packed-loop matcher
packed_f64_loop_index_partsadmits onlyi,i ± cwith|c| <= 64. It cannot seei * stride + k— the single most common dense-2D indexing shape — nor a strided induction variable (j += i).
Three independently shippable levers, cheapest first:
- (a) Monotone induction range for strided counters.
for (let j = i*i; j < LIMIT; j = j + i)withi >= 2andLIMITa positive constant provesj ∈ [0, LIMIT). This alone is the whole11_prime_sievewin (4×). perf(repsel): admit constant-bounded loop induction variables to canonical i32 (#7110) #7122's monotone loop-induction interval already exists for thei32promotion decision; it is not consulted here. - (b) Affine index proof.
a * b + cwhere each leaf carries a non-negative range and the product does not exceedi32::MAX. This is the16_matrix_multiplywin (10.7×) once (c) suppliessize. - (c) Interprocedural range summaries for numeric params. A callee-side range for a parameter used only as an array stride/bound, meet over all call sites,
Boxed/unknown on any unresolved caller. This is the piece the RFC's §5.2 interprocedural summaries would provide and is what makes (b) fire on real code instead of only on module-const strides.
Relation to existing work
This is the Array<number> (Ptr<NumArray>) row of docs/representation-selection-rfc.md §4, Phase 4a — but the blocker is not the element representation (Perry already lowers a numeric element to a raw in-place f64 on the inline path). The blocker is the index-side range proof that gates entry to that path at all. Filing here rather than under #7034 / #7151 because those track Ptr<Shape> receivers; this is the index expression.
Also note for the #7128 scoreboard: this is a case where "opaque js_* calls removed from hot paths" is exactly the right metric — 67.1M calls removed buys 10.7×.
What this is NOT
- Not fix(codegen): remove the unproven i64 function specialization (#7238) #7242. The deleted i64 function-specialization pass cannot have affected either kernel:
matmulisdefine double @perry_fn__..._matmul(double, double, double, double)(all params boxed doubles, no i64 anywhere), and11_prime_sievehas no user functions at all — the whole program ismain. There is no candidate for i64 specialization in either. Consistent with05_fibonacci(a single-numeric-param recursive function) being the one that paid the ~20%. - Not GC.
PERRY_GC_DIAG=1shows zero collections in the timed region of both. - Not LLVM codegen quality. With the range proof supplied the same source, same compiler and same
-O3produce 59 ms.
Repro
# baseline
target/release/perry benchmarks/suite/16_matrix_multiply.ts -o mm && ./mm
# masked index — identical checksum (41079519680)
sed 's/a\[i \* size + k\]/a[(i * size + k) \& 0x7fffffff]/; s/b\[k \* size + j\]/b[(k * size + j) \& 0x7fffffff]/; s/c\[i \* size + j\]/c[(i * size + j) \& 0x7fffffff]/' \
benchmarks/suite/16_matrix_multiply.ts > mm_masked.ts
target/release/perry mm_masked.ts -o mm_masked && ./mm_maskedMeasured on Apple M1 Max, macOS 26.5, perry 0.5.1279 @ defa4d601, auto-optimize on, node v22.23.1. Host was not quiet (a browser process was consuming ~1 core); the baseline figures reproduce benchmarks/results/public-node-bun-v1.json medians within 2% (631 vs 637, 107 vs 107, 22 vs 22), so the deltas above are sound, and the primary evidence is IR and static call counts rather than wall clock.
Activity
proggeramlug commentedon Aug 3, 2026
Companion issues from the same 2026-08-03 diagnosis of the three worst suite gaps: #7286 (array index range proof — matrix_multiply/prime_sieve), #7287 (method_calls is guard-bound, not dispatch-bound), #7288 (build determinism — method_calls' published 79 ms only reproduces inside the checkout).
perf(codegen): prove array-index ranges for strided counters and cons…
perf(codegen): inline canonical-index array stores without a range proof
perf(codegen): inline canonical-index array stores without a range proof