Skip to content

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

Description

@proggeramlug

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, %r117

4 opaque calls per innermost iteration × 256³ = 67.1M calls.

The index itself is computed in double (sitofp i32 %ifmul by the boxed sizefadd) 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) array

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.

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:

  1. 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.
  2. 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.
  3. 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.

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_masked

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    performanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions