diff --git a/changelog.d/6898-native-i32-integer-locals.md b/changelog.d/6898-native-i32-integer-locals.md new file mode 100644 index 0000000000..8030419090 --- /dev/null +++ b/changelog.d/6898-native-i32-integer-locals.md @@ -0,0 +1,14 @@ +Native-i32 residency for integer-valued locals seeded by a possibly-out-of-bounds INT typed-array element read — the bcryptjs `_encipher` Feistel accumulators `l`/`r` (`let l = lr[off]`, then only bitwise-updated). They are logically int32 but were stored as f64, paying an `fptosi`/`sitofp` round-trip on every bitwise access. + +A new whole-function use-analysis (`collectors/int_valued_ta_locals.rs`) admits a `let`-declared local to `integer_locals` — unlocking the existing i32 shadow-slot + i32-chain lowering — only when BOTH hold, which is what keeps it sound (an `Int32Array` OOB read is `undefined`, not `0`, unlike `Uint8Array`): + +- **every write** is i32-producing: an int-kind typed-array read (`Int8/Uint8/Uint8Clamped/Int16/Uint16/Int32`), a bitwise op, `~`, `Math.imul`, an i32 literal, or a `Uint8Array`/`Buffer` byte read — NOT additive `+`/`-`/`*` (i32 overflow; this is why `_encipher`'s `n` stays f64), and not a copy/call/anything else; and +- **every observation** is in a `ToInt32`-coercing context — a bitwise operand or the value stored into an int-kind typed-array element — NEVER where `undefined`-vs-integer is distinguishable (array index, additive operand, comparison, call argument, `return`, `console.log`, `String()`, `typeof`, plain-array/field store, …). + +Under those constraints the value is always fed through `ToInt32` (`ToInt32(undefined) == 0`) and the i32 slot is seeded with the same `0` for an OOB read, so the i32 and f64 representations are byte-for-byte indistinguishable. Deliberately conservative (params, `++`/`--` targets, closure-captured locals, and copy chains are excluded); no fixpoint required. + +Two supporting codegen fixes in `stmt/let_stmt.rs`: (1) a possibly-non-finite i32-slot init is seeded with the NaN-safe `toint32_wrap` (`ToInt32(undefined) == 0`) instead of a raw `fptosi` (LLVM poison — `0` on aarch64 but a garbage sentinel on x86-64), while known-finite inits keep the cheaper `fptosi`; (2) an `Any`-typed proven-integer local is refined to `Number` when the structural refiner can't type it, so it takes the numeric Let/LocalSet lowering (no `Any` boxing, no GC shadow-slot tracking) and `-O3` can collapse the residual round-trips. + +Gated by `PERRY_INT_VALUED_LOCALS` (default on; `=0`/`off`/`false` disables), keyed into the object cache. + +`enc.ts` (2.1M `_encipher` calls, quiet M1, min-of-9): 1347 ms -> 1010 ms (1.33x), byte-exact (`lr0=2135713266 lr1=-1949122846`); optimized `_encipher` `fptosi` 8 -> 0. New gap test `test_gap_int_valued_ta_locals.ts` pins the OOB-observability boundary (eligible accumulator byte-exact even on an OOB init; ineligible sibling still observes `undefined`), verified with the flag on, off, and under `PERRY_GC_FORCE_EVACUATE=1`. diff --git a/crates/perry-codegen/src/collectors/hir_facts.rs b/crates/perry-codegen/src/collectors/hir_facts.rs index 018e5e8e12..72fa13c954 100644 --- a/crates/perry-codegen/src/collectors/hir_facts.rs +++ b/crates/perry-codegen/src/collectors/hir_facts.rs @@ -317,12 +317,28 @@ pub(crate) fn collect_type_facts( compile_time_constants: &HashMap, module_dispatch: &super::ModuleDispatchFacts, ) -> TypeFacts { - let integer_locals = super::integer_locals::collect_integer_locals( + let mut integer_locals = super::integer_locals::collect_integer_locals( stmts, flat_const_ids, clamp_fn_ids, arg_dependent_clamp_fn_ids, ); + // Native-i32 residency for integer-valued locals whose init/writes include a + // possibly-out-of-bounds INT typed-array element read (bcryptjs `_encipher` + // Feistel accumulators `l`/`r`). Sound only under a whole-function + // observation constraint — see `int_valued_ta_locals`. Gated by + // `PERRY_INT_VALUED_LOCALS` (keyed into the object cache). Boxed / module- + // global locals are excluded (they never take the i32 shadow slot and would + // only pollute the fact for other consumers). + if super::int_valued_ta_locals::enabled() { + let extra = + super::int_valued_ta_locals::collect_int_valued_ta_locals(stmts, params, binding_types); + for id in extra { + if !boxed_vars.contains(&id) && !module_globals.contains_key(&id) { + integer_locals.insert(id); + } + } + } let unsigned_i32_locals = super::i32_locals::collect_unsigned_i32_locals(stmts); let not_bigint_locals = super::not_bigint_locals::collect_not_bigint_locals(stmts, params, binding_types); diff --git a/crates/perry-codegen/src/collectors/int_valued_ta_locals.rs b/crates/perry-codegen/src/collectors/int_valued_ta_locals.rs new file mode 100644 index 0000000000..e786b66dad --- /dev/null +++ b/crates/perry-codegen/src/collectors/int_valued_ta_locals.rs @@ -0,0 +1,657 @@ +//! Flow analysis: locals safe to treat as native-i32 ("integer-valued") even +//! though (at least) one of their writes is a *possibly out-of-bounds* integer +//! typed-array element read. +//! +//! ## Motivation (bcryptjs `_encipher` Feistel accumulators) +//! +//! ```ignore +//! function _encipher(lr: Int32Array, off: number, P: Int32Array, S: Int32Array) { +//! let l = lr[off], r = lr[off + 1]; // int typed-array reads (index UNBOUNDED) +//! l ^= P[0]; // only ever bitwise-updated +//! ... S[l >>> 24] ... S[l & 0xff] ... // only ever read in bitwise / index ctx +//! lr[off + 1] = l; // stored back into an int typed array +//! } +//! ``` +//! +//! `l` / `r` are logically int32, but their declared type is erased to `Any` +//! (the `let l = lr[off]` inference does not propagate the element type). The +//! existing `collect_integer_locals` only admits a typed-array element read as +//! integer-valued when the index is *statically proven in-bounds* +//! (`collect_int_ta_load_let_ids`); an unbounded `lr[off]` is rejected, so `l` +//! never enters `integer_locals`, never gets an i32 shadow slot, and every +//! `l ^ x` / `S[l >>> 24]` pays an `fptosi`/`sitofp` round-trip. +//! +//! ## The soundness trap +//! +//! An `Int32Array` element read is int32 **only in-bounds**. An OOB / negative / +//! fractional index yields **`undefined`** (a NaN-boxed value), NOT an integer. +//! (`Uint8ArrayGet` is safely integer-valued because its accessor returns `0` +//! OOB — a general typed-array read does not.) So marking such a local i32 +//! unconditionally would let `let x = S[oob]; console.log(x)` print a number +//! (`fptosi(undefined)` garbage / the seeded `0`) where JS prints `undefined`. +//! +//! ## What makes it sound +//! +//! A local is admitted here **only** when BOTH hold: +//! +//! 1. **Every write** produces an i32-representable value OR is an int-kind +//! typed-array element read (`Int8/Uint8/Uint8Clamped/Int16/Uint16/Int32` — +//! NOT `Uint32`, NOT the float / bigint kinds, NOT a plain-array `[]`): +//! a bitwise op (`& | ^ << >> >>>`), `~`, `Math.imul`, an i32 literal, or a +//! `Uint8ArrayGet`/`BufferIndexGet`. NOT additive `+`/`-`/`*` (can overflow +//! i32 — this is why `n` in `_encipher` stays f64), NOT a copy/call/anything +//! else. +//! 2. **Every observation** is in an integer-coercing context — the direct +//! operand of a bitwise binary/unary op, or the value stored into an +//! int-kind typed-array / `Uint8Array` / `Buffer` element. NEVER a context +//! where `undefined`-vs-integer is distinguishable (array index, additive +//! operand, `%`/`/`, comparison, call argument, `return`, `console.log`, +//! `String()`, `typeof`, property/field/plain-array store, `+` string, …). +//! +//! Under (2) the local's runtime value is *always* fed through `ToInt32` +//! (`ToInt32(undefined) == 0`), and the i32 slot is seeded with the same `0` +//! for an OOB read (see the NaN-safe seed in `stmt/let_stmt.rs`), so the two +//! representations are byte-for-byte indistinguishable — while the fast i32 +//! chain is unlocked. As soon as a value passes through one bitwise op the +//! `undefined`→`0` collapse has already happened identically on both paths, so +//! no transitive constraint on *downstream* locals is needed. +//! +//! ## Under-approximation +//! +//! This is deliberately conservative (the #6794 family rule: a correct 1.1× +//! beats an unsound 1.42×). It requires the local to be `let`-declared (params +//! excluded — their incoming argument is an unmodeled write), rejects `++`/`--` +//! targets, rejects any local referenced inside a closure body, and does NOT +//! chase copy chains (`m = l`). Anything unproven is simply left as f64. No +//! fixpoint is required: rule (1) is judged per write structurally (no reliance +//! on other candidates) and rule (2) is a single context-aware walk. +//! +//! Gated by `PERRY_INT_VALUED_LOCALS` (default on; `=0`/`off`/`false` disables +//! for A/B bisection — keyed into the object cache in `object_cache.rs`). + +use std::collections::{HashMap, HashSet}; + +use perry_hir::types::Type as HirType; +use perry_hir::{BinaryOp, Expr, Param, Stmt, UnaryOp}; + +/// `PERRY_INT_VALUED_LOCALS` gate. Enabled by default; `=0`/`off`/`false` +/// disables the analysis (returns an empty set), reverting the affected locals +/// to the f64 representation. Mirrors the sibling codegen fast-path env gates. +pub fn enabled() -> bool { + !matches!( + std::env::var("PERRY_INT_VALUED_LOCALS").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ) +} + +/// Integer typed-array element kinds whose value round-trips through a signed +/// i32 slot AND whose OOB read (`undefined`) is `ToInt32`-equal to `0`. +/// Excludes `Uint32Array` (upper half does not fit a signed i32) and the +/// float / bigint kinds. Mirrors `i32_locals::typed_array_kind_elem_fits_i32` +/// but keyed on the class name. +fn is_int_elem_typed_array_class(name: &str) -> bool { + matches!( + name, + "Int8Array" + | "Uint8Array" + | "Uint8ClampedArray" + | "Int16Array" + | "Uint16Array" + | "Int32Array" + ) +} + +/// True when `object` is a local/param whose declared type is an int-kind +/// typed array (so `object[i]` reads an integer-or-`undefined`, and +/// `object[i] = v` coerces `v` via `ToInt32`/`ToUint8`/…). +fn receiver_is_int_kind_ta(object: &Expr, types: &HashMap) -> bool { + let Expr::LocalGet(id) = object else { + return false; + }; + matches!(types.get(id), Some(HirType::Named(name)) if is_int_elem_typed_array_class(name)) +} + +/// A bare int-kind typed-array element read `S[idx]` (any index — possibly OOB). +fn is_int_kind_ta_read(e: &Expr, types: &HashMap) -> bool { + matches!(e, Expr::IndexGet { object, .. } if receiver_is_int_kind_ta(object, types)) +} + +fn is_bitwise_binop(op: BinaryOp) -> bool { + matches!( + op, + BinaryOp::BitAnd + | BinaryOp::BitOr + | BinaryOp::BitXor + | BinaryOp::Shl + | BinaryOp::Shr + | BinaryOp::UShr + ) +} + +/// Rule (1): a write whose value is *always* a genuine 32-bit integer, OR a +/// possibly-OOB int typed-array read (integer in-bounds, `undefined` OOB — made +/// observationally equivalent to `0` by rule (2)). Rejects additive / `*` / +/// `/` / `%` (i32 overflow / non-integer), copies, calls, and everything else. +fn write_is_i32_producing_safe(e: &Expr, types: &HashMap) -> bool { + match e { + Expr::Integer(n) => super::i32_locals::integer_literal_fits_i32(*n), + // Byte reads: `0` OOB, always integer. + Expr::Uint8ArrayGet { .. } | Expr::BufferIndexGet { .. } => true, + // Int-kind typed-array element read (possibly OOB → `undefined`). + Expr::IndexGet { object, .. } => receiver_is_int_kind_ta(object, types), + // Bitwise ops coerce both operands to int32 and yield int32 regardless + // of operand shapes — no operand check needed. + Expr::Binary { op, .. } => is_bitwise_binop(*op), + Expr::Unary { + op: UnaryOp::BitNot, + .. + } => true, + Expr::MathImul(_, _) => true, + _ => false, + } +} + +/// One-pass structural facts gathered before eligibility is decided. +#[derive(Default)] +struct Facts<'a> { + /// Every write (Let init + `LocalSet` rhs) per local. + writes: HashMap>, + /// Locals introduced by a `Stmt::Let` (candidates must be `let`-declared — + /// params carry an unmodeled incoming-argument write). + let_declared: HashSet, + /// Locals with ≥1 int-kind typed-array element read write (the seed: only + /// these need this analysis; a local with no such write is already handled + /// by `collect_integer_locals` when it qualifies). + seeded: HashSet, + /// Targets of `++`/`--` — excluded (the update's `± 1` can overflow i32 and + /// is not modeled as a write here). + update_targets: HashSet, + /// Locals referenced (read or written) anywhere inside a closure body — + /// excluded (a captured local cannot use the i32 slot). + closure_refs: HashSet, +} + +pub fn collect_int_valued_ta_locals( + stmts: &[Stmt], + params: &[Param], + binding_types: &HashMap, +) -> HashSet { + // Declared-type map (params + let bindings), used to classify typed-array + // receivers. Params are included so `lr: Int32Array` resolves. + let mut types: HashMap = binding_types.clone(); + for p in params { + types.entry(p.id).or_insert_with(|| p.ty.clone()); + } + + let mut facts = Facts::default(); + collect_facts(stmts, &types, &mut facts); + + // Rule (1) admission. A candidate is a `let`-declared local with ≥1 + // int-TA-read write, whose EVERY write is i32-producing-safe, that is not a + // `++`/`--` target and not referenced in a closure. + let mut candidates: HashSet = HashSet::new(); + for (&id, ws) in &facts.writes { + if !facts.let_declared.contains(&id) + || !facts.seeded.contains(&id) + || facts.update_targets.contains(&id) + || facts.closure_refs.contains(&id) + { + continue; + } + if ws.iter().all(|w| write_is_i32_producing_safe(w, &types)) { + candidates.insert(id); + } + } + if candidates.is_empty() { + return candidates; + } + + // Rule (2) observation check: disqualify any candidate read in a + // non-`ToInt32`-coercing position. + let mut disqualified: HashSet = HashSet::new(); + observe_stmts(stmts, &candidates, &types, &mut disqualified); + + candidates.retain(|id| !disqualified.contains(id)); + candidates +} + +// --------------------------------------------------------------------------- +// Fact collection (writes / seeds / update targets / closure refs). +// --------------------------------------------------------------------------- + +fn collect_facts<'a>(stmts: &'a [Stmt], types: &HashMap, facts: &mut Facts<'a>) { + for s in stmts { + match s { + Stmt::Let { id, init, .. } => { + facts.let_declared.insert(*id); + if let Some(e) = init { + record_write(*id, e, types, facts); + collect_facts_expr(e, types, facts); + } + } + Stmt::Expr(e) | Stmt::Throw(e) => collect_facts_expr(e, types, facts), + Stmt::Return(opt) => { + if let Some(e) = opt { + collect_facts_expr(e, types, facts); + } + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + collect_facts_expr(condition, types, facts); + collect_facts(then_branch, types, facts); + if let Some(eb) = else_branch { + collect_facts(eb, types, facts); + } + } + Stmt::While { condition, body } => { + collect_facts_expr(condition, types, facts); + collect_facts(body, types, facts); + } + Stmt::DoWhile { body, condition } => { + collect_facts(body, types, facts); + collect_facts_expr(condition, types, facts); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(i) = init { + collect_facts(std::slice::from_ref(i.as_ref()), types, facts); + } + if let Some(c) = condition { + collect_facts_expr(c, types, facts); + } + if let Some(u) = update { + collect_facts_expr(u, types, facts); + } + collect_facts(body, types, facts); + } + Stmt::Labeled { body, .. } => { + collect_facts(std::slice::from_ref(body.as_ref()), types, facts); + } + Stmt::Try { + body, + catch, + finally, + } => { + collect_facts(body, types, facts); + if let Some(c) = catch { + collect_facts(&c.body, types, facts); + } + if let Some(f) = finally { + collect_facts(f, types, facts); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + collect_facts_expr(discriminant, types, facts); + for case in cases { + if let Some(t) = &case.test { + collect_facts_expr(t, types, facts); + } + collect_facts(&case.body, types, facts); + } + } + Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) => {} + } + } +} + +fn record_write<'a>(id: u32, rhs: &'a Expr, types: &HashMap, facts: &mut Facts<'a>) { + facts.writes.entry(id).or_default().push(rhs); + if is_int_kind_ta_read(rhs, types) { + facts.seeded.insert(id); + } +} + +fn collect_facts_expr<'a>(e: &'a Expr, types: &HashMap, facts: &mut Facts<'a>) { + match e { + Expr::LocalSet(id, rhs) => { + record_write(*id, rhs, types, facts); + } + Expr::Update { id, .. } => { + facts.update_targets.insert(*id); + } + Expr::Closure { .. } => { + // Everything a closure touches is excluded from candidacy. + collect_closure_refs(e, &mut facts.closure_refs); + // Still descend to record nested `Update` targets on ENCLOSING + // locals (defensive; those ids are already in `closure_refs`). + perry_hir::walker::walk_expr_children(e, &mut |c| collect_facts_expr(c, types, facts)); + return; + } + _ => {} + } + perry_hir::walker::walk_expr_children(e, &mut |c| collect_facts_expr(c, types, facts)); +} + +/// Collect every local id read or written anywhere inside `e` (used to exclude +/// closure-touched candidates). Walks closure bodies (statements) too. +fn collect_closure_refs(e: &Expr, out: &mut HashSet) { + match e { + Expr::LocalGet(id) | Expr::Update { id, .. } => { + out.insert(*id); + } + Expr::LocalSet(id, value) => { + out.insert(*id); + collect_closure_refs(value, out); + } + Expr::Closure { body, .. } => { + for s in body { + collect_closure_refs_stmt(s, out); + } + perry_hir::walker::walk_expr_children(e, &mut |c| collect_closure_refs(c, out)); + } + _ => { + perry_hir::walker::walk_expr_children(e, &mut |c| collect_closure_refs(c, out)); + } + } +} + +fn collect_closure_refs_stmt(s: &Stmt, out: &mut HashSet) { + match s { + Stmt::Let { id, init, .. } => { + out.insert(*id); + if let Some(e) = init { + collect_closure_refs(e, out); + } + } + Stmt::Expr(e) | Stmt::Throw(e) => collect_closure_refs(e, out), + Stmt::Return(opt) => { + if let Some(e) = opt { + collect_closure_refs(e, out); + } + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + collect_closure_refs(condition, out); + for s in then_branch { + collect_closure_refs_stmt(s, out); + } + if let Some(eb) = else_branch { + for s in eb { + collect_closure_refs_stmt(s, out); + } + } + } + Stmt::While { condition, body } => { + collect_closure_refs(condition, out); + for s in body { + collect_closure_refs_stmt(s, out); + } + } + Stmt::DoWhile { body, condition } => { + for s in body { + collect_closure_refs_stmt(s, out); + } + collect_closure_refs(condition, out); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(i) = init { + collect_closure_refs_stmt(i, out); + } + if let Some(c) = condition { + collect_closure_refs(c, out); + } + if let Some(u) = update { + collect_closure_refs(u, out); + } + for s in body { + collect_closure_refs_stmt(s, out); + } + } + Stmt::Labeled { body, .. } => collect_closure_refs_stmt(body, out), + Stmt::Try { + body, + catch, + finally, + } => { + for s in body { + collect_closure_refs_stmt(s, out); + } + if let Some(c) = catch { + for s in &c.body { + collect_closure_refs_stmt(s, out); + } + } + if let Some(f) = finally { + for s in f { + collect_closure_refs_stmt(s, out); + } + } + } + Stmt::Switch { + discriminant, + cases, + } => { + collect_closure_refs(discriminant, out); + for case in cases { + if let Some(t) = &case.test { + collect_closure_refs(t, out); + } + for s in &case.body { + collect_closure_refs_stmt(s, out); + } + } + } + _ => {} + } +} + +// --------------------------------------------------------------------------- +// Rule (2): observation check. `coercing` = whether a bare `LocalGet(cand)` in +// THIS position is fed through `ToInt32` (so an OOB `undefined` reads as `0`). +// --------------------------------------------------------------------------- + +fn observe_stmts( + stmts: &[Stmt], + cands: &HashSet, + types: &HashMap, + disq: &mut HashSet, +) { + for s in stmts { + match s { + Stmt::Let { init, .. } => { + if let Some(e) = init { + observe(e, false, cands, types, disq); + } + } + Stmt::Expr(e) | Stmt::Throw(e) => observe(e, false, cands, types, disq), + Stmt::Return(opt) => { + if let Some(e) = opt { + observe(e, false, cands, types, disq); + } + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + observe(condition, false, cands, types, disq); + observe_stmts(then_branch, cands, types, disq); + if let Some(eb) = else_branch { + observe_stmts(eb, cands, types, disq); + } + } + Stmt::While { condition, body } => { + observe(condition, false, cands, types, disq); + observe_stmts(body, cands, types, disq); + } + Stmt::DoWhile { body, condition } => { + observe_stmts(body, cands, types, disq); + observe(condition, false, cands, types, disq); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(i) = init { + observe_stmts(std::slice::from_ref(i.as_ref()), cands, types, disq); + } + if let Some(c) = condition { + observe(c, false, cands, types, disq); + } + if let Some(u) = update { + observe(u, false, cands, types, disq); + } + observe_stmts(body, cands, types, disq); + } + Stmt::Labeled { body, .. } => { + observe_stmts(std::slice::from_ref(body.as_ref()), cands, types, disq); + } + Stmt::Try { + body, + catch, + finally, + } => { + observe_stmts(body, cands, types, disq); + if let Some(c) = catch { + observe_stmts(&c.body, cands, types, disq); + } + if let Some(f) = finally { + observe_stmts(f, cands, types, disq); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + observe(discriminant, false, cands, types, disq); + for case in cases { + if let Some(t) = &case.test { + observe(t, false, cands, types, disq); + } + observe_stmts(&case.body, cands, types, disq); + } + } + _ => {} + } + } +} + +fn observe( + e: &Expr, + coercing: bool, + cands: &HashSet, + types: &HashMap, + disq: &mut HashSet, +) { + match e { + Expr::LocalGet(id) => { + if cands.contains(id) && !coercing { + disq.insert(*id); + } + } + // A `++`/`--` target is already excluded at admission; if one slips + // through as a read, it is not a coercing observation. + Expr::Update { id, .. } => { + if cands.contains(id) { + disq.insert(*id); + } + } + // Bitwise binary: both operands are `ToInt32`-coerced. + Expr::Binary { op, left, right } => { + let c = is_bitwise_binop(*op); + observe(left, c, cands, types, disq); + observe(right, c, cands, types, disq); + } + // `~x` coerces its operand via `ToInt32`; `-x`/`+x`/`!x` do NOT make an + // `undefined`-vs-integer distinction disappear. + Expr::Unary { op, operand } => { + observe(operand, matches!(op, UnaryOp::BitNot), cands, types, disq); + } + // Store into a typed-array element: the value is coerced (`ToInt32` / + // `ToUint8` / …) iff the receiver is an int-kind typed array. The index + // is NOT a coercing position (`S[l]` with `l == undefined` differs from + // `S[0]`). + Expr::PutValueSet { + target, + key, + value, + receiver, + .. + } => { + observe(target, false, cands, types, disq); + observe(key, false, cands, types, disq); + observe(receiver, false, cands, types, disq); + let store_coercing = + receiver_is_int_kind_ta(receiver, types) || receiver_is_int_kind_ta(target, types); + observe(value, store_coercing, cands, types, disq); + } + Expr::IndexSet { + object, + index, + value, + } => { + observe(object, false, cands, types, disq); + observe(index, false, cands, types, disq); + observe( + value, + receiver_is_int_kind_ta(object, types), + cands, + types, + disq, + ); + } + // Byte stores clamp/mask the value through `ToUint8` (`undefined` → 0), + // so the stored value position is coercing. + Expr::Uint8ArraySet { + array, + index, + value, + } => { + observe(array, false, cands, types, disq); + observe(index, false, cands, types, disq); + observe(value, true, cands, types, disq); + } + Expr::BufferIndexSet { + buffer, + index, + value, + } => { + observe(buffer, false, cands, types, disq); + observe(index, false, cands, types, disq); + observe(value, true, cands, types, disq); + } + // Assignment rhs: a bare `LocalGet(cand)` here is a copy (not modeled), + // so it is non-coercing. Nested bitwise sub-expressions re-establish + // coercing-ness for their own operands. + Expr::LocalSet(_, value) => { + observe(value, false, cands, types, disq); + } + // Closure-touched candidates are already excluded; do not descend. + Expr::Closure { .. } => {} + // Every other position is non-coercing: recurse with `coercing = false` + // so any candidate read there disqualifies it. + _ => { + perry_hir::walker::walk_expr_children(e, &mut |c| { + observe(c, false, cands, types, disq) + }); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/perry-codegen/src/collectors/int_valued_ta_locals/tests.rs b/crates/perry-codegen/src/collectors/int_valued_ta_locals/tests.rs new file mode 100644 index 0000000000..dc2b740dfc --- /dev/null +++ b/crates/perry-codegen/src/collectors/int_valued_ta_locals/tests.rs @@ -0,0 +1,242 @@ +use super::*; +use perry_hir::{BinaryOp, Expr, Param, Stmt, UpdateOp}; + +fn let_stmt(id: u32, ty: HirType, init: Option) -> Stmt { + Stmt::Let { + id, + name: format!("v{id}"), + ty, + mutable: true, + init, + } +} + +fn set(id: u32, rhs: Expr) -> Stmt { + Stmt::Expr(Expr::LocalSet(id, Box::new(rhs))) +} + +fn bin(op: BinaryOp, l: Expr, r: Expr) -> Expr { + Expr::Binary { + op, + left: Box::new(l), + right: Box::new(r), + } +} + +fn xor(l: Expr, r: Expr) -> Expr { + bin(BinaryOp::BitXor, l, r) +} + +/// `S[idx]` — an element read on local `arr_id`. +fn idx_get(arr_id: u32, idx: Expr) -> Expr { + Expr::IndexGet { + object: Box::new(Expr::LocalGet(arr_id)), + index: Box::new(idx), + } +} + +/// Params: `arr: Int32Array` (id 0), `off: number` (id 1) unless overridden. +fn int32_array_param(id: u32) -> Param { + Param { + id, + name: format!("p{id}"), + ty: HirType::Named("Int32Array".to_string()), + default: None, + decorators: vec![], + is_rest: false, + arguments_object: None, + } +} + +fn number_param(id: u32) -> Param { + Param { + id, + name: format!("p{id}"), + ty: HirType::Number, + default: None, + decorators: vec![], + is_rest: false, + arguments_object: None, + } +} + +fn run(stmts: &[Stmt], params: &[Param]) -> HashSet { + collect_int_valued_ta_locals(stmts, params, &HashMap::new()) +} + +#[test] +fn bcrypt_feistel_accumulator_is_eligible() { + // arr:Int32Array (0), off:number (1) + // let l = arr[off]; // int-TA read init (SEED, possibly OOB) + // l = l ^ 0x12345678; // bitwise + // arr[off] = l; // int-TA store value (coercing) + let params = [int32_array_param(0), number_param(1)]; + let stmts = vec![ + let_stmt(9, HirType::Any, Some(idx_get(0, Expr::LocalGet(1)))), + set(9, xor(Expr::LocalGet(9), Expr::Integer(0x1234_5678))), + Stmt::Expr(Expr::PutValueSet { + target: Box::new(Expr::LocalGet(0)), + key: Box::new(Expr::LocalGet(1)), + value: Box::new(Expr::LocalGet(9)), + receiver: Box::new(Expr::LocalGet(0)), + strict: false, + }), + ]; + let got = run(&stmts, ¶ms); + assert!(got.contains(&9), "feistel accumulator missing: {got:?}"); +} + +#[test] +fn additive_write_excludes_local() { + // `n = arr[i]; n = n + arr[j];` — the additive write can overflow i32, so + // `n` must NOT be admitted (mirrors `_encipher`'s `n`). + let params = [int32_array_param(0), number_param(1)]; + let stmts = vec![ + let_stmt(8, HirType::Number, Some(Expr::Integer(0))), + set(8, idx_get(0, Expr::LocalGet(1))), + set( + 8, + bin( + BinaryOp::Add, + Expr::LocalGet(8), + idx_get(0, Expr::LocalGet(1)), + ), + ), + ]; + let got = run(&stmts, ¶ms); + assert!( + !got.contains(&8), + "additive local wrongly admitted: {got:?}" + ); +} + +#[test] +fn console_log_style_observation_excludes_local() { + // `let x = arr[off]; f(x);` — `x` observed as a call argument (where + // undefined-vs-integer is distinguishable) must be excluded. + let params = [int32_array_param(0), number_param(1)]; + let stmts = vec![ + let_stmt(5, HirType::Any, Some(idx_get(0, Expr::LocalGet(1)))), + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::FuncRef(42)), + args: vec![Expr::LocalGet(5)], + type_args: vec![], + byte_offset: 0, + }), + ]; + let got = run(&stmts, ¶ms); + assert!( + !got.contains(&5), + "call-arg-observed local wrongly admitted: {got:?}" + ); +} + +#[test] +fn bare_index_observation_excludes_local() { + // `let x = arr[off]; let y = arr[x];` — `x` used as a *bare* array index is + // NOT a ToInt32-coercing context (`arr[undefined]` != `arr[0]`), so exclude. + let params = [int32_array_param(0), number_param(1)]; + let stmts = vec![ + let_stmt(5, HirType::Any, Some(idx_get(0, Expr::LocalGet(1)))), + let_stmt(6, HirType::Any, Some(idx_get(0, Expr::LocalGet(5)))), + ]; + let got = run(&stmts, ¶ms); + assert!( + !got.contains(&5), + "bare-index-observed local wrongly admitted: {got:?}" + ); +} + +#[test] +fn return_observation_excludes_local() { + // `let x = arr[off]; return x;` — a bare return observes undefined-vs-int. + let params = [int32_array_param(0), number_param(1)]; + let stmts = vec![ + let_stmt(5, HirType::Any, Some(idx_get(0, Expr::LocalGet(1)))), + Stmt::Return(Some(Expr::LocalGet(5))), + ]; + let got = run(&stmts, ¶ms); + assert!( + !got.contains(&5), + "returned local wrongly admitted: {got:?}" + ); +} + +#[test] +fn returned_bitwise_result_keeps_local_eligible() { + // `let x = arr[off]; x = x ^ 7; return (x & 0xff);` — `x` itself is only + // read in bitwise ops; the *result* of a bitwise op (always defined) is + // returned, so `x` stays eligible. + let params = [int32_array_param(0), number_param(1)]; + let stmts = vec![ + let_stmt(5, HirType::Any, Some(idx_get(0, Expr::LocalGet(1)))), + set(5, xor(Expr::LocalGet(5), Expr::Integer(7))), + Stmt::Return(Some(bin( + BinaryOp::BitAnd, + Expr::LocalGet(5), + Expr::Integer(0xff), + ))), + ]; + let got = run(&stmts, ¶ms); + assert!( + got.contains(&5), + "bitwise-only local wrongly excluded: {got:?}" + ); +} + +#[test] +fn plain_array_store_value_excludes_local() { + // Storing into a plain (non-typed) array does NOT coerce to ToInt32, so a + // candidate used as such a store value is excluded. Here `arr2` is an + // untyped local (no declared int-TA type), so `arr2[i] = x` is non-coercing. + let params = [int32_array_param(0), number_param(1)]; + let stmts = vec![ + // arr2: untyped local (id 7), holds some object/array + let_stmt(7, HirType::Any, Some(Expr::Integer(0))), + let_stmt(5, HirType::Any, Some(idx_get(0, Expr::LocalGet(1)))), + Stmt::Expr(Expr::IndexSet { + object: Box::new(Expr::LocalGet(7)), + index: Box::new(Expr::LocalGet(1)), + value: Box::new(Expr::LocalGet(5)), + }), + ]; + let got = run(&stmts, ¶ms); + assert!( + !got.contains(&5), + "plain-array store value wrongly admitted: {got:?}" + ); +} + +#[test] +fn update_target_excluded() { + // `let x = arr[off]; x++;` — `++` is not modeled as a safe write. + let params = [int32_array_param(0), number_param(1)]; + let stmts = vec![ + let_stmt(5, HirType::Any, Some(idx_get(0, Expr::LocalGet(1)))), + Stmt::Expr(Expr::Update { + id: 5, + op: UpdateOp::Increment, + prefix: false, + }), + ]; + let got = run(&stmts, ¶ms); + assert!(!got.contains(&5), "++ target wrongly admitted: {got:?}"); +} + +#[test] +fn non_int_kind_typed_array_read_not_seeded() { + // Float64Array element reads are not int-kind; a local seeded only from one + // is not a candidate (no int-TA-read write → never seeded). + let mut binding_types = HashMap::new(); + binding_types.insert(0u32, HirType::Named("Float64Array".to_string())); + let params = [number_param(1)]; + let stmts = vec![ + let_stmt(5, HirType::Any, Some(idx_get(0, Expr::LocalGet(1)))), + set(5, xor(Expr::LocalGet(5), Expr::Integer(3))), + ]; + let got = collect_int_valued_ta_locals(&stmts, ¶ms, &binding_types); + assert!( + !got.contains(&5), + "float64 read wrongly seeded a candidate: {got:?}" + ); +} diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index 05709597bf..0cadb86003 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -17,6 +17,7 @@ mod hot_callees; mod i32_locals; mod i64_emit; mod index_uses; +mod int_valued_ta_locals; mod integer_locals; mod local_refs; mod mutation; diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 4033eeea49..698e14ed4f 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -238,6 +238,21 @@ pub(crate) fn lower_let( // `let x: Object = ...` deliberately. let refined_ty = if matches!(ty, perry_hir::types::Type::Any) { init.and_then(|e| crate::type_analysis::refine_type_from_init(ctx, e)) + // A local proven integer-valued (a loop counter, or an + // `int_valued_ta` Feistel accumulator whose init `lr[off]` the + // structural refiner can't type) is still definitely a clean + // Number — never a heap pointer. When the structural refiner can't + // pin it down, fall back to Number so the numeric Let/LocalSet + // lowering (i32 shadow slot, no conservative Any boxing, no GC + // shadow-slot pointer tracking) fires — matching a source `| 0`. + // Without this the accumulator stays `Any`, its f64 mirror is kept + // live across the loop, and `-O3` cannot collapse the residual + // `sitofp`/`fptosi` round-trips. + .or_else(|| { + ctx.integer_locals + .contains(&id) + .then_some(perry_hir::types::Type::Number) + }) .unwrap_or_else(|| ty.clone()) } else if matches!(ty, perry_hir::types::Type::Array(ref elem) if matches!(**elem, perry_hir::types::Type::Any)) { @@ -1501,8 +1516,23 @@ pub(crate) fn lower_let( // UB for such values; going through i64 then truncating gives // the correct bit pattern. if let Some(i32_slot) = ctx.i32_counter_slots.get(&id).cloned() { - let v_i64 = ctx.block().fptosi(DOUBLE, &v, crate::types::I64); - let v_i32 = ctx.block().trunc(crate::types::I64, &v_i64, I32); + // A possibly-non-finite init (`let l = lr[off]` — an int + // typed-array read that yields `undefined` = a NaN-boxed double + // on an out-of-bounds/fractional index) must seed the slot with + // spec ToInt32, which is `0` for NaN/±Infinity. A raw + // `fptosi(NaN)` is LLVM poison — 0 on aarch64 but a garbage + // sentinel on x86-64 — so it is NOT portable. `int_valued_ta` + // locals (and any other i32-shadow local with a non-known-finite + // init) are only ever observed through ToInt32, so seeding with + // the exact ToInt32 keeps every arm identical. Known-finite + // inits keep the cheaper `fptosi→i64→trunc` (bit-identical for + // finite values), so existing i32-shadow locals are unchanged. + let v_i32 = if crate::expr::is_known_finite(ctx, init_expr) { + let v_i64 = ctx.block().fptosi(DOUBLE, &v, crate::types::I64); + ctx.block().trunc(crate::types::I64, &v_i64, I32) + } else { + ctx.block().toint32_wrap(&v) + }; ctx.block().store(I32, &v_i32, &i32_slot); } } diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 185854f8c4..fd67f45f63 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -948,6 +948,16 @@ fn compute_object_cache_key_with_env( "env_ta_param_f64_read", env_var("PERRY_TA_PARAM_F64_READ").as_deref().unwrap_or(""), ); + // Native-i32 residency for integer-valued locals seeded by possibly-OOB int + // typed-array reads (bcryptjs `_encipher` Feistel accumulators): `=0`/`off`/ + // `false` reverts `l`/`r`-shaped locals from an i32 shadow slot back to the + // f64 slot + per-access ToInt32 round-trip, which changes the emitted IR / + // .o bytes — a warm cache must not serve an object built under the other + // setting. + h.field( + "env_int_valued_locals", + env_var("PERRY_INT_VALUED_LOCALS").as_deref().unwrap_or(""), + ); h.finish() } diff --git a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs index fd01f108ec..8a08cb1153 100644 --- a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs +++ b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs @@ -613,6 +613,8 @@ fn key_changes_with_codegen_env_vars() { "PERRY_INLINE_NONBIGINT_BITWISE", // Inline checked-f64 typed-array-param read. "PERRY_TA_PARAM_F64_READ", + // Native-i32 residency for int-typed-array-seeded locals. + "PERRY_INT_VALUED_LOCALS", ] { // Sample state without the var, with the var, and with a different // value — all three keys must be distinct. diff --git a/test-files/test_gap_int_valued_ta_locals.ts b/test-files/test_gap_int_valued_ta_locals.ts new file mode 100644 index 0000000000..a1c2ce95b2 --- /dev/null +++ b/test-files/test_gap_int_valued_ta_locals.ts @@ -0,0 +1,90 @@ +// Native-i32 residency for integer-valued locals seeded by a possibly-OOB INT +// typed-array element read (the bcryptjs `_encipher` Feistel-accumulator shape). +// `collectors/int_valued_ta_locals.rs` promotes a `let l = lr[off]` local to a +// native i32 shadow slot ONLY when every write is i32-producing and every +// observation is ToInt32-coercing; the OOB `undefined` then reads as +// `ToInt32(undefined) == 0`, indistinguishable from the f64 path. This test +// pins the SOUNDNESS boundary: an eligible accumulator whose OOB init flows +// only through bitwise ops must stay byte-exact, while an INELIGIBLE sibling +// whose read is observed as `undefined` / `String()` / `console.log` must still +// see `undefined`. Every line must match `node --experimental-strip-types` +// with PERRY_INT_VALUED_LOCALS on AND off, and under PERRY_GC_FORCE_EVACUATE=1. + +const S = new Int32Array(256); +for (let i = 0; i < 256; i++) S[i] = ((i * 2654435761) ^ (i << 13) ^ 0x9e3779b9) | 0; +const P = new Int32Array(4); +for (let i = 0; i < 4; i++) P[i] = ((i * 2654435761) ^ (i << 20)) | 0; + +// ---- ELIGIBLE: l/r init from an int typed-array read (index UNBOUNDED, may be +// OOB), only ever bitwise-updated, only ever read in bitwise ops / as a +// bitwise-derived index / returned as a bitwise result. Promoted to i32. ---- +function mix(lr: Int32Array, off: number): number { + let l = lr[off], r = lr[off + 1]; // OOB/negative -> undefined -> ToInt32 -> 0 + l ^= P[0]; + r ^= P[1]; + for (let round = 0; round < 8; round++) { + l = (l ^ S[(l >>> 3) & 0xff] ^ r) | 0; + r = (r ^ S[(r >>> 5) & 0xff] ^ l) | 0; + } + return (l ^ r) | 0; // bitwise result (always defined) is what is observed +} + +// ---- ELIGIBLE with a typed-array element STORE observation (`lr[k] = l`): the +// stored value is ToInt32-coerced, so it is a coercing observation too. ---- +function mixStore(lr: Int32Array, off: number): void { + let l = lr[off], r = lr[off + 1]; + l ^= P[0]; + r ^= P[1]; + for (let round = 0; round < 4; round++) { + l = (l ^ S[(l >>> 3) & 0xff] ^ r) | 0; + r = (r ^ S[(r >>> 5) & 0xff] ^ l) | 0; + } + lr[off] = r; // OOB store is a no-op; in-bounds store is ToInt32(l/r) + lr[off + 1] = l; +} + +const lr = new Int32Array(2); +lr[0] = 0x1234abcd | 0; +lr[1] = 0x7654321f | 0; + +console.log("mix-inbounds", mix(lr, 0)); +console.log("mix-oob", mix(lr, 8)); // lr[8]/lr[9] undefined -> both accumulators start 0 +console.log("mix-neg", mix(lr, -4)); // negative index -> undefined -> 0 +console.log("mix-frac", mix(lr, 0.5)); // fractional index -> undefined -> 0 + +const st = new Int32Array(4); +st[0] = 0x0f0f0f0f | 0; +st[1] = 0x12345678 | 0; +mixStore(st, 0); +console.log("store-inbounds", st[0] | 0, st[1] | 0); +mixStore(st, 8); // OOB: reads undefined (->0), stores are no-ops +console.log("store-oob-unchanged", st[0] | 0, st[1] | 0, st[2] | 0, st[3] | 0); + +// ---- INELIGIBLE: the read result is observed where `undefined` is +// distinguishable from an integer, so the analysis must NOT promote it and the +// OOB read must remain observable as `undefined`. If it were wrongly promoted, +// these would print `0`/`"0"`/`false`. ---- +function eqUndef(a: Int32Array, i: number): boolean { + const x = a[i]; // possibly OOB + return x === undefined; +} +function strOf(a: Int32Array, i: number): string { + const x = a[i]; + return String(x); +} +function logOf(a: Int32Array, i: number): void { + const x = a[i]; + console.log("probe-log", x); +} +// Mixed local: read into `x`, THEN observed both ways (===undefined and String). +function mixedObs(a: Int32Array, i: number): string { + const x = a[i]; + if (x === undefined) return "undef"; + return "num:" + String(x); +} + +console.log("eq-undef", eqUndef(S, 300), eqUndef(S, -1), eqUndef(S, 3.9), eqUndef(S, 5)); +console.log("str-of", strOf(S, 300), strOf(S, -1), strOf(S, 5)); +logOf(S, 300); +logOf(S, 5); +console.log("mixed", mixedObs(S, 300), mixedObs(S, 5));