Skip to content

perf(codegen): lower Math.imul and typed-array-param reads to native ops#6850

Merged
proggeramlug merged 3 commits into
mainfrom
perf/native-imul-typed-array-param
Jul 26, 2026
Merged

perf(codegen): lower Math.imul and typed-array-param reads to native ops#6850
proggeramlug merged 3 commits into
mainfrom
perf/native-imul-typed-array-param

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Two JS integer-math primitives that should be single machine instructions were
compiled as runtime function calls, and they cascaded through the i32
type-flow to poison a whole hot loop into slow f64 ToInt32 arithmetic. This
lands both native lowerings (they reinforce each other — fixing one alone leaves
the cascade intact):

A. Math.imul(a, b) -> native mul i32 when both operands are provably
in-range i32. Multiplication mod 2^32 has identical low 32 bits for signed and
unsigned operands, so this is exact. Non-finite / fractional / >2^32 operands
keep the js_math_imul runtime helper (a bare fptosi would break JS
ToUint32/ToInt32: NaN->0, Inf->0, truncation).

The accumulator case a = Math.imul(a, 0x9e3779b1) did not lower before:
the mixer constant 0x9e3779b1 = 2654435761 exceeds i32::MAX, so the i32
fast-path leaf check rejected it. Because Math.imul is defined as
ToInt32(ToUint32·ToUint32 mod 2^32), an integer-literal operand is exact under
low-32 truncation even above i32::MAX, so the imul operand check now
accepts 32-bit-representable literals. The relaxation is deliberately confined
to Math.imul operands: a plain x * BIGLIT | 0 evaluates its product in f64
(precision loss above 2^53) and must stay ToInt32(f64_product), never an exact
mul i32.

B. Reading a typed-array element through a parameter (S[i] where
S: Int32Array etc. is a function parameter, in an i32/ToInt32 context) ->
checked inline native load. Perry already emitted bare loads for typed-array
locals with proven bounds (#6750); a parameter's length and inline-vs-view
storage are unknown at compile time, so it fell back to js_typed_array_get.
The new lowering guards a bare width-correct load at runtime (pointer +
inline-storage PERRY_TA_VIEW_GUARD + kind-cache + header-length bounds check);
an in-kind out-of-bounds read yields 0 (== ToInt32(undefined), the only
observable value in that context); and every rejected shape
(view/detached/resizable backing, wrong runtime kind) defers to a new
js_typed_array_read_int32 runtime fallback. Plain-value parameter reads still
observe undefined out of bounds.

Measurements (/tmp/mix/mix.ts, 40M-iteration Int32Array-parameter bit-mixer)

Structural (load-independent), in perry_fn_mix:

metric before after
js_math_imul calls 3 0
js_typed_array_get calls 3 0
js_dyn_index_get calls 0 0
sitofp/fptosi/select ~60 18 (i32<->f64 slot mirrors; cold/DCE-able)
mul i32 0 3

Wall clock (min-of-many; machine was heavily contended, 1-min load 7–18, so
treat as directional):

kernel Node (v8) Perry before Perry after
mix 40M ~471 ms ~4280 ms (~9x) ~990 ms (~2.1x)

The residual ~2.1x is not the new guard: a typed-array-local variant
(unchecked native load, no per-read guard) measures ~967 ms — within noise of
the parameter/checked version — so the checked lowering matches the "ideal"
local path; the remaining gap is Perry's general per-call/slot overhead for this
micro-kernel on a saturated box, unrelated to either fix.

Correctness

New byte-for-byte gap tests (vs node --experimental-strip-types):

  • test_gap_math_imul_native.tsMath.imul edge cases (NaN/±Infinity->0,
    fractional, 0x7fffffff/0xffffffff boundary, 2**32+3), i32-accumulator
    chains, nested imul, plus scoping guards asserting x * BIGLIT | 0 and
    friends stay f64-exact.
  • test_gap_typedarray_param_read.ts — every typed-array kind read as a
    parameter in a loop; OOB (i32-context -> 0, plain-value -> undefined),
    negative & fractional index, Float64Array (width != 4), an
    ArrayBuffer-backed view (slow-path storage), and a detached buffer.

Also verified against a targeted set of existing gap tests covering the affected
paths (toint32_large_finite, i32_arith_chain_copy, ushr_uint32_signed_slot,
typed_arrays, arraybuffer_transfer, number_math, buffers, float16, …) —
all byte-identical to Node.

Summary by CodeRabbit

  • Performance
    • Added a native Math.imul fast path for provably in-range 32-bit integers (including representable large 32-bit literals), with automatic fallback to preserve JS semantics.
    • Optimized integer typed-array element reads when the receiver is a parameter, using checked inline loads to avoid unnecessary fallbacks.
  • Behavior
    • Kept JS conversion behavior for NaN/±Infinity/fractional and out-of-range imul inputs.
    • Integer-context typed-array reads now return 0 for out-of-bounds/detached/invalid cases; plain reads continue to return undefined.
  • Tests
    • Expanded coverage for imul boundaries/accumulator chains, parameter typed-array reads (including fractional index regression), and detached-buffer behavior.

Ralph Küpper added 2 commits July 26, 2026 16:37
Two integer-math JS primitives were compiled as runtime calls and cascaded a
whole hot integer loop into slow f64 ToInt32 towers.

- Math.imul(a,b) -> native `mul i32` when both operands are provable i32 (exact:
  the low 32 bits of the product are identical for signed/unsigned operands).
  Non-finite / fractional / >2^32 operands keep the js_math_imul helper so JS
  ToUint32/ToInt32 semantics are preserved. Because `Math.imul` is defined as
  ToInt32(ToUint32*ToUint32 mod 2^32), an integer-literal operand is exact under
  low-32 truncation even above i32::MAX, so the imul operand check now accepts
  32-bit-representable literals -- fixing the accumulator case
  `a = Math.imul(a, 0x9e3779b1)` (golden-ratio constant 2654435761 > i32::MAX).
  This relaxation is confined to Math.imul operands: a plain `x * BIGLIT | 0`
  evaluates its product in f64 (precision loss above 2^53), so it must stay
  ToInt32(f64_product), never an exact `mul i32`.

- Typed-array element read through a parameter (S[i] where S: Int32Array etc. is
  a function parameter, in an i32/ToInt32 context) -> checked inline native
  load: a runtime guard (pointer + inline-storage PERRY_TA_VIEW_GUARD +
  kind-cache) and a header-length bounds check gate a bare width-correct load;
  an in-kind out-of-bounds read yields 0 (== ToInt32(undefined)); every rejected
  shape (view/detached/resizable backing, wrong runtime kind) defers to the new
  js_typed_array_read_int32 fallback. Extends the #6750 typed-array-local bare
  load to parameters, whose length/storage are unknown at compile time.
  Plain-value parameter reads still observe undefined out of bounds.

On the 40M-iteration Int32Array-parameter bit-mixer both runtime-call families
(js_math_imul x3, js_typed_array_get x3) reach zero and the read/multiply chain
stays in native i32.

Adds gap tests test_gap_math_imul_native.ts and test_gap_typedarray_param_read.ts
(byte-identical to node --experimental-strip-types).
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a72ce72c-3dd0-49bd-83c4-343b27f88832

📥 Commits

Reviewing files that changed from the base of the PR and between e0c8478 and a292848.

📒 Files selected for processing (4)
  • crates/perry-codegen/src/expr/i32_fast_path.rs
  • crates/perry-codegen/src/expr/index_get.rs
  • crates/perry-runtime/src/typedarray/access.rs
  • test-files/test_gap_typedarray_param_read.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • crates/perry-runtime/src/typedarray/access.rs
  • test-files/test_gap_typedarray_param_read.ts
  • crates/perry-codegen/src/expr/i32_fast_path.rs

📝 Walkthrough

Walkthrough

The compiler adds native i32 lowering for eligible Math.imul expressions and checked inline i32 reads for typed-array parameters. Runtime fallbacks preserve coercion and invalid-view behavior, with tests covering numeric boundaries, typed-array kinds, bounds, and detached buffers.

Changes

Native i32 lowering

Layer / File(s) Summary
Math.imul i32 lowering and coverage
crates/perry-codegen/src/expr/i32_fast_path.rs, crates/perry-codegen/src/expr/math_simple.rs, crates/perry-codegen/src/expr/mod.rs, test-files/test_gap_math_imul_native.ts, changelog.d/6850-native-imul-typed-array-param.md
Representable integer literals and i32-lowerable operands use native wrapped multiplication; other inputs retain the runtime helper path. Tests cover coercion boundaries, accumulator chains, nested calls, and non-Math.imul arithmetic.

Checked typed-array parameter loads

Layer / File(s) Summary
Guarded typed-array reads and runtime fallback
crates/perry-codegen/src/expr/i32_fast_path.rs, crates/perry-codegen/src/expr/index_get.rs, crates/perry-codegen/src/runtime_decls/strings_part2.rs, crates/perry-runtime/src/typedarray/access.rs, test-files/test_gap_typedarray_param_read.ts, changelog.d/6850-native-imul-typed-array-param.md
Integer typed-array parameter reads use pointer, view, kind, and bounds checks for inline loads, return 0 for out-of-bounds i32-context reads, and call js_typed_array_read_int32 for rejected shapes. Tests cover plain reads, float arrays, buffer views, fractional indices, and detached buffers.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AOTLowering
  participant TypedArrayHeader
  participant LLVMIR
  participant RuntimeFallback
  AOTLowering->>TypedArrayHeader: Check view guard, kind cache, and length
  AOTLowering->>LLVMIR: Emit typed load or zero for out-of-bounds
  AOTLowering->>RuntimeFallback: Call js_typed_array_read_int32 on guard miss
  RuntimeFallback->>TypedArrayHeader: Read and apply ToInt32 semantics
Loading

Possibly related PRs

  • PerryTS/perry#6750: Related typed-array IndexGet i32-native lowering changes in the same codegen module.

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it omits required template sections for Changes, Related issue, Test plan, and Checklist. Add the missing template sections with concrete change bullets, an issue reference or n/a, test commands, and checklist items.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main codegen change to lower Math.imul and typed-array parameter reads to native ops.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/native-imul-typed-array-param

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-codegen/src/expr/i32_fast_path.rs`:
- Around line 771-775: Update the Expr::IndexGet eligibility logic so
checked_typed_array_i32_kind(ctx, object) is used only when index has an
integral or int-range proof. Preserve the existing
ta_int_elem_load_is_i32_provable and masked_window_i32_load_is_provable paths,
while preventing fractional indices such as 3.9 from reaching
lower_checked_typed_array_i32_load.

In `@crates/perry-runtime/src/typedarray/access.rs`:
- Around line 60-70: The js_typed_array_read_int32 path can dereference an
invalid TypedArrayHeader before validating the pointer. Update
js_typed_array_get, or the entry path it uses, to validate the raw pointer with
lookup_typed_array_kind or strict_typed_array_from_raw before any
TypedArrayHeader field access, while preserving the existing fallback behavior
for invalid or out-of-bounds reads.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d0e682cd-1fc8-4386-9be1-a1952668ad83

📥 Commits

Reviewing files that changed from the base of the PR and between 551de05 and e0c8478.

📒 Files selected for processing (8)
  • changelog.d/6850-native-imul-typed-array-param.md
  • crates/perry-codegen/src/expr/i32_fast_path.rs
  • crates/perry-codegen/src/expr/math_simple.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/runtime_decls/strings_part2.rs
  • crates/perry-runtime/src/typedarray/access.rs
  • test-files/test_gap_math_imul_native.ts
  • test-files/test_gap_typedarray_param_read.ts

Comment thread crates/perry-codegen/src/expr/i32_fast_path.rs
Comment thread crates/perry-runtime/src/typedarray/access.rs
…rray-param read

Two Major findings on #6850:

1. Correctness — the checked typed-array-param i32 fast path gated on the
   receiver kind but NOT the index: `S[3.9]` in an i32/ToInt32 context lowered
   the index through `fptosi` (=3) and read element 3, but JS reads a fractional
   typed-array index as `undefined` (-> 0 in that consumer). Gate the arm on
   `numeric_index_has_integer_array_index_proof` (made pub(crate)) — the same
   integer-index proof the sibling typed-array read paths in index_get.rs use.
   Integer literals, `i & mask`, and i32 loop counters keep the native path.

2. Memory safety — `js_typed_array_read_int32` (cold fallback) called
   `js_typed_array_get`, which reads `(*ta).length` (a TypedArrayHeader field)
   before classifying the pointer. That fallback is reached on a wrong-kind /
   kind-cache miss, which includes a receiver that is NOT a typed array (a
   `f(S: Int32Array)` called with an arbitrary value — TS types are erased), so
   a non-typed-array pointer type-confused the first GC-header read. Validate the
   raw pointer with `lookup_typed_array_kind` (the same gate
   `strict_typed_array_from_raw` uses; it covers native/inline views) before any
   header access; a non-array receiver returns 0 (= ToInt32(undefined)).

Gap test extended with the fractional-index-in-i32-context regression cases,
byte-identical to Node.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Both addressed in a292848:

1. Fractional index on the checked TA-param path (i32_fast_path.rs). Confirmed real — S[3.9] | 0 took the checked native path (fptosi(3.9)=3) and read element 3 instead of undefined→0. Gated the arm on numeric_index_has_integer_array_index_proof (made pub(crate)) — the same integer-index proof the sibling typed-array read paths in index_get.rs already use, so it stays consistent. Integer literals, i & mask, and i32 loop counters keep the native fast path; provably-fractional/unknown indices defer to the runtime [[Get]]. Regression cases added to the gap test (frac-lit/frac-var/int-var), byte-identical to Node.

2. Unvalidated TypedArrayHeader deref in js_typed_array_read_int32 (access.rs). Confirmed real — the cold fallback is entered on wrong-kind/kind-miss, which includes a genuinely-non-typed-array receiver (TS types are erased, so the statically-emitted checked path can run with an arbitrary value), and js_typed_array_get reads (*ta).length before classifying the pointer. Now validate the raw pointer with lookup_typed_array_kind (the exact gate strict_typed_array_from_raw uses — it covers native/inline views, so no valid array is false-rejected) before any header access; a non-array receiver returns 0 (= ToInt32(undefined) in this i32 consumer).

@proggeramlug
proggeramlug merged commit 6899e75 into main Jul 26, 2026
3 checks passed
@proggeramlug
proggeramlug deleted the perf/native-imul-typed-array-param branch July 26, 2026 15:21
proggeramlug added a commit that referenced this pull request Jul 26, 2026
…6855)

* perf(codegen): inline small hot (in-loop) functions via inlinehint

After native Math.imul + typed-array-param reads (#6850), the only residual
gap of a tight integer-math kernel vs V8 is function-call overhead: V8 inlines
a small hot function into its loop; Perry left it out-of-line. A NaN-boxed
bit-mixer (`mix`, ~10 statements) costs ~800 in LLVM's inline model once GC
shadow-frame calls + typed-array reads + double<->i32 marshaling are counted —
well above the base -O3 threshold — so -O3 kept it as a call.

Bias, don't force. Perry keeps `alwaysinline` only for the genuinely tiny
(<= 8 statements). This adds a distinct `inlinehint` path for functions that
are:
  - small (9..=SIZE_CAP statements, default cap 20),
  - called from inside a loop (a whole-module HIR pre-pass collects callee ids
    with an in-loop call site — approximates "hot" for an AOT compiler), AND
  - called from few total sites (default <= 4).

The linker raises `-inlinehint-threshold` (default 850) so the hinted kernels
actually inline; this lifts LLVM's ceiling ONLY for functions Perry stamped
`inlinehint` — every other function keeps the base -O3 threshold, so cold code
is untouched.

Anti-bloat is the whole point, and it rests on two backstops:
  1. The loop gate: a function only ever called from cold/straight-line code is
     never hinted, so cold utility functions can't bloat the binary.
  2. The call-site cap: `-inlinehint-threshold` raises the ceiling at EVERY
     call site of a hinted callee (LLVM can't tell hot from cold per-site
     through a function attribute), so a hot kernel also called from many cold
     sites would be duplicated at all of them. Capping total call sites bounds
     the duplication.

Measured binary-size delta (flag OFF vs ON), byte-precise __text section:
  - real programs (5 test-files): 0 hints, 0.000% delta;
  - broadly-called helper (300 fns x 40 cold sites): excluded by cap, 0%;
  - large programs (>6MB IR compile at -Os): flag inert, 0%;
  - realistic hot-kernel density: +0.34% (25 kernels), +0.79% (50), +1.57%
    (100); an all-kernels synthetic (250) reaches +3.86% — bounded by the cap
    (without it the same program's optimized IR grew 5.7x).

`mix` 40M-call microbench (min-of-many, contended box load ~27): OFF 1024ms ->
ON 904ms. Structural proof (load-independent): the caller loop no longer emits
a `bl` to `mix` (0 vs 2 BR26 relocations), and `mix` carries `inlinehint`.

Gated behind PERRY_INLINE_HOT_SMALL (default on); PERRY_INLINE_HOT_SMALL_CAP /
_THRESHOLD / _MAX_SITES tune the window, hint threshold, and call-site cap. All
four are folded into the object cache key. Existing `noinline` cases
(try/setjmp/volatile) are respected via to_ir's has_try-first attribute
precedence. Adds test_gap_inline_hot_small.ts (byte-identical to Node ON/OFF
and under PERRY_GC_FORCE_EVACUATE=1).

Stacks on #6850 (merged).

Claude-Session: https://claude.ai/code/session_01UGDwjukzhowLDJYsMPFwMv

* docs(changelog): add 6855 inline-hot-small fragment

Claude-Session: https://claude.ai/code/session_01UGDwjukzhowLDJYsMPFwMv

* perf(codegen): scan all class-member bodies in hot-callee call-site census

CodeRabbit (#6855): collect_hot_loop_callees only walked hir.init,
free functions, class constructors, and instance methods. It missed
static methods, getters/setters, computed-key members (body + key
expr), and instance/static field initializers.

The miss matters for the anti-bloat cap, not just hot-callee discovery:
`max_call_sites` bounds inlinehint duplication, so any call site the
census doesn't see is a call site the cap can't count. A small function
whose extra call sites hide in a getter or a field initializer could
slip under the cap and get hinted despite being widely used, defeating
the size backstop. Field/computed-key exprs are walked with
in_loop=false so they feed the count without spuriously marking a
callee hot.

Verified byte-exact vs Node 26.5.0 on a class kernel whose getter,
static method, and field initializer each carry an in-loop call
(flag on and off).

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant