fix(codegen): round JS arithmetic to f64 at every i32-chain step (#7232) - #7237
Conversation
`(x * 1103515245 + 12345) & 0x7fffffff` — an LCG step — printed 654583775 under Perry and 654583808 under Node. The i32-native fast path evaluated the whole chain in exact two's-complement `mul/add i32`; ECMAScript evaluates it in doubles, rounding at every operator. The ~2^61 product is past 2^53, so the double had already discarded the low bits the exact chain still carried, and the mask read them straight back. The old admission rule required only that every integer *literal* fit in i32. That is neither necessary (`Math.imul` is defined as an exact low-32 multiply) nor sufficient: 1103515245 fits, and its product with an i32-range local does not. Replace it with a magnitude bound carried through the whole chain — `i32_chain_magnitude_bits` — capped at 2^53, the largest integer a double represents exactly. Below the cap the JS double IS the exact integer and `low32(exact) == ToInt32(double)`; above it the two models diverge, so the chain falls onto the f64 path whose `fmul`/`fadd` round where the spec says to. Both emitters of the chain carry the proof, so the gate and the last-resort arithmetic arm in `lower_expr_native_i32` cannot drift apart. Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9
Both directions: widening F64_EXACT_INTEGER_BITS past 53 reds the four divergence tests, and deleting the BitAnd/shift tightening reds the four exactness tests. Named in the module doc so the sabotage is reproducible. Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9
…und (#7232) The 2^53 cap costs `buf[y * WIDTH + x]` its exact path when WIDTH is a `const` binding: the local reads as the 32-bit default, so 33 + 32 = 65 bits and the whole strided index falls to f64. Measured on benchmarks/suite/bench_int_arithmetic.ts, where the convolution index halved its `mul i32` count (108 -> 54). A `const` bound to a numeric literal has an exactly-known magnitude, so use it: 33 + 7 = 40 keeps the index exact. The tightening never widens past 32 — the chain reads the local's i32 slot, so what it computes with is ToInt32-shaped whatever the literal was. Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9
#7232) The proof added to `lower_expr_native_i32`'s last-resort arm covered every i32-chain operator, including the bitwise ones. That arm is not dead code: it is where bcryptjs's `S[l >>> 24]` lands, with an untyped-but-ToInt32-consumed operand and no i32-chain proof to be had. Routing it through `lower_expr` turned a native `lshr` into a `js_dynamic_ushr` call — measured as an IR regression on benchmarks/suite/bench_typed_array_untyped_access.ts, the #5525 Blowfish guard. Bitwise operators are ToInt32-wrapped by definition and cannot leave the double's exact range, so they need no proof. Scope the guard to Add/Sub/Mul, which is exactly the class that can. With this, the only benchmark whose IR still moves is 11_prime_sieve, whose `j = i * i` loop preheader is genuinely unbounded and must round. Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9
📝 WalkthroughWalkthroughThe i32 fast path now tracks expression magnitude through the exact Changesi32 exactness
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Expression
participant I32Analysis as i32 fast-path analysis
participant NativeLowering as native i32 lowering
participant FloatLowering as floating-point lowering
Expression->>I32Analysis: provide expression and constant-local facts
I32Analysis-->>NativeLowering: approve exact i32 chain
I32Analysis-->>FloatLowering: reject unproven arithmetic chain
FloatLowering->>FloatLowering: evaluate Add, Sub, or Mul as f64
FloatLowering->>NativeLowering: apply ToInt32 for the consumer
Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (3)
crates/perry-codegen/src/expr/buffer_access.rs (1)
204-239: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRemove the discarded eligibility checks in
lower_index_i32_valueandlower_value_i32.Both functions compute
can_lower_expr_as_i32and then run the same expression in both branches. The result is never used. This PR makes the wasted call more expensive:can_lower_expr_as_i32now walks the whole expression tree to compute a magnitude bound instead of performing a shape check.Either drop the checks, or route the false branch to the intended fallback lowering.
♻️ Proposed cleanup if the branches are genuinely identical
fn lower_index_i32_value(ctx: &mut FnCtx<'_>, index: &Expr) -> Result<LoweredValue> { - let value = if can_lower_expr_as_i32( - index, - &ctx.i32_counter_slots, - ctx.flat_const_arrays, - &ctx.array_row_aliases, - ctx.native_facts.integer_locals(), - &ctx.const_number_locals, - ctx.clamp3_functions, - ctx.clamp_u8_functions, - ctx.integer_returning_functions, - ctx.i32_identity_functions, - ) { - lower_expr_native(ctx, index, crate::native_value::ExpectedNativeRep::I32)?.value - } else { - lower_expr_native(ctx, index, crate::native_value::ExpectedNativeRep::I32)?.value - }; + let value = lower_expr_native(ctx, index, crate::native_value::ExpectedNativeRep::I32)?.value; Ok(LoweredValue::i32(value)) } fn lower_value_i32(ctx: &mut FnCtx<'_>, value: &Expr) -> Result<String> { - if can_lower_expr_as_i32( - value, - &ctx.i32_counter_slots, - ctx.flat_const_arrays, - &ctx.array_row_aliases, - ctx.native_facts.integer_locals(), - &ctx.const_number_locals, - ctx.clamp3_functions, - ctx.clamp_u8_functions, - ctx.integer_returning_functions, - ctx.i32_identity_functions, - ) { - Ok(lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::I32)?.value) - } else { - Ok(lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::I32)?.value) - } + Ok(lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::I32)?.value) }🤖 Prompt for 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. In `@crates/perry-codegen/src/expr/buffer_access.rs` around lines 204 - 239, Remove the unused can_lower_expr_as_i32 checks from lower_index_i32_value and lower_value_i32, since both branches currently call identical lower_expr_native logic with ExpectedNativeRep::I32. Preserve the existing lowering result while avoiding the unnecessary expression-tree traversal.crates/perry-codegen/src/expr/i32_fast_path.rs (2)
856-881: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoffConsider memoizing the magnitude analysis for deep chains.
region_i32_chain_magnitude_bitsruns the full ctx-free analysis on the whole subtree at Line 860 before it recurses into the children at Lines 878-879. Each child then repeats the same ctx-free walk over its own subtree. For a chain of N binary nodes the analysis performs O(N^2) work in the worst case. The new fallback arm inlower_expr_native_i32(Line 1454) calls this function once per arithmetic node during lowering, which compounds the cost.This affects compile time only, and typical chains are shallow. If long generated arithmetic chains appear in real inputs, add a per-lowering memo keyed by expression pointer.
🤖 Prompt for 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. In `@crates/perry-codegen/src/expr/i32_fast_path.rs` around lines 856 - 881, Memoize region_i32_chain_magnitude_bits results per lowering using the expression pointer as the key, and consult/store the cache across recursive calls and lower_expr_native_i32 fallback invocations. Thread the memo through the existing analysis context or lowering state without changing the computed magnitude results.
489-509: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueSimplify the call gate; the arity check is currently bypassable.
The two conditions do not enforce arity for a function that is in both
clamp3_fnsandinteger_returning_fns. Such a call with two arguments passes the first check throughinteger_returning_fns, and passes the second check becauseclamp3_fns.contains(fid)is true. The result stays correct, becausetry_lower_expr_native_i32_structuraland theExpr::Callarm oflower_expr_native_i32both re-check the arity and fall back tolower_exprplusfptosi. Only the gate intent is unclear.Collapse both checks into the single admission rule they describe.
♻️ Proposed simplification
- if !((env.clamp3_fns.contains(fid) && args.len() == 3) - || (env.clamp_u8_fns.contains(fid) && args.len() == 1) - || env.integer_returning_fns.contains(fid)) - { - return None; - } - if env.integer_returning_fns.contains(fid) - && !env.clamp3_fns.contains(fid) - && !env.clamp_u8_fns.contains(fid) - && !env.i32_identity_fns.contains(fid) - { - return None; - } + // Admitted callees: a clamp helper at its exact arity, or an + // i32-identity helper. A plain integer-returning function is not + // enough — its result is not proven ToInt32-shaped here. + if !((env.clamp3_fns.contains(fid) && args.len() == 3) + || (env.clamp_u8_fns.contains(fid) && args.len() == 1) + || env.i32_identity_fns.contains(fid)) + { + return None; + }🤖 Prompt for 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. In `@crates/perry-codegen/src/expr/i32_fast_path.rs` around lines 489 - 509, In the Expr::Call branch, replace the two-stage function-membership checks with one admission rule that requires each recognized function category to have its corresponding arity, while preserving integer-returning identity functions as valid calls. Ensure a function appearing in multiple sets cannot bypass the clamp3 or clamp_u8 arity requirements; leave the existing argument magnitude validation unchanged.
🤖 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 `@changelog.d/7237-i32-chain-double-rounding.md`:
- Around line 38-42: Correct the test-count reference in the changelog fragment
from twelve to fourteen, leaving the surrounding coverage description unchanged.
In `@crates/perry-codegen/src/expr/i32_fast_path/bits_tests.rs`:
- Around line 276-279: Correct the comment above the idx construction to
describe (x + -1) * K + (y + -1), identify K as the inner-product width, and
explain that the intermediate bound is 40 while the outer addition yields 41. Do
not change the expression or assertion in the bits test.
---
Nitpick comments:
In `@crates/perry-codegen/src/expr/buffer_access.rs`:
- Around line 204-239: Remove the unused can_lower_expr_as_i32 checks from
lower_index_i32_value and lower_value_i32, since both branches currently call
identical lower_expr_native logic with ExpectedNativeRep::I32. Preserve the
existing lowering result while avoiding the unnecessary expression-tree
traversal.
In `@crates/perry-codegen/src/expr/i32_fast_path.rs`:
- Around line 856-881: Memoize region_i32_chain_magnitude_bits results per
lowering using the expression pointer as the key, and consult/store the cache
across recursive calls and lower_expr_native_i32 fallback invocations. Thread
the memo through the existing analysis context or lowering state without
changing the computed magnitude results.
- Around line 489-509: In the Expr::Call branch, replace the two-stage
function-membership checks with one admission rule that requires each recognized
function category to have its corresponding arity, while preserving
integer-returning identity functions as valid calls. Ensure a function appearing
in multiple sets cannot bypass the clamp3 or clamp_u8 arity requirements; leave
the existing argument magnitude validation unchanged.
🪄 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: 26e6ab1d-878e-423a-a217-02531d027b40
📒 Files selected for processing (12)
changelog.d/7237-i32-chain-double-rounding.mdcrates/perry-codegen/src/expr/arrays_finds.rscrates/perry-codegen/src/expr/bigint_set.rscrates/perry-codegen/src/expr/buffer_access.rscrates/perry-codegen/src/expr/channel.rscrates/perry-codegen/src/expr/i32_fast_path.rscrates/perry-codegen/src/expr/i32_fast_path/bits_tests.rscrates/perry-codegen/src/expr/math_simple.rscrates/perry-codegen/src/expr/proven_view_access.rscrates/perry-codegen/src/lower_call/func_ref.rscrates/perry-codegen/src/stmt/let_stmt.rstest-files/test_gap_7232_i32_chain_double_rounding.ts
… and the fragment's test count (#7237) CodeRabbit, PR #7237: * `lower_index_i32_value` / `lower_value_i32` in expr/buffer_access.rs branched on `can_lower_expr_as_i32` into two identical arms, so the predicate's answer was computed and thrown away. `lower_expr_native` makes the same decision internally; the outer call was pure cost, and this PR turned it into a whole-subtree walk. Verified IR-neutral across the typed-array/buffer benchmarks. * The const-literal test comment had the operands swapped and quoted the inner product's width rather than the asserted one. * The changelog fragment said twelve unit tests; there are fourteen. Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9
…#7242) * fix(codegen): remove the unproven i64 function specialization (#7238) `emit_i64_specializations` re-emitted a whole `number`-typed function body in i64 arithmetic and wrapped it in an f64 shim that `fptosi`d every argument and `sitofp`d the result. Two independent halves of its contract were unchecked: * overflow — i64 add/sub/mul are exact, JS rounds to the nearest double at every operator, so the two agree only while every intermediate satisfies |v| <= 2^53; * argument truncation — a `number` parameter is a double, and the wrapper's `fptosi double %arg to i64` truncated a fractional one on entry. Neither is statically provable for the self-recursive bodies the pass existed to serve: a parameter fed by its own recursive-call argument has no bound, so #7237's `i32_chain_magnitude_bits` has no bounded leaf to measure from. Even the motivating example diverges (`fib(79)` crosses 2^53). The pass also suppressed the sound replacements — `typed_f64`/`typed_i32`/`typed_i1` clones and the Phase-2 specialized ABI were all retained minus the i64-specialized set — so removing it hands those functions to a specializer that proves what this one assumed. Claimed 2 of the 30 `benchmarks/suite/` programs (`05_fibonacci`, `14_closure`). Fixes #7238. * docs: changelog fragment for #7238 * test(codegen): drop a vacuous disjunct from the no-i64-body assertion * test(codegen): make the no-entry-truncation checks naming-independent CodeRabbit review on #7242. Three assertions matched the literal `fptosi double %arg`, which couples them to how parameters happen to be named in the emitted IR. Match on the opcode alone, scoped to the public function body, so a rename cannot turn them vacuous — none of these fixtures has another reason to narrow a double to an integer. Also renames the `native_proof_regressions` fixture to `number_add_module`, since it no longer describes an i64-specialization collision. --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Fixes #7232.
The bug
An LCG step. Wrong straight-line and loop-carried; correct through a function
boundary. No throw, no warning — just different numbers, in PRNG seeds, hash
mixing and checksum accumulators.
Root cause
crates/perry-codegen/src/expr/i32_fast_path.rs. The i32-native fast pathevaluated the whole chain in exact two's-complement
mul/add i32:ECMAScript numbers are IEEE-754 doubles, so
*and+round their result tothe nearest double before the next operator runs.
1406932606 * 1103515245is ~2^61 — past 2^53, where the double's ulp is 256 — so Node'smask reads a rounded product while the exact
mul i32reads the low bits thedouble had already discarded. It only reproduced through a local because a
function boundary NaN-boxes the intermediate, which rounds it.
The old admission rule (
can_lower_expr_as_i32) required only that everyinteger literal fit in i32. That is neither necessary —
Math.imulisdefined as an exact low-32 multiply and needs no such rule — nor sufficient:
1103515245fits in i32, and its product with an i32-range local does not fitin a double.
The invariant
So the predicate now carries a magnitude bound through the whole chain
(
i32_chain_magnitude_bits), capped at 53.Add/Subgrow the bound by onebit and
Mulsums it — the same compositionknown_finite_magnitude_bitsalready used for the
toint32_fastpoison proof, with 53 in place of 63because this bound gates exact integer arithmetic, not one
fptosi. The capapplies to
Add/Subtoo, not onlyMul: two ceiling-width products sum to2^54.
Both emitters of the chain consult the same function, so the gate and the
last-resort arithmetic arm in
lower_expr_native_i32cannot drift apart.Keeping the fast path fast
A naive 53-bit cap deoptimizes a lot of correct code, so the bound is measured
rather than assumed:
h * 31is 37 bits, not 64);x & mwith a non-negative literal mask lands in[0, m];x >> k/x >>> kby a literalkdropkbits;constbound to a numeric literal contributes its width — this is whatkeeps the dominant strided-index shape
buf[y * WIDTH + x]exact;Math.imulis exempt entirely.Measured over all 30 programs in
benchmarks/suite/, comparing emitted LLVM IRbyte-for-byte against
main: 29 are unchanged. The one that moves is11_prime_sieve, whosefor (let j = i * i; ...)preheader is genuinelyunbounded and now rounds — one
mul i32becomesfmul+fjcvtzs, executedonce per outer-loop iteration.
Two of those 29 took a detour worth recording, because both are cases where the
first attempt at this fix did regress them:
bench_int_arithmeticlost half itsmul i32(108 → 54) until the constliteral's own width was measured —
const SIZE = 100was reading as the32-bit default, so the convolution index
(y - 1) * SIZE + (x - 1)came outat 65 bits.
bench_typed_array_untyped_access(the Perf: untyped typed-array element access ~6x slower (per-access thread-local kind lookup) — bcryptjs cost-12 ~28s vs ~250ms #5525 Blowfish guard) turned a nativelshrinto ajs_dynamic_ushrcall, because the exactness proof had beenapplied to the last-resort bitwise arm as well. Bitwise operators are
ToInt32-wrapped by definition and cannot leave the exact range; that arm is
where bcryptjs's
S[l >>> 24]lands with an untyped operand, and it muststay proof-free.
Red then green
test-files/test_gap_7232_i32_chain_double_rounding.ts— the issue's threeshapes (straight-line, loop-carried, through-a-call), every ToInt32-shaped
consumer, compound assignment, the unmasked product, a full LCG run, the
2^53 boundary from both sides, and the chains that must stay exact
(
h * 31 + c, FNV-1a viaMath.imul, 16×16 masked mixing,i * cols + jindices,
i * i).main: 4 lines diverge fromnode --experimental-strip-types26.5.1.Fourteen unit tests in
crates/perry-codegen/src/expr/i32_fast_path/bits_tests.rspin the bound itself, and are sabotage-checked both ways: widening
F64_EXACT_INTEGER_BITSpast 53 reds the four divergence tests; deleting theBitAnd/shift/const tightening reds the four exactness tests.Gates
cargo test -p perry-codegen --lib— 540 pass.compiler_output_regression.py census --gate— green, no floor touched, no--update.cargo fmt --all; file-size cap offender list unchanged (16), and no file inthis diff is on it.
confined to
expr/i32_fast_path.rsplus the mechanical argument threading atits call sites.
Summary by CodeRabbit
Bug Fixes
2^53precision boundary across arithmetic, loops, assignments, indexing, and function calls.Math.imul.Performance
Tests