You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
perf: array element access falls to the opaque runtime-key helper whenever the index has no [0, i32::MAX] range proof — 10.7x on 16_matrix_multiply, 4x on 11_prime_sieve #7286
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:
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.
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:
Note 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.
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.
It is not about the index being an i32.(i*size+k) | 0is 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_expr has no interprocedural range for numeric parameters. matmul(..., size: number) arrives as double %arg11 with no fact attached, and one unbounded leaf poisons i * size + k.
numeric_index_has_integer_array_index_proof special-cases BitAnd masks only (bitand_has_nonnegative_i32_mask); >>> 0 (range [0, 2^32-1], max > i32::MAX) and | 0 both fail.
The packed-loop matcher packed_f64_loop_index_parts admits only i, i ± c with |c| <= 64. It cannot see i * 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) with i >= 2 and LIMIT a positive constant proves j ∈ [0, LIMIT). This alone is the whole 11_prime_sieve win (4×). perf(repsel): admit constant-bounded loop induction variables to canonical i32 (#7110) #7122's monotone loop-induction interval already exists for the i32 promotion decision; it is not consulted here.
(b) Affine index proof.a * b + c where each leaf carries a non-negative range and the product does not exceed i32::MAX. This is the 16_matrix_multiply win (10.7×) once (c) supplies size.
(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: matmul is define double @perry_fn__..._matmul(double, double, double, double) (all params boxed doubles, no i64 anywhere), and 11_prime_sieve has no user functions at all — the whole program is main. There is no candidate for i64 specialization in either. Consistent with 05_fibonacci (a single-numeric-param recursive function) being the one that paid the ~20%.
Not GC.PERRY_GC_DIAG=1 shows zero collections in the timed region of both.
Not LLVM codegen quality. With the range proof supplied the same source, same compiler and same -O3 produce 59 ms.
Measured 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.
Summary
16_matrix_multiply(19.3× vs node) and11_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.rsWhen this returns
true, the entire access — read and write — lowers to the fully opaquejs_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 returnsfalse, the access lowers to an inline guarded diamond with zero runtime calls on the fast path.The proof required is
int_range_expr(index)withmin >= 0 && max <= i32::MAX(or the packed-loop fact matcher, which only admitsi,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). Innermostkloop,--trace llvm,for.body.23:4 opaque calls per innermost iteration × 256³ = 67.1M calls.
The index itself is computed in
double(sitofp i32 %i→fmulby the boxedsize→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) isfor.body.44:Note the contrast inside the same program: the initialisation loop
sieve[i] = trueand the count loopif (sieve[i])both take the inlineidxset.guarded.*/arr.guard.derefpath, becauseiis a0..LIMITcounter with a non-negative range fact. Only thej = i*i; j += icounter 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/-6and two length loads, and then callsjs_is_truthy(double)once per element.Measured levers (all in-repo compile, auto-optimize on, release, checksums verified identical)
16_matrix_multiplybaselineconst(i * SIZE + k)(i*size+k) & 0x7fffffff| 0—(i*size+k) | 0const s = size | 0theni*s+kFloat64Arrayparams,sizeparam11_prime_sievebaselinesieve[j & 0x7fffffff]Uint8Arrayinstead ofboolean[]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; everyjs_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) | 0is an i32 and buys nothing, because| 0is ToInt32 with range[-2^31, 2^31-1]whosemin < 0failsmin >= 0. Likewiseconst s = size | 0produces a genuine i32 slot (%r11 = alloca i32in 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.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:
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.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.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 ofdocs/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-placef64on 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 trackPtr<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
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%.PERRY_GC_DIAG=1shows zero collections in the timed region of both.-O3produce 59 ms.Repro
Measured 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 reproducebenchmarks/results/public-node-bun-v1.jsonmedians 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.