From 4061684ddaf90c02c6928b67e5bd13f715781f8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 2 Aug 2026 08:06:04 +0200 Subject: [PATCH 1/6] fix(codegen): round JS arithmetic to f64 at every i32-chain step (#7232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `(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 --- .../perry-codegen/src/expr/i32_fast_path.rs | 437 +++++++++++------- ...test_gap_7232_i32_chain_double_rounding.ts | 137 ++++++ 2 files changed, 398 insertions(+), 176 deletions(-) create mode 100644 test-files/test_gap_7232_i32_chain_double_rounding.ts diff --git a/crates/perry-codegen/src/expr/i32_fast_path.rs b/crates/perry-codegen/src/expr/i32_fast_path.rs index c71593af0c..4de745951a 100644 --- a/crates/perry-codegen/src/expr/i32_fast_path.rs +++ b/crates/perry-codegen/src/expr/i32_fast_path.rs @@ -304,143 +304,228 @@ pub(crate) fn lower_imul_operand_i32(ctx: &mut FnCtx<'_>, e: &Expr) -> Result, - flat_const_arrays: &std::collections::HashMap, - array_row_aliases: &std::collections::HashMap)>, - integer_locals: &std::collections::HashSet, - clamp3_fns: &std::collections::HashSet, - clamp_u8_fns: &std::collections::HashSet, - integer_returning_fns: &std::collections::HashSet, - i32_identity_fns: &std::collections::HashSet, -) -> bool { +/// Every integer with `|v| <= 2^53` is exactly representable as an IEEE-754 +/// double; past that the ulp exceeds 1 and a JS `*`/`+` result is a *rounded* +/// integer. This is the constant that makes an i32-native chain agree with JS — +/// see [`i32_chain_magnitude_bits`]. +const F64_EXACT_INTEGER_BITS: u32 = 53; + +/// Magnitude bound of a leaf that is only known to be i32/u32-shaped. +const I32_CHAIN_LEAF_BITS: u32 = 32; + +/// `|n| < 2^bits`. +fn integer_magnitude_bits(n: i64) -> u32 { + 64 - n.unsigned_abs().leading_zeros() +} + +/// Cap an intermediate at the double's exact-integer range. `None` once the +/// value could reach 2^53, which is where JS rounds and exact integer +/// arithmetic does not. +fn f64_exact_bits(bits: u32) -> Option { + (bits <= F64_EXACT_INTEGER_BITS).then_some(bits) +} + +/// The combining operators an i32-native chain admits. +fn is_i32_chain_op(op: BinaryOp) -> bool { + matches!( + op, + BinaryOp::Add + | BinaryOp::Sub + | BinaryOp::Mul + | BinaryOp::BitAnd + | BinaryOp::BitOr + | BinaryOp::BitXor + | BinaryOp::Shl + | BinaryOp::Shr + | BinaryOp::UShr + ) +} + +/// Magnitude bound of `left right` from the operands' bounds. +/// +/// `Add`/`Sub` grow the bound by one bit and `Mul` sums them — the same +/// composition [`known_finite_magnitude_bits`] uses — but capped at 2^53 +/// instead of 2^63, because this bound gates *exact integer arithmetic* rather +/// than a single `fptosi`. +/// +/// The ToInt32/ToUint32-wrapped operators reset the bound to 32. Two of them +/// carry a tighter one, which is what keeps masked/shifted hash mixing on the +/// fast path once the cap exists: `x & m` with a non-negative literal mask +/// lands in `[0, m]`, and `x >> k` / `x >>> k` by a literal `k` in `1..32` drop +/// `k` bits off a 32-bit value. (Shift counts outside that range take the +/// untightened 32 — JS masks the count to 5 bits, which this does not model.) +fn combine_i32_chain_bits(op: BinaryOp, left: &Expr, right: &Expr, l: u32, r: u32) -> Option { + match op { + BinaryOp::Add | BinaryOp::Sub => f64_exact_bits(l.max(r) + 1), + BinaryOp::Mul => f64_exact_bits(l + r), + BinaryOp::BitAnd => { + let mask_bits = |e: &Expr| match e { + Expr::Integer(m) if *m >= 0 => Some(integer_magnitude_bits(*m)), + _ => None, + }; + Some( + mask_bits(left) + .into_iter() + .chain(mask_bits(right)) + .min() + .unwrap_or(I32_CHAIN_LEAF_BITS), + ) + } + BinaryOp::Shr | BinaryOp::UShr => Some(match right { + Expr::Integer(k) if (1..32).contains(k) => I32_CHAIN_LEAF_BITS - *k as u32, + _ => I32_CHAIN_LEAF_BITS, + }), + BinaryOp::BitOr | BinaryOp::BitXor | BinaryOp::Shl => Some(I32_CHAIN_LEAF_BITS), + _ => None, + } +} + +/// Borrowed view of the fact tables the i32-chain rules consult, so the +/// recursion carries one argument instead of eight. +#[derive(Clone, Copy)] +struct I32ChainEnv<'a> { + i32_slots: &'a std::collections::HashMap, + flat_const_arrays: &'a std::collections::HashMap, + array_row_aliases: &'a std::collections::HashMap)>, + integer_locals: &'a std::collections::HashSet, + clamp3_fns: &'a std::collections::HashSet, + clamp_u8_fns: &'a std::collections::HashSet, + integer_returning_fns: &'a std::collections::HashSet, + i32_identity_fns: &'a std::collections::HashSet, +} + +/// (Issue #49) `Some(bits)` when `e` can be lowered as an i32-native +/// expression — every leaf sourced from an i32 slot, a typed-array byte load, +/// or an integer literal, combined by `Add/Sub/Mul` and the bitwise ops — where +/// `bits` additionally proves the node's exact integer value satisfies +/// `|v| < 2^bits`. `None` means "do not take the fast path". +/// +/// ## The invariant (#7232) +/// +/// An i32-native chain computes the **exact** two's-complement low 32 bits of +/// the integer result. JS evaluates the same chain in doubles, rounding at +/// *every* operator. The two agree only while each intermediate is exactly +/// representable as a double, i.e. `|v| <= 2^53`: below that ceiling the JS +/// double *is* the exact integer and `low32(exact) == ToInt32(double)`, above +/// it the double has already discarded low bits the exact chain still carries. +/// +/// `(x * 1103515245 + 12345) & 0x7fffffff` — an LCG step — is the shape that +/// exposed this: the product is ~2^61, so Node's mask reads a rounded product +/// (654583808) while an exact `mul i32` reads the true low bits (654583775). +/// Capping the bound at 53 pushes such a chain onto the f64 path, whose +/// `fmul`/`fadd` round exactly where the spec says to. +/// +/// The old rule only required every *literal* to fit in i32, which is neither +/// necessary (`Math.imul` is exempt) nor sufficient: `1103515245` fits, and its +/// product with an i32-range local does not. +fn i32_chain_magnitude_bits(e: &Expr, env: I32ChainEnv<'_>) -> Option { match e { // Strict i32 range for a general leaf: a `>i32::MAX` literal must NOT - // enter an arbitrary i32 chain. In particular `x * BIGLIT | 0` computes - // the product in f64 (JS `*`), which loses precision above 2^53, so an - // exact `mul i32` would diverge from `ToInt32(f64_product)`. Only - // `Math.imul` (below) and the runtime helper interpret the operand under + // enter an arbitrary i32 chain, because the i32 lowering truncates it + // to the low 32 bits while JS `*` sees the full value. Only + // `Math.imul` (below) and the runtime helper interpret an operand under // exact-low-32 semantics. - Expr::Integer(n) => i32::try_from(*n).is_ok(), - Expr::LocalGet(id) => i32_slots.contains_key(id) || integer_locals.contains(id), - Expr::Uint8ArrayGet { .. } | Expr::BufferIndexGet { .. } => true, + Expr::Integer(n) => i32::try_from(*n).ok().map(|_| integer_magnitude_bits(*n)), + Expr::LocalGet(id) => (env.i32_slots.contains_key(id) || env.integer_locals.contains(id)) + .then_some(I32_CHAIN_LEAF_BITS), + Expr::Uint8ArrayGet { .. } | Expr::BufferIndexGet { .. } => Some(8), Expr::MathImul(a, b) => { - // `Math.imul(x, y) == ToInt32(ToUint32(x) * ToUint32(y) mod 2^32)`, - // so an integer literal operand is exact under low-32 truncation - // even when it exceeds `i32::MAX` (e.g. the `0x9e3779b1` mixer - // constant). Accept 32-bit-representable literal operands here only. + // `Math.imul(x, y) == ToInt32(ToUint32(x) * ToUint32(y) mod 2^32)` + // — defined as an exact low-32 multiply, so it is NOT subject to + // the 2^53 rule and an integer literal operand is exact under + // low-32 truncation even past `i32::MAX` (the `0x9e3779b1` mixer + // constant). Its *result* is an i32. let operand_ok = |e: &Expr| { matches!(e, Expr::Integer(n) if integer_is_i32_bit_representable(*n)) - || can_lower_expr_as_i32( - e, - i32_slots, - flat_const_arrays, - array_row_aliases, - integer_locals, - clamp3_fns, - clamp_u8_fns, - integer_returning_fns, - i32_identity_fns, - ) + || i32_chain_magnitude_bits(e, env).is_some() }; - operand_ok(a) && operand_ok(b) + (operand_ok(a) && operand_ok(b)).then_some(I32_CHAIN_LEAF_BITS) } Expr::Binary { op: BinaryOp::BitOr, left, right, - } if matches!(right.as_ref(), Expr::Integer(0)) => can_lower_expr_as_i32( - left, - i32_slots, - flat_const_arrays, - array_row_aliases, - integer_locals, - clamp3_fns, - clamp_u8_fns, - integer_returning_fns, - i32_identity_fns, - ), - Expr::Binary { op, left, right } - if matches!( - op, - BinaryOp::Add - | BinaryOp::Sub - | BinaryOp::Mul - | BinaryOp::BitAnd - | BinaryOp::BitOr - | BinaryOp::BitXor - | BinaryOp::Shl - | BinaryOp::Shr - | BinaryOp::UShr - ) => - { - can_lower_expr_as_i32( - left, - i32_slots, - flat_const_arrays, - array_row_aliases, - integer_locals, - clamp3_fns, - clamp_u8_fns, - integer_returning_fns, - i32_identity_fns, - ) && can_lower_expr_as_i32( - right, - i32_slots, - flat_const_arrays, - array_row_aliases, - integer_locals, - clamp3_fns, - clamp_u8_fns, - integer_returning_fns, - i32_identity_fns, - ) + } if matches!(right.as_ref(), Expr::Integer(0)) => { + i32_chain_magnitude_bits(left, env).map(|l| l.min(I32_CHAIN_LEAF_BITS)) + } + Expr::Binary { op, left, right } if is_i32_chain_op(*op) => { + let l = i32_chain_magnitude_bits(left, env)?; + let r = i32_chain_magnitude_bits(right, env)?; + combine_i32_chain_bits(*op, left, right, l, r) } Expr::Call { callee, args, .. } => { - if let Expr::FuncRef(fid) = callee.as_ref() { - if (clamp3_fns.contains(fid) && args.len() == 3) - || (clamp_u8_fns.contains(fid) && args.len() == 1) - || integer_returning_fns.contains(fid) - { - if integer_returning_fns.contains(fid) - && !clamp3_fns.contains(fid) - && !clamp_u8_fns.contains(fid) - && !i32_identity_fns.contains(fid) - { - return false; - } - return args.iter().all(|a| { - can_lower_expr_as_i32( - a, - i32_slots, - flat_const_arrays, - array_row_aliases, - integer_locals, - clamp3_fns, - clamp_u8_fns, - integer_returning_fns, - i32_identity_fns, - ) - }); - } + let Expr::FuncRef(fid) = callee.as_ref() else { + return None; + }; + 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; } - false + args.iter() + .all(|a| i32_chain_magnitude_bits(a, env).is_some()) + .then_some(I32_CHAIN_LEAF_BITS) } // Issue #50 bridge: element of a flat-const 2D int table. Expr::IndexGet { object, .. } => match object.as_ref() { Expr::IndexGet { object: inner, .. } => { - matches!(inner.as_ref(), Expr::LocalGet(id) if flat_const_arrays.contains_key(id)) + matches!(inner.as_ref(), Expr::LocalGet(id) if env.flat_const_arrays.contains_key(id)) } - Expr::LocalGet(id) => array_row_aliases + Expr::LocalGet(id) => env + .array_row_aliases .get(id) - .is_some_and(|(cid, _)| flat_const_arrays.contains_key(cid)), + .is_some_and(|(cid, _)| env.flat_const_arrays.contains_key(cid)), _ => false, - }, - _ => false, + } + .then_some(I32_CHAIN_LEAF_BITS), + _ => None, } } +/// (Issue #49) Return `true` if `e` can be lowered as an i32-native +/// expression. Used by the `LocalSet` fast path to decide whether the rhs can +/// bypass the fp round-trip. +/// +/// The fallback `lower_expr_as_i32` path is `toint32(lower_expr())`, which is +/// always correct — it evaluates the chain in doubles exactly as the spec +/// requires — so returning `false` is always the safe direction. We only commit +/// to the fast path when every leaf is recognizably int-sourced AND the whole +/// chain is provably f64-exact ([`i32_chain_magnitude_bits`]). +pub(crate) fn can_lower_expr_as_i32( + e: &Expr, + i32_slots: &std::collections::HashMap, + flat_const_arrays: &std::collections::HashMap, + array_row_aliases: &std::collections::HashMap)>, + integer_locals: &std::collections::HashSet, + clamp3_fns: &std::collections::HashSet, + clamp_u8_fns: &std::collections::HashSet, + integer_returning_fns: &std::collections::HashSet, + i32_identity_fns: &std::collections::HashSet, +) -> bool { + i32_chain_magnitude_bits( + e, + I32ChainEnv { + i32_slots, + flat_const_arrays, + array_row_aliases, + integer_locals, + clamp3_fns, + clamp_u8_fns, + integer_returning_fns, + i32_identity_fns, + }, + ) + .is_some() +} + /// `object[index]` on a width-tracked typed-array local whose element kind is /// integral and value-representable in a signed i32 (I8/U8/U8Clamped/I16/U16/ /// I32 — NOT U32, whose upper half doesn't round-trip through an i32 slot, and @@ -708,79 +793,79 @@ fn packed_u32_loop_index_get_fact(ctx: &FnCtx<'_>, e: &Expr) -> Option(ctx: &'a FnCtx<'_>) -> I32ChainEnv<'a> { + I32ChainEnv { + i32_slots: &ctx.i32_counter_slots, + flat_const_arrays: ctx.flat_const_arrays, + array_row_aliases: &ctx.array_row_aliases, + integer_locals: ctx.native_facts.integer_locals(), + clamp3_fns: ctx.clamp3_functions, + clamp_u8_fns: ctx.clamp_u8_functions, + integer_returning_fns: ctx.integer_returning_functions, + i32_identity_fns: ctx.i32_identity_functions, + } +} + pub(crate) fn can_lower_expr_as_i32_in_current_region(ctx: &FnCtx<'_>, e: &Expr) -> bool { + region_i32_chain_magnitude_bits(ctx, e).is_some() +} + +/// Region-aware [`i32_chain_magnitude_bits`]: the ctx-free leaf set plus the +/// per-scope proofs (packed-loop element reads, bounds-proven typed-array +/// loads, masked-window loads), all of which are i32-valued leaves. The 2^53 +/// exactness cap (#7232) is the same one, applied through the same combiner — +/// a region leaf is not a licence to evaluate past double precision. +fn region_i32_chain_magnitude_bits(ctx: &FnCtx<'_>, e: &Expr) -> Option { if matches!(e, Expr::IterResultGetValue) { - return true; + return Some(I32_CHAIN_LEAF_BITS); } - if can_lower_expr_as_i32( - e, - &ctx.i32_counter_slots, - ctx.flat_const_arrays, - &ctx.array_row_aliases, - ctx.native_facts.integer_locals(), - ctx.clamp3_functions, - ctx.clamp_u8_functions, - ctx.integer_returning_functions, - ctx.i32_identity_functions, - ) { - return true; + if let Some(bits) = i32_chain_magnitude_bits(e, ctx_i32_chain_env(ctx)) { + return Some(bits); } if packed_i32_loop_index_get_fact(ctx, e).is_some() { - return true; + return Some(I32_CHAIN_LEAF_BITS); } match e { - Expr::MathImul(left, right) => { - imul_operand_i32_lowerable_in_current_region(ctx, left) - && imul_operand_i32_lowerable_in_current_region(ctx, right) - } + Expr::MathImul(left, right) => (imul_operand_i32_lowerable_in_current_region(ctx, left) + && imul_operand_i32_lowerable_in_current_region(ctx, right)) + .then_some(I32_CHAIN_LEAF_BITS), Expr::Binary { op: BinaryOp::BitOr, left, right, } if matches!(right.as_ref(), Expr::Integer(0)) => { - can_lower_expr_as_i32_in_current_region(ctx, left) + region_i32_chain_magnitude_bits(ctx, left).map(|l| l.min(I32_CHAIN_LEAF_BITS)) } - Expr::Binary { op, left, right } - if matches!( - op, - BinaryOp::Add - | BinaryOp::Sub - | BinaryOp::Mul - | BinaryOp::BitAnd - | BinaryOp::BitOr - | BinaryOp::BitXor - | BinaryOp::Shl - | BinaryOp::Shr - | BinaryOp::UShr - ) => - { - can_lower_expr_as_i32_in_current_region(ctx, left) - && can_lower_expr_as_i32_in_current_region(ctx, right) + Expr::Binary { op, left, right } if is_i32_chain_op(*op) => { + let l = region_i32_chain_magnitude_bits(ctx, left)?; + let r = region_i32_chain_magnitude_bits(ctx, right)?; + combine_i32_chain_bits(*op, left, right, l, r) } Expr::Call { callee, args, .. } => { let Expr::FuncRef(fid) = callee.as_ref() else { - return false; + return None; }; - ((ctx.clamp3_functions.contains(fid) && args.len() == 3) + (((ctx.clamp3_functions.contains(fid) && args.len() == 3) || (ctx.clamp_u8_functions.contains(fid) && args.len() == 1) || ctx.i32_identity_functions.contains(fid)) && args .iter() - .all(|arg| can_lower_expr_as_i32_in_current_region(ctx, arg)) + .all(|arg| can_lower_expr_as_i32_in_current_region(ctx, arg))) + .then_some(I32_CHAIN_LEAF_BITS) } - Expr::IndexGet { object, index } => { - ta_int_elem_load_is_i32_provable(ctx, object, index) - || super::masked_window::masked_window_i32_load_is_provable(ctx, object, index) - // The checked-kind fast path lowers `index` through `fptosi` - // (ToInt32), so a fractional index like `S[3.9]` would read - // element 3 — JS reads a fractional typed-array index as - // `undefined` (→ 0 in this ToInt32 consumer). Only take it with a - // proven integer index (the same gate the sibling typed-array - // read paths use in `index_get.rs`). - || (checked_typed_array_i32_kind(ctx, object).is_some() - && super::index_get::numeric_index_has_integer_array_index_proof(ctx, index)) - } - _ => false, + Expr::IndexGet { object, index } => (ta_int_elem_load_is_i32_provable(ctx, object, index) + || super::masked_window::masked_window_i32_load_is_provable(ctx, object, index) + // The checked-kind fast path lowers `index` through `fptosi` + // (ToInt32), so a fractional index like `S[3.9]` would read + // element 3 — JS reads a fractional typed-array index as + // `undefined` (→ 0 in this ToInt32 consumer). Only take it with a + // proven integer index (the same gate the sibling typed-array + // read paths use in `index_get.rs`). + || (checked_typed_array_i32_kind(ctx, object).is_some() + && super::index_get::numeric_index_has_integer_array_index_proof(ctx, index))) + .then_some(I32_CHAIN_LEAF_BITS), + _ => None, } } @@ -1311,20 +1396,20 @@ fn lower_expr_native_i32(ctx: &mut FnCtx<'_>, e: &Expr) -> Result left, right, } if matches!(right.as_ref(), Expr::Integer(0)) => lower_expr_native_i32(ctx, left)?.value, - Expr::Binary { op, left, right } - if matches!( - op, - BinaryOp::Add - | BinaryOp::Sub - | BinaryOp::Mul - | BinaryOp::BitAnd - | BinaryOp::BitOr - | BinaryOp::BitXor - | BinaryOp::Shl - | BinaryOp::Shr - | BinaryOp::UShr - ) => + // Last-resort integer arithmetic, reached only when `lower_expr_value` + // could not produce a value at all. It is a SECOND emitter of the same + // `mul/add i32` chain the structural path above emits, so it carries + // the same #7232 exactness proof — otherwise a shape that reaches here + // would evaluate past double precision behind the fixed gate. Without + // the proof the chain is evaluated in doubles and ToInt32-wrapped, + // which is what the spec asks for. + Expr::Binary { op, .. } + if is_i32_chain_op(*op) && region_i32_chain_magnitude_bits(ctx, e).is_none() => { + let d = lower_expr(ctx, e)?; + ctx.block().toint32(&d) + } + Expr::Binary { op, left, right } if is_i32_chain_op(*op) => { let l = lower_expr_native_i32(ctx, left)?.value; let r = lower_expr_native_i32(ctx, right)?.value; let blk = ctx.block(); diff --git a/test-files/test_gap_7232_i32_chain_double_rounding.ts b/test-files/test_gap_7232_i32_chain_double_rounding.ts new file mode 100644 index 0000000000..fda3ae6b0f --- /dev/null +++ b/test-files/test_gap_7232_i32_chain_double_rounding.ts @@ -0,0 +1,137 @@ +// #7232 — an i32-native arithmetic chain must not evaluate past double precision. +// +// ECMAScript numbers are IEEE-754 doubles, so `*` and `+` round their result to +// the nearest double BEFORE the next operator runs. Perry's i32 fast path +// (`expr/i32_fast_path.rs`) evaluates the same chain in exact two's-complement +// `mul/add i32`, which agrees with JS only while every intermediate integer is +// exactly representable — |v| <= 2^53. `(x * 1103515245 + 12345) & 0x7fffffff`, +// the classic LCG step, has a ~2^61 product: the exact chain kept low bits that +// the double had already rounded away and the mask read them straight back, so +// Perry printed 654583775 where Node prints 654583808. +// +// The three shapes below are the issue's own repro. They fail independently: +// a fix that only rounds at a function boundary passes the third and none of +// the others. + +// ---- 1. straight-line, both intermediates in SSA locals ---- +let s0 = 12345; +let s1 = (s0 * 1103515245 + 12345) & 0x7fffffff; +let s2 = (s1 * 1103515245 + 12345) & 0x7fffffff; +console.log("straight:", s1, s2); + +// ---- 2. loop-carried ---- +let t = 12345; +for (let i = 0; i < 4; i++) { + t = (t * 1103515245 + 12345) & 0x7fffffff; +} +console.log("loop:", t); + +// ---- 3. through a function boundary (already correct pre-fix) ---- +function step(x: number): number { + return (x * 1103515245 + 12345) & 0x7fffffff; +} +console.log("call:", step(step(step(12345)))); + +// ---- the same divergence under every ToInt32-shaped consumer ---- +let u = 1406932606; +console.log("or0:", (u * 1103515245 + 12345) | 0); +console.log("ushr:", (u * 1103515245 + 12345) >>> 0); +console.log("xor:", (u * 1103515245 + 12345) ^ 0); +console.log("shr:", (u * 1103515245 + 12345) >> 3); +console.log("shl:", (u * 1103515245 + 12345) << 1); +console.log("and-neg:", (u * -1103515245 - 12345) & 0x7fffffff); + +// ---- compound assignment and `const` forms ---- +let c = 1406932606; +c *= 1103515245; +c += 12345; +c &= 0x7fffffff; +console.log("compound:", c); + +const k0 = 1406932606; +const k1 = (k0 * 1103515245 + 12345) & 0x7fffffff; +console.log("const:", k1); + +// ---- the un-masked value itself: the rounded double is observable ---- +let raw = 1406932606; +const prod = raw * 1103515245; +console.log("product:", prod, prod + 12345); + +// ---- a full LCG run, the shape that surfaced this ---- +let seed = 42; +const drawn: number[] = []; +for (let i = 0; i < 6; i++) { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + drawn.push(seed); +} +console.log("lcg:", drawn.join(",")); + +// ---- exactness boundary: 2^53 is where the two models part ---- +// 94906265 * 94906265 = 9007199326062225 > 2^53 (rounds); 94906264 * 94906265 +// = 9007199231155960 < 2^53 (exact). Both must print Node's answer. +const lo = 94906264; +const hi = 94906265; +console.log("under-2^53:", (lo * hi + 1) & 0x7fffffff); +console.log("over-2^53:", (hi * hi + 1) & 0x7fffffff); +console.log("at-2^53:", (67108864 * 134217728 + 1) & 0x7fffffff); + +// ---- the other direction: chains that ARE f64-exact must stay exact ---- +// Java-style string hash: |h| < 2^31 and 31 < 2^5, so the product is < 2^36 — +// well inside 2^53. This must keep matching Node bit for bit. +function javaHash(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) { + h = (h * 31 + s.charCodeAt(i)) | 0; + } + return h; +} +console.log("javaHash:", javaHash("the quick brown fox"), javaHash("")); + +// Math.imul is defined as an exact low-32 multiply, so it is NOT subject to the +// double-rounding rule and must stay on the exact path even past 2^53. +function fnv1a(bytes: number[]): number { + let h = 0x811c9dc5 | 0; + for (let i = 0; i < bytes.length; i++) { + h = h ^ bytes[i]; + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} +console.log("fnv1a:", fnv1a([1, 2, 3, 4, 250, 251, 252, 253])); +console.log("imul-big:", Math.imul(1406932606, 1103515245)); + +// 16x16 masked operands multiply to 32 bits — exact, must stay correct. +const m0 = 0xdeadbeef | 0; +const m1 = 0xfeedface | 0; +console.log("masked16:", ((m0 & 0xffff) * (m1 & 0xffff)) | 0); +console.log("shifted:", ((m0 >>> 16) * (m1 >>> 16)) | 0); + +// Computed array indices: i * cols + j stays small and must stay exact. +const cols = 7; +const grid: number[] = []; +for (let i = 0; i < 5 * cols; i++) { + grid.push(i * 3); +} +let gridSum = 0; +for (let i = 0; i < 5; i++) { + for (let j = 0; j < cols; j++) { + gridSum = gridSum + grid[i * cols + j]; + } +} +console.log("grid:", gridSum, grid[2 * cols + 3]); + +// Squaring a loop counter (the sieve shape) — small, exact, must not change. +let sieveTrips = 0; +for (let i = 2; i * i < 400; i++) { + sieveTrips = sieveTrips + i * i; +} +console.log("sieve:", sieveTrips); + +// ---- negatives and zero through the same chain ---- +const probes = [0, 1, -1, 2147483647, -2147483648, 65536, -65536]; +const out: string[] = []; +for (let i = 0; i < probes.length; i++) { + const p = probes[i]; + out.push(String((p * 1103515245 + 12345) & 0x7fffffff)); +} +console.log("probes:", out.join(",")); From 800de4e6ff6112c18280ca037a0511ea6eb90fd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 2 Aug 2026 08:11:12 +0200 Subject: [PATCH 2/6] test(codegen): unit-cover the i32-chain 2^53 exactness bound (#7232) 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 --- .../perry-codegen/src/expr/i32_fast_path.rs | 3 + .../src/expr/i32_fast_path/bits_tests.rs | 256 ++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 crates/perry-codegen/src/expr/i32_fast_path/bits_tests.rs diff --git a/crates/perry-codegen/src/expr/i32_fast_path.rs b/crates/perry-codegen/src/expr/i32_fast_path.rs index 4de745951a..5a64b6799a 100644 --- a/crates/perry-codegen/src/expr/i32_fast_path.rs +++ b/crates/perry-codegen/src/expr/i32_fast_path.rs @@ -17,6 +17,9 @@ use crate::type_analysis::{ }; use crate::types::{DOUBLE, F32, I1, I16, I32, I64, I8}; +#[cfg(test)] +mod bits_tests; + /// Returns true if `e` provably produces a finite double whose magnitude is /// small enough (`|v| < 2^63`) for the unguarded `toint32_fast` lowering. /// Used to skip the NaN/Inf/range guard in `toint32` for integer-arithmetic diff --git a/crates/perry-codegen/src/expr/i32_fast_path/bits_tests.rs b/crates/perry-codegen/src/expr/i32_fast_path/bits_tests.rs new file mode 100644 index 0000000000..bf79b94eea --- /dev/null +++ b/crates/perry-codegen/src/expr/i32_fast_path/bits_tests.rs @@ -0,0 +1,256 @@ +//! #7232: the exactness proof an i32-native chain has to carry. +//! +//! An i32-native chain computes the exact two's-complement low 32 bits of the +//! integer result. JS evaluates the same chain in doubles, rounding at every +//! operator. They agree only while each intermediate is exactly representable +//! as a double (`|v| <= 2^53`), which is what [`i32_chain_magnitude_bits`] +//! proves. +//! +//! Sabotage, both directions: +//! +//! * Widen `F64_EXACT_INTEGER_BITS` past 53 (or drop the `f64_exact_bits` cap +//! from `combine_i32_chain_bits`) and the `*_diverges_*` tests go green while +//! the compiler goes back to printing 654583775 for the issue's LCG step. +//! * Drop the `BitAnd` / `Shr`-`UShr` tightening and the `*_stays_exact_*` +//! tests go red — masked and shifted hash mixing would leave the fast path +//! for no correctness reason. + +use std::collections::{HashMap, HashSet}; + +use perry_hir::{BinaryOp, Expr}; + +use super::{i32_chain_magnitude_bits, FlatConstInfo, I32ChainEnv}; + +/// An environment whose only fact is "locals 1..=4 are integer-valued". +struct Tables { + i32_slots: HashMap, + flat_const_arrays: HashMap, + array_row_aliases: HashMap)>, + integer_locals: HashSet, + empty_fns: HashSet, +} + +impl Tables { + fn new() -> Self { + Self { + i32_slots: HashMap::new(), + flat_const_arrays: HashMap::new(), + array_row_aliases: HashMap::new(), + integer_locals: (1..=4).collect(), + empty_fns: HashSet::new(), + } + } + + fn env(&self) -> I32ChainEnv<'_> { + I32ChainEnv { + i32_slots: &self.i32_slots, + flat_const_arrays: &self.flat_const_arrays, + array_row_aliases: &self.array_row_aliases, + integer_locals: &self.integer_locals, + clamp3_fns: &self.empty_fns, + clamp_u8_fns: &self.empty_fns, + integer_returning_fns: &self.empty_fns, + i32_identity_fns: &self.empty_fns, + } + } +} + +fn bits(e: &Expr) -> Option { + let tables = Tables::new(); + i32_chain_magnitude_bits(e, tables.env()) +} + +fn bin(op: BinaryOp, left: Expr, right: Expr) -> Expr { + Expr::Binary { + op, + left: Box::new(left), + right: Box::new(right), + } +} + +fn mul(left: Expr, right: Expr) -> Expr { + bin(BinaryOp::Mul, left, right) +} + +fn add(left: Expr, right: Expr) -> Expr { + bin(BinaryOp::Add, left, right) +} + +fn and(left: Expr, right: Expr) -> Expr { + bin(BinaryOp::BitAnd, left, right) +} + +/// `x`, an integer-valued local: i32-shaped, magnitude unknown beyond 2^32. +fn x() -> Expr { + Expr::LocalGet(1) +} + +fn y() -> Expr { + Expr::LocalGet(2) +} + +fn byte() -> Expr { + Expr::Uint8ArrayGet { + array: Box::new(Expr::LocalGet(3)), + index: Box::new(Expr::Integer(0)), + } +} + +// --------------------------------------------------------------------------- +// The bug: a product past 2^53 must leave the exact-integer path. +// --------------------------------------------------------------------------- + +/// The issue's own expression: `(x * 1103515245 + 12345) & 0x7fffffff`. +/// 32 + 31 = 63 bits of product — past 2^53, so JS has already rounded and an +/// exact `mul i32` would read low bits the double discarded. +#[test] +fn lcg_step_diverges_and_is_rejected() { + let step = and( + add(mul(x(), Expr::Integer(1103515245)), Expr::Integer(12345)), + Expr::Integer(0x7fffffff), + ); + assert_eq!(bits(&step), None); + // Every sub-chain that contains the product is rejected with it, so no + // consumer can pick up a half-exact intermediate. + assert_eq!(bits(&mul(x(), Expr::Integer(1103515245))), None); + assert_eq!( + bits(&add( + mul(x(), Expr::Integer(1103515245)), + Expr::Integer(12345) + )), + None + ); +} + +/// Two i32-range locals multiplied: 64 bits, rejected. This is the general +/// `i * size` / `i * i` shape, which is only exact because the *runtime* +/// values are small — nothing here proves that. +#[test] +fn unbounded_local_square_diverges_and_is_rejected() { + assert_eq!(bits(&mul(x(), y())), None); +} + +/// The cap applies to `Add`/`Sub` too, not just `Mul`: two products that are +/// each exactly at the ceiling sum to 2^54. +#[test] +fn sum_of_two_ceiling_products_is_rejected() { + // (x & 0x3fffff) * (y & 0x7fffffff) == 22 + 31 == 53 bits: admitted. + let p = mul( + and(x(), Expr::Integer(0x3fffff)), + and(y(), Expr::Integer(0x7fffffff)), + ); + assert_eq!(bits(&p), Some(53)); + // ...but their sum could reach 2^54. + assert_eq!(bits(&add(p.clone(), p)), None); +} + +/// The exactness boundary itself, from both sides. +#[test] +fn boundary_at_2_pow_53() { + let exact = mul( + and(x(), Expr::Integer(0x3fffff)), // 22 bits + and(y(), Expr::Integer(0x7fffffff)), // 31 bits + ); + assert_eq!(bits(&exact), Some(53)); + let one_bit_wider = mul( + and(x(), Expr::Integer(0x7fffff)), // 23 bits + and(y(), Expr::Integer(0x7fffffff)), // 31 bits + ); + assert_eq!(bits(&one_bit_wider), None); +} + +// --------------------------------------------------------------------------- +// The other direction: chains that ARE f64-exact must stay on the fast path. +// --------------------------------------------------------------------------- + +/// Java-style string hashing, `h * 31 + c`: 32 + 5 = 37 bits, comfortably +/// exact. Rejecting this would deoptimize every small-multiplier accumulator. +#[test] +fn small_multiplier_stays_exact() { + assert_eq!(bits(&mul(x(), Expr::Integer(31))), Some(37)); + assert_eq!(bits(&add(mul(x(), Expr::Integer(31)), byte())), Some(38)); +} + +/// `Math.imul` is *defined* as an exact low-32 multiply, so it is exempt from +/// the rounding rule and its result is an i32 — even with a `>i32::MAX` +/// literal operand and even when the true product is astronomically large. +#[test] +fn math_imul_is_exempt_from_the_rounding_rule() { + let imul = Expr::MathImul(Box::new(x()), Box::new(Expr::Integer(1103515245))); + assert_eq!(bits(&imul), Some(32)); + let mixer = Expr::MathImul(Box::new(x()), Box::new(Expr::Integer(0x9e3779b1))); + assert_eq!(bits(&mixer), Some(32)); + // ...and an imul result feeds an ordinary chain as a 32-bit leaf. + assert_eq!(bits(&add(imul, Expr::Integer(1))), Some(33)); +} + +/// Masking with a non-negative literal bounds the operand by the mask, which +/// is what keeps 16x16 mixing on the exact path. +#[test] +fn masked_operands_stay_exact() { + assert_eq!(bits(&and(x(), Expr::Integer(0xffff))), Some(16)); + assert_eq!( + bits(&mul( + and(x(), Expr::Integer(0xffff)), + and(y(), Expr::Integer(0xffff)) + )), + Some(32) + ); +} + +/// An unsigned/signed shift by a literal count drops that many bits, which is +/// what keeps `(h >>> 16) * K` mixing on the exact path. +#[test] +fn shifted_operands_stay_exact() { + let hi = bin(BinaryOp::UShr, x(), Expr::Integer(16)); + assert_eq!(bits(&hi), Some(16)); + assert_eq!(bits(&mul(hi, Expr::Integer(0x5bd1e995))), Some(47)); + let arith = bin(BinaryOp::Shr, y(), Expr::Integer(24)); + assert_eq!(bits(&arith), Some(8)); + // A non-literal count keeps the untightened 32. + assert_eq!(bits(&bin(BinaryOp::UShr, x(), y())), Some(32)); +} + +/// Byte loads are 8-bit leaves, so byte products stay far inside the ceiling. +#[test] +fn byte_leaves_are_eight_bits() { + assert_eq!(bits(&byte()), Some(8)); + assert_eq!(bits(&mul(byte(), byte())), Some(16)); +} + +/// Bitwise results are ToInt32-wrapped, so they reset the bound to 32 and a +/// long masked chain never escalates. +#[test] +fn bitwise_resets_the_bound() { + assert_eq!(bits(&bin(BinaryOp::BitXor, x(), y())), Some(32)); + assert_eq!(bits(&bin(BinaryOp::BitOr, x(), y())), Some(32)); + assert_eq!(bits(&bin(BinaryOp::Shl, x(), Expr::Integer(3))), Some(32)); + // `v | 0` keeps a tighter incoming bound instead of widening to 32. + assert_eq!( + bits(&bin(BinaryOp::BitOr, byte(), Expr::Integer(0))), + Some(8) + ); +} + +/// Leaves the chain never admitted: a `>i32::MAX` literal outside `Math.imul` +/// (its low-32 truncation is not what JS `*` computes) and a local with no +/// integer proof. +#[test] +fn unproven_leaves_are_still_rejected() { + assert_eq!(bits(&Expr::Integer(3000000000)), None); + assert_eq!(bits(&Expr::LocalGet(99)), None); + assert_eq!(bits(&mul(x(), Expr::Integer(3000000000))), None); +} + +/// Literal magnitudes are measured, not assumed: the bound of a literal leaf +/// is its own bit width, which is what makes `x * 31` land at 37 rather than +/// the leaf default of 64. +#[test] +fn literal_leaves_carry_their_own_width() { + assert_eq!(bits(&Expr::Integer(0)), Some(0)); + assert_eq!(bits(&Expr::Integer(1)), Some(1)); + assert_eq!(bits(&Expr::Integer(31)), Some(5)); + assert_eq!(bits(&Expr::Integer(-31)), Some(5)); + assert_eq!(bits(&Expr::Integer(i32::MAX as i64)), Some(31)); + assert_eq!(bits(&Expr::Integer(i32::MIN as i64)), Some(32)); +} From 2e8d1236785158608f778f561d78863267c571b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 2 Aug 2026 08:21:25 +0200 Subject: [PATCH 3/6] fix(codegen): measure a const literal's own width in the i32-chain bound (#7232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/perry-codegen/src/expr/arrays_finds.rs | 5 +++ crates/perry-codegen/src/expr/bigint_set.rs | 1 + .../perry-codegen/src/expr/buffer_access.rs | 3 ++ crates/perry-codegen/src/expr/channel.rs | 2 + .../perry-codegen/src/expr/i32_fast_path.rs | 40 +++++++++++++++++-- .../src/expr/i32_fast_path/bits_tests.rs | 37 +++++++++++++++++ crates/perry-codegen/src/expr/math_simple.rs | 1 + .../src/expr/proven_view_access.rs | 1 + .../perry-codegen/src/lower_call/func_ref.rs | 1 + crates/perry-codegen/src/stmt/let_stmt.rs | 3 ++ 10 files changed, 91 insertions(+), 3 deletions(-) diff --git a/crates/perry-codegen/src/expr/arrays_finds.rs b/crates/perry-codegen/src/expr/arrays_finds.rs index d4c9a691be..8e8dd8a43a 100644 --- a/crates/perry-codegen/src/expr/arrays_finds.rs +++ b/crates/perry-codegen/src/expr/arrays_finds.rs @@ -38,6 +38,7 @@ fn lower_index_i32(ctx: &mut FnCtx<'_>, index: &Expr) -> Result { ctx.flat_const_arrays, &ctx.array_row_aliases, ctx.integer_locals, + &ctx.const_number_locals, ctx.clamp3_functions, ctx.clamp_u8_functions, ctx.integer_returning_functions, @@ -832,6 +833,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ctx.flat_const_arrays, &ctx.array_row_aliases, ctx.integer_locals, + &ctx.const_number_locals, ctx.clamp3_functions, ctx.clamp_u8_functions, ctx.integer_returning_functions, @@ -843,6 +845,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ctx.flat_const_arrays, &ctx.array_row_aliases, ctx.integer_locals, + &ctx.const_number_locals, ctx.clamp3_functions, ctx.clamp_u8_functions, ctx.integer_returning_functions, @@ -922,6 +925,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ctx.flat_const_arrays, &ctx.array_row_aliases, ctx.integer_locals, + &ctx.const_number_locals, ctx.clamp3_functions, ctx.clamp_u8_functions, ctx.integer_returning_functions, @@ -933,6 +937,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ctx.flat_const_arrays, &ctx.array_row_aliases, ctx.integer_locals, + &ctx.const_number_locals, ctx.clamp3_functions, ctx.clamp_u8_functions, ctx.integer_returning_functions, diff --git a/crates/perry-codegen/src/expr/bigint_set.rs b/crates/perry-codegen/src/expr/bigint_set.rs index a9f613dd23..c0316c9b7a 100644 --- a/crates/perry-codegen/src/expr/bigint_set.rs +++ b/crates/perry-codegen/src/expr/bigint_set.rs @@ -320,6 +320,7 @@ fn can_lower_i32_for_collection_value(ctx: &FnCtx<'_>, value: &Expr) -> bool { ctx.flat_const_arrays, &ctx.array_row_aliases, ctx.integer_locals, + &ctx.const_number_locals, ctx.clamp3_functions, ctx.clamp_u8_functions, ctx.integer_returning_functions, diff --git a/crates/perry-codegen/src/expr/buffer_access.rs b/crates/perry-codegen/src/expr/buffer_access.rs index d80d4f9270..9e9693b265 100644 --- a/crates/perry-codegen/src/expr/buffer_access.rs +++ b/crates/perry-codegen/src/expr/buffer_access.rs @@ -207,6 +207,7 @@ fn lower_index_i32_value(ctx: &mut FnCtx<'_>, index: &Expr) -> Result, value: &Expr) -> Result { 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, @@ -244,6 +246,7 @@ pub(crate) fn can_lower_integer_typed_array_store_value(ctx: &FnCtx<'_>, value: 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, diff --git a/crates/perry-codegen/src/expr/channel.rs b/crates/perry-codegen/src/expr/channel.rs index 5450588070..9a52bcb036 100644 --- a/crates/perry-codegen/src/expr/channel.rs +++ b/crates/perry-codegen/src/expr/channel.rs @@ -332,6 +332,7 @@ pub(crate) fn lower_channel_reduction(ctx: &mut FnCtx<'_>, r: &ChannelReduction) &flat_ca, &ara, &int_locals, + &ctx.const_number_locals, ctx.clamp3_functions, ctx.clamp_u8_functions, ctx.integer_returning_functions, @@ -349,6 +350,7 @@ pub(crate) fn lower_channel_reduction(ctx: &mut FnCtx<'_>, r: &ChannelReduction) &flat_ca, &ara, &int_locals, + &ctx.const_number_locals, ctx.clamp3_functions, ctx.clamp_u8_functions, ctx.integer_returning_functions, diff --git a/crates/perry-codegen/src/expr/i32_fast_path.rs b/crates/perry-codegen/src/expr/i32_fast_path.rs index 5a64b6799a..9f803129b2 100644 --- a/crates/perry-codegen/src/expr/i32_fast_path.rs +++ b/crates/perry-codegen/src/expr/i32_fast_path.rs @@ -175,6 +175,7 @@ pub(crate) fn try_lower_flat_const_index_get( &flat_ca, &ara, &int_locals, + &ctx.const_number_locals, ctx.clamp3_functions, ctx.clamp_u8_functions, ctx.integer_returning_functions, @@ -191,6 +192,7 @@ pub(crate) fn try_lower_flat_const_index_get( &flat_ca, &ara, &int_locals, + &ctx.const_number_locals, ctx.clamp3_functions, ctx.clamp_u8_functions, ctx.integer_returning_functions, @@ -383,14 +385,27 @@ fn combine_i32_chain_bits(op: BinaryOp, left: &Expr, right: &Expr, l: u32, r: u3 } } +/// Magnitude bound of a `const` local bound to a numeric literal — an exact +/// integer, or `None` to leave the caller on the untightened 32-bit default. +/// Never *widens* past 32: the i32 chain reads the local's i32 slot, so the +/// value it computes with is ToInt32-shaped whatever the literal was. +fn const_number_magnitude_bits(v: f64) -> Option { + if !v.is_finite() || v.trunc() != v { + return None; + } + let as_i64 = v as i64; + (as_i64 as f64 == v).then(|| integer_magnitude_bits(as_i64).min(I32_CHAIN_LEAF_BITS)) +} + /// Borrowed view of the fact tables the i32-chain rules consult, so the -/// recursion carries one argument instead of eight. +/// recursion carries one argument instead of nine. #[derive(Clone, Copy)] struct I32ChainEnv<'a> { i32_slots: &'a std::collections::HashMap, flat_const_arrays: &'a std::collections::HashMap, array_row_aliases: &'a std::collections::HashMap)>, integer_locals: &'a std::collections::HashSet, + const_number_locals: &'a std::collections::HashMap, clamp3_fns: &'a std::collections::HashSet, clamp_u8_fns: &'a std::collections::HashSet, integer_returning_fns: &'a std::collections::HashSet, @@ -429,8 +444,23 @@ fn i32_chain_magnitude_bits(e: &Expr, env: I32ChainEnv<'_>) -> Option { // `Math.imul` (below) and the runtime helper interpret an operand under // exact-low-32 semantics. Expr::Integer(n) => i32::try_from(*n).ok().map(|_| integer_magnitude_bits(*n)), - Expr::LocalGet(id) => (env.i32_slots.contains_key(id) || env.integer_locals.contains(id)) - .then_some(I32_CHAIN_LEAF_BITS), + Expr::LocalGet(id) => { + if !(env.i32_slots.contains_key(id) || env.integer_locals.contains(id)) { + return None; + } + // A `const` bound to a numeric literal has an exactly-known + // magnitude, and that is what keeps the dominant strided-index + // shape on the exact path once the 2^53 cap exists: in + // `buf[y * WIDTH + x]` the product is measured against WIDTH's + // actual width, not the 32-bit default a plain local gets. + Some( + env.const_number_locals + .get(id) + .copied() + .and_then(const_number_magnitude_bits) + .unwrap_or(I32_CHAIN_LEAF_BITS), + ) + } Expr::Uint8ArrayGet { .. } | Expr::BufferIndexGet { .. } => Some(8), Expr::MathImul(a, b) => { // `Math.imul(x, y) == ToInt32(ToUint32(x) * ToUint32(y) mod 2^32)` @@ -502,12 +532,14 @@ fn i32_chain_magnitude_bits(e: &Expr, env: I32ChainEnv<'_>) -> Option { /// requires — so returning `false` is always the safe direction. We only commit /// to the fast path when every leaf is recognizably int-sourced AND the whole /// chain is provably f64-exact ([`i32_chain_magnitude_bits`]). +#[allow(clippy::too_many_arguments)] pub(crate) fn can_lower_expr_as_i32( e: &Expr, i32_slots: &std::collections::HashMap, flat_const_arrays: &std::collections::HashMap, array_row_aliases: &std::collections::HashMap)>, integer_locals: &std::collections::HashSet, + const_number_locals: &std::collections::HashMap, clamp3_fns: &std::collections::HashSet, clamp_u8_fns: &std::collections::HashSet, integer_returning_fns: &std::collections::HashSet, @@ -520,6 +552,7 @@ pub(crate) fn can_lower_expr_as_i32( flat_const_arrays, array_row_aliases, integer_locals, + const_number_locals, clamp3_fns, clamp_u8_fns, integer_returning_fns, @@ -803,6 +836,7 @@ fn ctx_i32_chain_env<'a>(ctx: &'a FnCtx<'_>) -> I32ChainEnv<'a> { flat_const_arrays: ctx.flat_const_arrays, array_row_aliases: &ctx.array_row_aliases, integer_locals: ctx.native_facts.integer_locals(), + const_number_locals: &ctx.const_number_locals, clamp3_fns: ctx.clamp3_functions, clamp_u8_fns: ctx.clamp_u8_functions, integer_returning_fns: ctx.integer_returning_functions, diff --git a/crates/perry-codegen/src/expr/i32_fast_path/bits_tests.rs b/crates/perry-codegen/src/expr/i32_fast_path/bits_tests.rs index bf79b94eea..774c251d2a 100644 --- a/crates/perry-codegen/src/expr/i32_fast_path/bits_tests.rs +++ b/crates/perry-codegen/src/expr/i32_fast_path/bits_tests.rs @@ -27,6 +27,7 @@ struct Tables { flat_const_arrays: HashMap, array_row_aliases: HashMap)>, integer_locals: HashSet, + const_number_locals: HashMap, empty_fns: HashSet, } @@ -37,6 +38,9 @@ impl Tables { flat_const_arrays: HashMap::new(), array_row_aliases: HashMap::new(), integer_locals: (1..=4).collect(), + // Local 4 is `const K = 100`, a numeric-literal binding whose + // magnitude is exactly known (7 bits). + const_number_locals: HashMap::from([(4, 100.0)]), empty_fns: HashSet::new(), } } @@ -47,6 +51,7 @@ impl Tables { flat_const_arrays: &self.flat_const_arrays, array_row_aliases: &self.array_row_aliases, integer_locals: &self.integer_locals, + const_number_locals: &self.const_number_locals, clamp3_fns: &self.empty_fns, clamp_u8_fns: &self.empty_fns, integer_returning_fns: &self.empty_fns, @@ -89,6 +94,12 @@ fn y() -> Expr { Expr::LocalGet(2) } +/// `const K = 100` — an integer-valued local whose magnitude is 7 bits, not +/// the 32-bit default. +fn k() -> Expr { + Expr::LocalGet(4) +} + fn byte() -> Expr { Expr::Uint8ArrayGet { array: Box::new(Expr::LocalGet(3)), @@ -254,3 +265,29 @@ fn literal_leaves_carry_their_own_width() { assert_eq!(bits(&Expr::Integer(i32::MAX as i64)), Some(31)); assert_eq!(bits(&Expr::Integer(i32::MIN as i64)), Some(32)); } + +/// A `const` bound to a numeric literal carries its literal's magnitude, which +/// is what keeps the dominant strided-index shape `buf[y * WIDTH + x]` exact +/// after the cap. Delete the `const_number_locals` lookup in the `LocalGet` +/// arm and this goes red while `bench_int_arithmetic` loses half its `mul i32`. +#[test] +fn const_literal_locals_carry_their_literal_width() { + assert_eq!(bits(&k()), Some(7)); + // `(y + -1) * WIDTH + (x + -1)` — the convolution index. 33 + 7 = 40. + let row = add(x(), Expr::Integer(-1)); + let idx = add(mul(row, k()), add(y(), Expr::Integer(-1))); + assert_eq!(bits(&idx), Some(41)); + // A plain integer local on the same spot is 32 bits and stays rejected. + assert_eq!(bits(&add(mul(add(x(), Expr::Integer(-1)), y()), x())), None); +} + +/// A `const` whose literal is not an exact integer, or is outside i32 range, +/// falls back to the untightened 32 rather than widening the bound. +#[test] +fn const_bits_never_widen_past_the_leaf_default() { + assert_eq!(super::const_number_magnitude_bits(100.0), Some(7)); + assert_eq!(super::const_number_magnitude_bits(0.5), None); + assert_eq!(super::const_number_magnitude_bits(f64::NAN), None); + assert_eq!(super::const_number_magnitude_bits(f64::INFINITY), None); + assert_eq!(super::const_number_magnitude_bits(1e18), Some(32)); +} diff --git a/crates/perry-codegen/src/expr/math_simple.rs b/crates/perry-codegen/src/expr/math_simple.rs index 34c25f163c..c81adf7d02 100644 --- a/crates/perry-codegen/src/expr/math_simple.rs +++ b/crates/perry-codegen/src/expr/math_simple.rs @@ -106,6 +106,7 @@ fn can_lower_i32_for_collection_value(ctx: &FnCtx<'_>, value: &Expr) -> bool { ctx.flat_const_arrays, &ctx.array_row_aliases, ctx.integer_locals, + &ctx.const_number_locals, ctx.clamp3_functions, ctx.clamp_u8_functions, ctx.integer_returning_functions, diff --git a/crates/perry-codegen/src/expr/proven_view_access.rs b/crates/perry-codegen/src/expr/proven_view_access.rs index 4034a4e90a..703c63f1d0 100644 --- a/crates/perry-codegen/src/expr/proven_view_access.rs +++ b/crates/perry-codegen/src/expr/proven_view_access.rs @@ -172,6 +172,7 @@ fn proven_view_for( ctx.flat_const_arrays, &ctx.array_row_aliases, ctx.integer_locals, + &ctx.const_number_locals, ctx.clamp3_functions, ctx.clamp_u8_functions, ctx.integer_returning_functions, diff --git a/crates/perry-codegen/src/lower_call/func_ref.rs b/crates/perry-codegen/src/lower_call/func_ref.rs index 145fc99522..b8cdd24554 100644 --- a/crates/perry-codegen/src/lower_call/func_ref.rs +++ b/crates/perry-codegen/src/lower_call/func_ref.rs @@ -325,6 +325,7 @@ pub fn try_lower_func_ref_call( ctx.flat_const_arrays, &ctx.array_row_aliases, ctx.integer_locals, + &ctx.const_number_locals, ctx.clamp3_functions, ctx.clamp_u8_functions, ctx.integer_returning_functions, diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 2b47108c0c..3a6b4f9c25 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -1209,6 +1209,7 @@ pub(crate) fn lower_let( &flat_ca, &ara, &int_locals, + &ctx.const_number_locals, ctx.clamp3_functions, ctx.clamp_u8_functions, ctx.integer_returning_functions, @@ -1365,6 +1366,7 @@ pub(crate) fn lower_let( &flat_ca, &ara, &int_locals, + &ctx.const_number_locals, ctx.clamp3_functions, ctx.clamp_u8_functions, ctx.integer_returning_functions, @@ -1555,6 +1557,7 @@ pub(crate) fn lower_let( &flat_ca, &ara, &int_locals, + &ctx.const_number_locals, ctx.clamp3_functions, ctx.clamp_u8_functions, ctx.integer_returning_functions, From 85de7c11e9320d574a7ac874bb552f3deab35779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 2 Aug 2026 08:29:49 +0200 Subject: [PATCH 4/6] fix(codegen): keep the last-resort bitwise arm off the exactness proof (#7232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../perry-codegen/src/expr/i32_fast_path.rs | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/perry-codegen/src/expr/i32_fast_path.rs b/crates/perry-codegen/src/expr/i32_fast_path.rs index 9f803129b2..0eafafd7d9 100644 --- a/crates/perry-codegen/src/expr/i32_fast_path.rs +++ b/crates/perry-codegen/src/expr/i32_fast_path.rs @@ -1433,15 +1433,25 @@ fn lower_expr_native_i32(ctx: &mut FnCtx<'_>, e: &Expr) -> Result left, right, } if matches!(right.as_ref(), Expr::Integer(0)) => lower_expr_native_i32(ctx, left)?.value, - // Last-resort integer arithmetic, reached only when `lower_expr_value` - // could not produce a value at all. It is a SECOND emitter of the same - // `mul/add i32` chain the structural path above emits, so it carries - // the same #7232 exactness proof — otherwise a shape that reaches here + // Last-resort ARITHMETIC, reached when `lower_expr_value` could not + // produce a value at all. It is a SECOND emitter of the same + // `add/sub/mul i32` the structural path above emits, so it carries the + // same #7232 exactness proof — otherwise a shape that reaches here // would evaluate past double precision behind the fixed gate. Without // the proof the chain is evaluated in doubles and ToInt32-wrapped, // which is what the spec asks for. + // + // Scoped to `Add`/`Sub`/`Mul`, the only operators whose exact integer + // result can leave the double's exact range. The bitwise arm below is + // ToInt32-wrapped by definition and needs no proof — and must NOT get + // one: its operands here are untyped-but-ToInt32-consumed values + // (bcryptjs's `S[l >>> 24]` reaches exactly this arm), and routing + // those through `lower_expr` swaps a native `lshr` for a + // `js_dynamic_ushr` call. Measured on + // `benchmarks/suite/bench_typed_array_untyped_access.ts`. Expr::Binary { op, .. } - if is_i32_chain_op(*op) && region_i32_chain_magnitude_bits(ctx, e).is_none() => + if matches!(op, BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul) + && region_i32_chain_magnitude_bits(ctx, e).is_none() => { let d = lower_expr(ctx, e)?; ctx.block().toint32(&d) From a1f2ef6efff59c3b895f80617ba03d7549ef1fd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 2 Aug 2026 08:31:35 +0200 Subject: [PATCH 5/6] docs: changelog fragment for #7232 (PR #7237) Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9 --- changelog.d/7237-i32-chain-double-rounding.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 changelog.d/7237-i32-chain-double-rounding.md diff --git a/changelog.d/7237-i32-chain-double-rounding.md b/changelog.d/7237-i32-chain-double-rounding.md new file mode 100644 index 0000000000..a74b697e04 --- /dev/null +++ b/changelog.d/7237-i32-chain-double-rounding.md @@ -0,0 +1,42 @@ +### Fixed + +- **Integer arithmetic in a local no longer evaluates past double precision (#7232).** + `(x * 1103515245 + 12345) & 0x7fffffff` — an LCG step — printed `654583775` + where Node prints `654583808`. The i32-native fast path + (`crates/perry-codegen/src/expr/i32_fast_path.rs`) 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. Wrong straight-line and loop-carried, correct only + through a function boundary (where the intermediate gets NaN-boxed and + therefore rounded) — so PRNG seeds, hash mixing, checksum accumulators and ID + arithmetic diverged silently, with no throw and no warning. + + The old admission rule required only that every integer *literal* fit in i32, + which 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. It is replaced by a magnitude bound carried through + the whole chain and 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 are different + numbers**, so the chain now falls onto the f64 path whose `fmul`/`fadd` round + where the spec says to. The cap applies to `Add`/`Sub` as well as `Mul` — + two ceiling-width products sum to 2^54 — and both emitters of the chain + consult the same bound, so the gate and the last-resort arithmetic arm cannot + drift apart. + + The bound is measured rather than assumed, so correct code keeps its fast + path: an integer literal contributes its own bit width (`h * 31 + c` is 37 + bits, not 64), `x & m` with a non-negative literal mask lands in `[0, m]`, + `x >> k` / `x >>> k` by a literal count drop `k` bits, a `const` bound to a + numeric literal contributes *its* width (which is what keeps + `buf[y * WIDTH + x]` exact), and `Math.imul` is exempt entirely. Across all + 30 programs in `benchmarks/suite/`, 29 emit byte-identical LLVM IR to before; + the exception is `11_prime_sieve`, whose `for (let j = i * i; …)` preheader + is genuinely unbounded and now rounds. + + Covered by `test-files/test_gap_7232_i32_chain_double_rounding.ts` (the + issue's three shapes, every ToInt32-shaped consumer, the 2^53 boundary from + both sides, and the chains that must stay exact) and by twelve unit tests in + `crates/perry-codegen/src/expr/i32_fast_path/bits_tests.rs` that are + sabotage-checked in both directions. From a1d05b91bf0343ff07fa814b7bf5fcaba35bc3ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 2 Aug 2026 08:41:59 +0200 Subject: [PATCH 6/6] review: drop two discarded i32 eligibility checks, fix a test comment 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 --- changelog.d/7237-i32-chain-double-rounding.md | 2 +- .../perry-codegen/src/expr/buffer_access.rs | 40 ++++--------------- .../src/expr/i32_fast_path/bits_tests.rs | 3 +- 3 files changed, 11 insertions(+), 34 deletions(-) diff --git a/changelog.d/7237-i32-chain-double-rounding.md b/changelog.d/7237-i32-chain-double-rounding.md index a74b697e04..b6953d13b8 100644 --- a/changelog.d/7237-i32-chain-double-rounding.md +++ b/changelog.d/7237-i32-chain-double-rounding.md @@ -37,6 +37,6 @@ Covered by `test-files/test_gap_7232_i32_chain_double_rounding.ts` (the issue's three shapes, every ToInt32-shaped consumer, the 2^53 boundary from - both sides, and the chains that must stay exact) and by twelve unit tests in + both sides, and the chains that must stay exact) and by fourteen unit tests in `crates/perry-codegen/src/expr/i32_fast_path/bits_tests.rs` that are sabotage-checked in both directions. diff --git a/crates/perry-codegen/src/expr/buffer_access.rs b/crates/perry-codegen/src/expr/buffer_access.rs index 9e9693b265..f2fd3c0724 100644 --- a/crates/perry-codegen/src/expr/buffer_access.rs +++ b/crates/perry-codegen/src/expr/buffer_access.rs @@ -200,43 +200,19 @@ pub(crate) fn access_facts_for_spec( } } +// Both of these used to branch on `can_lower_expr_as_i32` into two identical +// arms — `lower_expr_native(.., I32)` either way, so the predicate's answer was +// computed and thrown away. `lower_expr_native` makes the same decision +// internally and correctly; the outer call was pure cost, and #7232 made it a +// whole-subtree walk instead of a shape check. (CodeRabbit, PR #7237.) + fn lower_index_i32_value(ctx: &mut FnCtx<'_>, index: &Expr) -> Result { - 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 { - 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) } pub(crate) fn can_lower_integer_typed_array_store_value(ctx: &FnCtx<'_>, value: &Expr) -> bool { diff --git a/crates/perry-codegen/src/expr/i32_fast_path/bits_tests.rs b/crates/perry-codegen/src/expr/i32_fast_path/bits_tests.rs index 774c251d2a..3f395da143 100644 --- a/crates/perry-codegen/src/expr/i32_fast_path/bits_tests.rs +++ b/crates/perry-codegen/src/expr/i32_fast_path/bits_tests.rs @@ -273,7 +273,8 @@ fn literal_leaves_carry_their_own_width() { #[test] fn const_literal_locals_carry_their_literal_width() { assert_eq!(bits(&k()), Some(7)); - // `(y + -1) * WIDTH + (x + -1)` — the convolution index. 33 + 7 = 40. + // `(x + -1) * WIDTH + (y + -1)` — the convolution index. The product is + // 33 + 7 = 40 bits; the outer `Add` takes it to 41. let row = add(x(), Expr::Integer(-1)); let idx = add(mul(row, k()), add(y(), Expr::Integer(-1))); assert_eq!(bits(&idx), Some(41));