From cb384922e345906ee2b81418aa44abd93d029ae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 22 Jul 2026 06:43:03 +0200 Subject: [PATCH 1/3] perf(codegen): eliminate per-access array-read guard calls in hot masked-index loops Array element reads in tight loops compiled to one runtime guard call per access (js_typed_feedback_numeric_array_index_get_guard and the typed-array getters), making integer/array-heavy JS 10-15x slower than V8 even though the element load itself was already inline. bcryptjs's Blowfish S-boxes are exactly this shape, turning a login's compareSync into seconds of overhead. Three complementary fixes: 1. Static index windows for the buffer bounds prover (expr/range_facts.rs): int_range_expr now understands 'e & K' (always [0, K] for a non-negative i32 constant mask, by ToInt32 semantics), 'e >>> k' (ToUint32 result bounded by the shift), and non-negative 'a | b' windows. Width-tracked typed-array reads like S[i & 1023] / S[256 + ((x >>> 16) & 0xff)] now prove bounds against the tracked view length and take the existing unchecked inline load instead of an out-of-line getter call. An IndexGet range rule (elem-kind value range, gated on the same bounds proof) feeds the same machinery. The i32 fast path classifies proven in-bounds loads from int-element typed arrays as native i32 leaves, so bit-mixing chains stay in add/xor i32 instead of round-tripping f64 through the branchless ToInt32 tower. 2. Collector support so const typed-array lookup tables behave like ints (collectors/): const S = new Int32Array() bindings that never escape element-access receiver position are recorded with their length; 'const a = S[x & 0xff]'-style Lets seed integer_locals (window proven inside [0, length)), and the pointer analysis knows numerically-indexed typed-array reads never yield pointers - killing the per-iteration GC shadow-slot spills and dynamic string_or_number_add dispatch the Blowfish round function was paying for. 3. Read-only DENSE tier for the #6011 packed-f64 range loop (stmt/loops.rs, typed_feedback.rs): the versioned-loop matcher now also accepts multi-statement read-only bodies whose array reads carry static masked windows, guarded once at loop entry by a dense window guard (window must be hole-free - loads then need no hole check and no side exit, which is what makes multi-statement bodies safe to version). Two guard tiers: _dense_i32 additionally proves every window value is an i32 integer so loads materialize as a bare exact fptosi and LLVM keeps (s + S[i & k])|0 loops fully in integer registers; _dense keeps raw-double loads for float lookup tables. Read eligibility no longer consults the array-kind / materialization-hazard facts: mark_unknown_call_escape blanket-hazards every function-local array whenever the function contains any call, which kept every locally built lookup table off the fast path forever; the entry guard re-validates the actual runtime array, so a wrong static hint costs one failed guard, never correctness. Benchmarks (Apple M1-class, 20M reads / 5M Blowfish-F rounds, byte-identical outputs vs node --experimental-strip-types): node perry before perry after arith-only (20M) 30 ms 26 ms 26 ms plain-Array read 21 ms 200 ms 4 ms Int32Array read 16 ms 235 ms 10 ms Blowfish-F mimic 18 ms 558 ms 15 ms An edge-case suite (OOB windows, holes, fractional/huge/NaN elements, frozen arrays, string elements, shrunk length, mixed counter+masked indices, all int TA kinds, escape shapes) is byte-identical between this branch and origin/main; the four pre-existing divergences from node it found are unrelated to this change and reproduce on main. --- crates/perry-codegen/src/codegen/closure.rs | 1 + crates/perry-codegen/src/codegen/entry.rs | 2 + crates/perry-codegen/src/codegen/function.rs | 1 + crates/perry-codegen/src/codegen/method.rs | 2 + .../src/collectors/integer_locals.rs | 340 +++++++++++ crates/perry-codegen/src/collectors/mod.rs | 4 +- .../src/collectors/pointer_locals.rs | 73 ++- .../perry-codegen/src/expr/i32_fast_path.rs | 71 +++ crates/perry-codegen/src/expr/index_get.rs | 184 +++++- crates/perry-codegen/src/expr/mod.rs | 28 + crates/perry-codegen/src/expr/range_facts.rs | 155 +++++ .../src/runtime_decls/objects.rs | 12 + crates/perry-codegen/src/stmt/loops.rs | 575 ++++++++++++++---- crates/perry-runtime/src/array/header.rs | 61 ++ crates/perry-runtime/src/array/mod.rs | 1 + crates/perry-runtime/src/typed_feedback.rs | 131 ++++ 16 files changed, 1534 insertions(+), 107 deletions(-) diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index d3aa399eef..111b5b62e4 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -840,6 +840,7 @@ pub(super) fn compile_closure( cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), + masked_window_array_facts: Vec::new(), class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), i1_local_slots: HashMap::new(), diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 22c6164c10..133f506045 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -753,6 +753,7 @@ pub(super) fn compile_module_entry( cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), + masked_window_array_facts: Vec::new(), class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), i1_local_slots: HashMap::new(), @@ -1351,6 +1352,7 @@ pub(super) fn compile_module_entry( cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), + masked_window_array_facts: Vec::new(), class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), i1_local_slots: HashMap::new(), diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index dea854d474..9b2fb8b773 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -548,6 +548,7 @@ pub(super) fn compile_function( cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), + masked_window_array_facts: Vec::new(), class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), i1_local_slots: HashMap::new(), diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index ef56e68a6c..9f2e51bfaf 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -466,6 +466,7 @@ pub(super) fn compile_method( cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), + masked_window_array_facts: Vec::new(), class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), i1_local_slots: HashMap::new(), @@ -1461,6 +1462,7 @@ pub(super) fn compile_static_method( cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), + masked_window_array_facts: Vec::new(), class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), i1_local_slots: HashMap::new(), diff --git a/crates/perry-codegen/src/collectors/integer_locals.rs b/crates/perry-codegen/src/collectors/integer_locals.rs index d17d90a4b5..32f7061c9a 100644 --- a/crates/perry-codegen/src/collectors/integer_locals.rs +++ b/crates/perry-codegen/src/collectors/integer_locals.rs @@ -47,6 +47,328 @@ use std::collections::{HashMap, HashSet}; use super::*; +/// Element-value-fits-signed-i32 `TYPED_ARRAY_KIND_*` tags (excludes +/// `Uint32Array`, whose upper half does not round-trip through an i32 slot, +/// and the float/BigInt kinds). +fn typed_array_kind_elem_fits_i32(kind: u8) -> bool { + use perry_hir::{ + TYPED_ARRAY_KIND_INT16, TYPED_ARRAY_KIND_INT32, TYPED_ARRAY_KIND_INT8, + TYPED_ARRAY_KIND_UINT16, TYPED_ARRAY_KIND_UINT8, TYPED_ARRAY_KIND_UINT8_CLAMPED, + }; + matches!( + kind, + TYPED_ARRAY_KIND_INT8 + | TYPED_ARRAY_KIND_UINT8 + | TYPED_ARRAY_KIND_UINT8_CLAMPED + | TYPED_ARRAY_KIND_INT16 + | TYPED_ARRAY_KIND_UINT16 + | TYPED_ARRAY_KIND_INT32 + ) +} + +/// Context-free value window of an index expression: `Some((lo, hi))` proves +/// the JS value is an integer in `[lo, hi]` for EVERY runtime environment. +/// Only shapes whose result is integral by construction qualify — literals and +/// `ToInt32`/`ToUint32`-wrapping bitwise ops (`e & K` is `[0, K]` for any `e`, +/// `e >>> k` is bounded by the shift) — plus `+`/`-` compositions of such +/// windows. This is the syntactic sibling of the ctx-aware +/// `int_range_expr` rules in `expr/range_facts.rs`; collectors run before any +/// `FnCtx` exists, so they cannot consult local range facts. +pub(crate) fn static_index_window(e: &perry_hir::Expr) -> Option<(i64, i64)> { + use perry_hir::{BinaryOp, Expr}; + fn int_constant(e: &Expr) -> Option { + match e { + Expr::Integer(n) => Some(*n), + Expr::Number(n) if n.is_finite() && n.fract() == 0.0 && n.abs() < 2f64.powi(53) => { + Some(*n as i64) + } + _ => None, + } + } + fn ones_cover(value: i64) -> i64 { + if value == 0 { + 0 + } else { + ((1u64 << (64 - (value as u64).leading_zeros())) - 1) as i64 + } + } + if let Some(n) = int_constant(e) { + return Some((n, n)); + } + let Expr::Binary { op, left, right } = e else { + return None; + }; + match op { + BinaryOp::BitAnd => { + let mask = int_constant(left) + .or_else(|| int_constant(right)) + .filter(|mask| (0..=i64::from(i32::MAX)).contains(mask))?; + Some((0, mask)) + } + BinaryOp::UShr => { + let max = match int_constant(right).map(|k| (k as u64) & 31) { + Some(k) if k > 0 => (1i64 << (32 - k)) - 1, + _ => i64::from(u32::MAX), + }; + Some((0, max)) + } + BinaryOp::Add => { + let (ll, lh) = static_index_window(left)?; + let (rl, rh) = static_index_window(right)?; + Some((ll.checked_add(rl)?, lh.checked_add(rh)?)) + } + BinaryOp::Sub => { + let (ll, lh) = static_index_window(left)?; + let (rl, rh) = static_index_window(right)?; + Some((ll.checked_sub(rh)?, lh.checked_sub(rl)?)) + } + BinaryOp::BitOr => { + let (ll, lh) = static_index_window(left)?; + let (rl, rh) = static_index_window(right)?; + if ll >= 0 && rl >= 0 && lh <= i64::from(i32::MAX) && rh <= i64::from(i32::MAX) { + Some((ll.max(rl), ones_cover(lh) | ones_cover(rh))) + } else { + None + } + } + BinaryOp::Shr => { + let shift = int_constant(right).map(|k| (k as u64) & 31)?; + if shift == 0 { + return None; + } + Some((i64::from(i32::MIN) >> shift, i64::from(i32::MAX) >> shift)) + } + _ => None, + } +} + +/// `const S = new Int32Array()`-style bindings whose element +/// reads are provably integers: the binding is a `const` (never reassigned), +/// the element kind fits a signed i32, the length is a compile-time literal, +/// and the binding is only ever used as an element-access receiver (`S[...]` +/// reads and writes) — so nothing can alias it, detach its buffer, or swap +/// the value behind it. Returns `id → length`. +fn collect_const_int_ta_views(stmts: &[perry_hir::Stmt]) -> HashMap { + use perry_hir::{Expr, Stmt}; + let mut views: HashMap = HashMap::new(); + fn seed_stmt(stmt: &Stmt, views: &mut HashMap) { + if let Stmt::Let { + id, + mutable: false, + init: + Some(Expr::TypedArrayNew { + kind, + arg: Some(arg), + }), + .. + } = stmt + { + let len = match arg.as_ref() { + Expr::Integer(n) => Some(*n), + Expr::Number(n) if n.is_finite() && n.fract() == 0.0 => Some(*n as i64), + _ => None, + }; + if let Some(len) = len { + if typed_array_kind_elem_fits_i32(*kind) && (0..=16_000_000).contains(&len) { + views.insert(*id, len); + } + } + } + for_each_child_stmt(stmt, &mut |child| seed_stmt(child, views)); + } + for stmt in stmts { + seed_stmt(stmt, &mut views); + } + if views.is_empty() { + return views; + } + for stmt in stmts { + scan_ta_view_escapes_stmt(stmt, &mut views); + } + views +} + +/// Invoke `f` on every statement nested directly inside `stmt` (branch +/// bodies, loop bodies, catch/finally, switch cases, labels). +fn for_each_child_stmt(stmt: &perry_hir::Stmt, f: &mut dyn FnMut(&perry_hir::Stmt)) { + use perry_hir::Stmt; + match stmt { + Stmt::If { + then_branch, + else_branch, + .. + } => { + then_branch.iter().for_each(&mut *f); + if let Some(eb) = else_branch { + eb.iter().for_each(&mut *f); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => body.iter().for_each(&mut *f), + Stmt::For { init, body, .. } => { + if let Some(init) = init { + f(init); + } + body.iter().for_each(&mut *f); + } + Stmt::Try { + body, + catch, + finally, + } => { + body.iter().for_each(&mut *f); + if let Some(c) = catch { + c.body.iter().for_each(&mut *f); + } + if let Some(fin) = finally { + fin.iter().for_each(&mut *f); + } + } + Stmt::Switch { cases, .. } => { + for case in cases { + case.body.iter().for_each(&mut *f); + } + } + Stmt::Labeled { body, .. } => f(body.as_ref()), + _ => {} + } +} + +/// Remove from `views` every binding used anywhere other than as the direct +/// receiver of an element access. A bare `LocalGet` in any other position +/// (call argument, property access, capture, assignment source, …) can alias +/// or detach the array, so the window proof no longer holds. +fn scan_ta_view_escapes_stmt(stmt: &perry_hir::Stmt, views: &mut HashMap) { + use perry_hir::Stmt; + if let Stmt::Let { init: Some(e), .. } = stmt { + scan_ta_view_escapes_expr(e, views); + } + match stmt { + Stmt::Expr(e) | Stmt::Throw(e) | Stmt::Return(Some(e)) => { + scan_ta_view_escapes_expr(e, views); + } + Stmt::If { condition, .. } => scan_ta_view_escapes_expr(condition, views), + Stmt::While { condition, .. } | Stmt::DoWhile { condition, .. } => { + scan_ta_view_escapes_expr(condition, views); + } + Stmt::For { + condition, update, .. + } => { + if let Some(c) = condition { + scan_ta_view_escapes_expr(c, views); + } + if let Some(u) = update { + scan_ta_view_escapes_expr(u, views); + } + } + Stmt::Switch { discriminant, .. } => scan_ta_view_escapes_expr(discriminant, views), + _ => {} + } + for_each_child_stmt(stmt, &mut |child| scan_ta_view_escapes_stmt(child, views)); +} + +fn scan_ta_view_escapes_expr(e: &perry_hir::Expr, views: &mut HashMap) { + use perry_hir::Expr; + match e { + // Receiver position of an element access is the one allowed use. + Expr::IndexGet { object, index } => { + if !matches!(object.as_ref(), Expr::LocalGet(_)) { + scan_ta_view_escapes_expr(object, views); + } + scan_ta_view_escapes_expr(index, views); + } + Expr::IndexSet { + object, + index, + value, + } => { + if !matches!(object.as_ref(), Expr::LocalGet(_)) { + scan_ta_view_escapes_expr(object, views); + } + scan_ta_view_escapes_expr(index, views); + scan_ta_view_escapes_expr(value, views); + } + // `S[k] = v` lowers as PutValueSet with `target` and `receiver` both + // the receiver local — receiver-position uses, like IndexSet's object. + Expr::PutValueSet { + target, + key, + value, + receiver, + .. + } => { + if !matches!(target.as_ref(), Expr::LocalGet(_)) { + scan_ta_view_escapes_expr(target, views); + } + if !matches!(receiver.as_ref(), Expr::LocalGet(_)) { + scan_ta_view_escapes_expr(receiver, views); + } + scan_ta_view_escapes_expr(key, views); + scan_ta_view_escapes_expr(value, views); + } + Expr::LocalGet(id) => { + views.remove(id); + } + Expr::LocalSet(id, value) => { + views.remove(id); + scan_ta_view_escapes_expr(value, views); + } + Expr::Closure { body, .. } => { + for stmt in body { + scan_ta_view_escapes_stmt(stmt, views); + } + perry_hir::walker::walk_expr_children(e, &mut |child| { + scan_ta_view_escapes_expr(child, views); + }); + } + _ => { + perry_hir::walker::walk_expr_children(e, &mut |child| { + scan_ta_view_escapes_expr(child, views); + }); + } + } +} + +/// `S[idx]` where `S` is a tracked const int-typed-array view and `idx`'s +/// static window is inside `[0, length)` — an integer by construction. +fn is_proven_int_ta_load(views: &HashMap, e: &perry_hir::Expr) -> bool { + use perry_hir::Expr; + let Expr::IndexGet { object, index } = e else { + return false; + }; + let Expr::LocalGet(id) = object.as_ref() else { + return false; + }; + let Some(&len) = views.get(id) else { + return false; + }; + static_index_window(index).is_some_and(|(lo, hi)| lo >= 0 && hi < len) +} + +/// Walk all Lets and seed ids whose init is a proven in-window int-typed-array +/// element load (`const a = S[x & 0xff]` — the bcryptjs Blowfish shape). +fn collect_int_ta_load_let_ids( + stmts: &[perry_hir::Stmt], + views: &HashMap, + out: &mut HashSet, +) { + use perry_hir::Stmt; + for stmt in stmts { + if let Stmt::Let { + id, + init: Some(init), + .. + } = stmt + { + if is_proven_int_ta_load(views, init) { + out.insert(*id); + } + } + for_each_child_stmt(stmt, &mut |child| { + collect_int_ta_load_let_ids(std::slice::from_ref(child), views, out); + }); + } +} + pub fn collect_integer_locals( stmts: &[perry_hir::Stmt], flat_const_ids: &HashSet, @@ -70,6 +392,13 @@ pub fn collect_integer_locals( clamp_fn_ids, ); + // `const a = S[x & 0xff]` where `S` is a const int-typed-array view and + // the index window is statically inside the array: the load is an integer + // by construction, so seed it like any other int-producing Let. The + // provenance judge exempts these inits below for the same reason. + let int_ta_views = collect_const_int_ta_views(stmts); + collect_int_ta_load_let_ids(stmts, &int_ta_views, &mut candidates); + // Forward closure pass: extend the seed set with Lets whose init is // `is_int32_producing_expr` against the current candidate set. // The initial `collect_integer_let_ids` only seeds on syntactic @@ -118,6 +447,7 @@ pub fn collect_integer_locals( flat_row_alias_ids: &flat_row_alias_ids, clamp_fn_ids, arg_dependent_clamp_fn_ids, + int_ta_views: &int_ta_views, dependents: HashMap::new(), disqualified: HashSet::new(), closure_written: HashSet::new(), @@ -166,6 +496,10 @@ struct ProvenanceJudge<'a> { flat_row_alias_ids: &'a HashSet, clamp_fn_ids: &'a HashSet, arg_dependent_clamp_fn_ids: &'a HashSet, + /// Const int-typed-array views (`id → length`) whose in-window element + /// loads are integers by construction — obligations whose rhs is such a + /// load pass without deps. + int_ta_views: &'a HashMap, /// dep local id → candidate ids whose integer verdict relied on it. dependents: HashMap>, /// Candidates with at least one failed obligation. @@ -176,6 +510,12 @@ struct ProvenanceJudge<'a> { impl ProvenanceJudge<'_> { fn judge_obligation(&mut self, id: u32, rhs: &Expr) { + // A proven in-window int-typed-array load is an integer regardless of + // any candidate's status — pass with no deps (the view binding is a + // never-escaping const, so nothing can invalidate it). + if is_proven_int_ta_load(self.int_ta_views, rhs) { + return; + } let mut deps: HashSet = HashSet::new(); if int32_producing_deps( rhs, diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index 29c2107c7e..b6618a15ea 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -43,7 +43,9 @@ pub(crate) use escape_check::{check_escapes_in_stmts, find_new_candidates}; pub(crate) use escape_news::MAX_SCALAR_ARRAY_LEN; pub(crate) use hir_facts::{collect_native_region_fact_graph, NativeRegionFactGraph}; pub(crate) use i32_locals::{collect_integer_let_ids, collect_localset_ids_in_stmts, is_ushr_zero}; -pub(crate) use integer_locals::{collect_flat_row_aliases, is_int32_producing_expr}; +pub(crate) use integer_locals::{ + collect_flat_row_aliases, is_int32_producing_expr, static_index_window, +}; pub(crate) use local_refs::{expr_contains_local_get, mark_all_candidate_refs_in_expr}; pub(crate) use mutation::has_any_mutation; pub(crate) use pointer_locals::collect_pointer_typed_locals; diff --git a/crates/perry-codegen/src/collectors/pointer_locals.rs b/crates/perry-codegen/src/collectors/pointer_locals.rs index 5e8de62984..41f26e2578 100644 --- a/crates/perry-codegen/src/collectors/pointer_locals.rs +++ b/crates/perry-codegen/src/collectors/pointer_locals.rs @@ -32,6 +32,63 @@ thread_local! { const MAX_POINTER_ANALYSIS_TYPE_DEPTH: usize = 4; +/// Class name for a `TYPED_ARRAY_KIND_*` tag (reverse of +/// `perry_hir::typed_array_kind_for_name`). +fn typed_array_class_name_for_kind(kind: u8) -> Option<&'static str> { + const NAMES: &[&str] = &[ + "Int8Array", + "Uint8Array", + "Int16Array", + "Uint16Array", + "Int32Array", + "Uint32Array", + "Float32Array", + "Float64Array", + "Uint8ClampedArray", + "BigInt64Array", + "BigUint64Array", + "Float16Array", + ]; + let name = NAMES.get(kind as usize)?; + debug_assert_eq!(perry_hir::typed_array_kind_for_name(name), Some(kind)); + Some(name) +} + +/// Typed-array classes whose elements are plain Numbers (excludes the BigInt +/// kinds, whose elements are heap-allocated BigInt pointers). +fn typed_array_elem_is_number(name: &str) -> bool { + perry_hir::typed_array_kind_for_name(name).is_some() + && !matches!(name, "BigInt64Array" | "BigUint64Array") +} + +/// Index shapes that are definitely canonical numeric keys — integer/number +/// literals, bitwise ops (which `ToInt32`/`ToUint32` their operands), and +/// arithmetic over such shapes. A `LocalGet` index is NOT accepted: the local +/// could hold a string/symbol key that reaches a prototype method. +fn index_is_definitely_numeric(e: &Expr) -> bool { + match e { + Expr::Integer(_) | Expr::Number(_) | Expr::MathImul(_, _) => true, + Expr::Unary { op, .. } => matches!( + op, + perry_hir::UnaryOp::Neg | perry_hir::UnaryOp::Pos | perry_hir::UnaryOp::BitNot + ), + Expr::Binary { op, left, right } => match op { + BinaryOp::BitAnd + | BinaryOp::BitOr + | BinaryOp::BitXor + | BinaryOp::Shl + | BinaryOp::Shr + | BinaryOp::UShr => true, + BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod | BinaryOp::Pow => true, + // `+` may be string concatenation — both sides must be numeric. + BinaryOp::Add => { + index_is_definitely_numeric(left) && index_is_definitely_numeric(right) + } + }, + _ => false, + } +} + fn pointer_analysis_type(ty: &Type) -> Type { pointer_analysis_type_inner(ty, 0) } @@ -299,10 +356,21 @@ pub fn collect_pointer_typed_locals( } Some(pointer_analysis_array_type(elem_ty.unwrap_or(Type::Any))) } - Expr::IndexGet { object, .. } => { + Expr::IndexGet { object, index } => { match expr_value_type(object, local_types, local_value_types, non_pointer_locals)? { Type::Array(elem) => Some(*elem), Type::String => Some(Type::String), + // A numerically-keyed element read of a non-BigInt typed + // array yields a Number (or `undefined` when out of + // bounds) — never a pointer. String/symbol keys could + // reach `%TypedArray%.prototype` methods (pointers), so + // only definitely-numeric index shapes qualify. + Type::Named(name) + if typed_array_elem_is_number(&name) + && index_is_definitely_numeric(index) => + { + Some(Type::Union(vec![Type::Number, Type::Void])) + } _ => None, } } @@ -315,6 +383,9 @@ pub fn collect_pointer_typed_locals( | Expr::Uint8ArrayNew(_) | Expr::Uint8ArrayFrom(_) | Expr::TextEncoderEncode(_) => Some(Type::Named("Uint8Array".into())), + Expr::TypedArrayNew { kind, .. } => { + typed_array_class_name_for_kind(*kind).map(|name| Type::Named(name.into())) + } Expr::TextEncoderEncodeInto { .. } => Some(Type::Object(Default::default())), Expr::NativeMethodCall { module, diff --git a/crates/perry-codegen/src/expr/i32_fast_path.rs b/crates/perry-codegen/src/expr/i32_fast_path.rs index bc0b0074e8..3725e66a88 100644 --- a/crates/perry-codegen/src/expr/i32_fast_path.rs +++ b/crates/perry-codegen/src/expr/i32_fast_path.rs @@ -63,6 +63,16 @@ fn known_finite_magnitude_bits(ctx: &FnCtx<'_>, e: &Expr) -> Option { || ctx.unsigned_i32_locals.contains(id)) .then_some(32), Expr::Uint8ArrayGet { .. } | Expr::BufferIndexGet { .. } => Some(8), + // In-bounds loads from an int-element typed array are integers in + // i32 range by construction (see `ta_int_elem_load_is_i32_provable`), + // as are i32-tier masked-window plain-array loads (the dense-i32 + // range guard proved every window value is an i32 integer). + Expr::IndexGet { object, index } + if ta_int_elem_load_is_i32_provable(ctx, object, index) + || super::index_get::masked_window_i32_load_is_provable(ctx, object, index) => + { + Some(32) + } Expr::MathImul(_, _) => Some(32), // Math.imul returns i32 → always finite Expr::Call { callee, .. } => { matches!(callee.as_ref(), Expr::FuncRef(fid) if ctx.integer_returning_functions.contains(fid)) @@ -389,6 +399,55 @@ pub(crate) fn can_lower_expr_as_i32( } } +/// `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 +/// not the float kinds), with the index bounds proven against the tracked view +/// length. In-bounds loads of these kinds are integers by construction, so the +/// access is an i32-native leaf — this is what keeps bcrypt-style S-box chains +/// (`(s + S[x & 1023]) | 0`) in `add i32` instead of a per-element +/// f64 round-trip through the branchless ToInt32 tower. Out-of-bounds reads +/// (which produce `undefined`) are excluded by the same bounds proof the +/// unchecked native load itself requires. +fn ta_int_elem_load_is_i32_provable(ctx: &FnCtx<'_>, object: &Expr, index: &Expr) -> bool { + use crate::native_value::{BufferElem, BufferIndexUnit}; + if ctx.disable_buffer_fast_path { + return false; + } + let Expr::LocalGet(id) = object else { + return false; + }; + let Some(view) = ctx.buffer_view_slots.get(id) else { + return false; + }; + if view.index_unit != BufferIndexUnit::Element + || !view.alias.allows_noalias() + || view.scope_idx.is_none() + { + return false; + } + if !matches!( + view.elem, + BufferElem::I8 + | BufferElem::U8 + | BufferElem::U8Clamped + | BufferElem::I16 + | BufferElem::U16 + | BufferElem::I32 + ) { + return false; + } + if ctx.closure_captures.contains_key(id) + || matches!( + ctx.buffer_hazard_reasons.get(id), + Some(MaterializationReason::ClosureCapture) + ) + { + return false; + } + super::bounds_for_buffer_access_width(ctx, *id, index, 1).allows_inbounds() +} + fn packed_i32_loop_index_get_fact(ctx: &FnCtx<'_>, e: &Expr) -> Option { let Expr::IndexGet { object, index } = e else { return None; @@ -483,6 +542,10 @@ pub(crate) fn can_lower_expr_as_i32_in_current_region(ctx: &FnCtx<'_>, e: &Expr) .iter() .all(|arg| can_lower_expr_as_i32_in_current_region(ctx, arg)) } + Expr::IndexGet { object, index } => { + ta_int_elem_load_is_i32_provable(ctx, object, index) + || super::index_get::masked_window_i32_load_is_provable(ctx, object, index) + } _ => false, } } @@ -694,6 +757,14 @@ fn try_lower_expr_native_i32_structural(ctx: &mut FnCtx<'_>, e: &Expr) -> Result let lowered = super::arrays_finds::lower_buffer_index_get_i32(ctx, buffer, index)?; Some(i32_from_indexed_get_lowered(ctx, lowered)) } + Expr::IndexGet { object, index } => { + if ta_int_elem_load_is_i32_provable(ctx, object, index) { + super::lower_typed_array_load(ctx, object, index)? + .map(|lowered| i32_from_indexed_get_lowered(ctx, lowered)) + } else { + super::index_get::lower_masked_window_index_get_i32(ctx, object, index)? + } + } _ => None, }; Ok(value) diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index 1b48b44f62..c6a83688ef 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -129,12 +129,174 @@ fn packed_f64_loop_fact_for_index( ) -> Option<(PackedF64LoopFact, u32, i32)> { let (idx_id, offset) = packed_f64_loop_index_parts(index)?; let fact = packed_f64_loop_fact(ctx, arr_id, idx_id)?; - if offset != 0 && !fact.allow_holes { + if offset != 0 && !fact.allow_holes && !fact.window_validated { return None; } Some((fact, idx_id, offset)) } +/// Look up an active masked-window fact for `(arr, index-expr)`: the index's +/// static value window (`collectors::static_index_window` — the same function +/// the range-loop matcher used, so match-time and lowering-time agree) must +/// sit inside a window the dense range guard validated for this array in the +/// current fast-loop scope. +fn masked_window_fact_for_index( + ctx: &FnCtx<'_>, + arr_id: u32, + index: &Expr, +) -> Option { + let (lo, hi) = crate::collectors::static_index_window(index)?; + ctx.masked_window_array_facts + .iter() + .rev() + .find(|fact| { + fact.array_local_id == arr_id && lo >= fact.min_idx && hi < fact.max_idx_exclusive + }) + .cloned() +} + +/// Emit the raw in-window element load for a masked-window fact: the dense +/// range guard already proved a plain raw-f64 numeric array with every slot +/// in `[min_idx, max_idx_exclusive)` an in-bounds number (no holes), so the +/// load is a bare `header + 8 + idx*8` f64 read — no guard call, no hole +/// check, no side exit. +fn lower_masked_window_index_get( + ctx: &mut FnCtx<'_>, + arr_id: u32, + arr_box: &str, + idx_i32: &str, + fact: &super::MaskedWindowArrayFact, +) -> String { + let value = { + let blk = ctx.block(); + let arr_bits = blk.bitcast_double_to_i64(arr_box); + let arr_handle = blk.and(I64, &arr_bits, POINTER_MASK_I64); + let idx_i64 = blk.zext(I32, idx_i32, I64); + let byte_offset = blk.shl(I64, &idx_i64, "3"); + let with_header = blk.add(I64, &byte_offset, "8"); + let element_addr = blk.add(I64, &arr_handle, &with_header); + let element_ptr = blk.inttoptr(I64, &element_addr); + blk.load(DOUBLE, &element_ptr) + }; + let lowered = LoweredValue { + semantic: SemanticKind::JsNumber, + rep: NativeRep::F64, + llvm_ty: DOUBLE, + value: value.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "NumericArrayIndexGet", + Some(arr_id), + "packed_f64_masked_window_load", + &lowered, + Some(BoundsState::Guarded { + guard_id: fact.guard_id.clone(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + vec![raw_f64_layout_fact( + Some(arr_id), + "consumed", + &fact.guard_id, + None, + )], + Vec::new(), + false, + false, + vec![ + "index_range=static_window_guarded".to_string(), + "length_range=guarded_i32".to_string(), + "storage_layout=raw_f64_numeric_slots".to_string(), + ], + ); + value +} + +/// True when `object[index]` matches an active i32-tier masked-window fact — +/// the dense-i32 range guard proved every window slot is an i32-representable +/// integer, so the load can produce a native `i32` with a bare exact `fptosi`. +pub(crate) fn masked_window_i32_load_is_provable( + ctx: &FnCtx<'_>, + object: &Expr, + index: &Expr, +) -> bool { + let Expr::LocalGet(arr_id) = object else { + return false; + }; + masked_window_fact_for_index(ctx, *arr_id, index).is_some_and(|fact| fact.values_i32) +} + +/// i32-tier masked-window load: raw in-window f64 element load + bare +/// `fptosi` (exact — the dense-i32 guard proved the value is an i32 integer). +/// Returns `None` when no i32-tier fact covers the access. +pub(crate) fn lower_masked_window_index_get_i32( + ctx: &mut FnCtx<'_>, + object: &Expr, + index: &Expr, +) -> Result> { + let Expr::LocalGet(arr_id) = object else { + return Ok(None); + }; + let Some(fact) = + masked_window_fact_for_index(ctx, *arr_id, index).filter(|fact| fact.values_i32) + else { + return Ok(None); + }; + let arr_box = lower_expr(ctx, object)?; + let idx_i32 = lower_expr_as_i32(ctx, index)?; + let raw_f64 = { + let blk = ctx.block(); + let arr_bits = blk.bitcast_double_to_i64(&arr_box); + let arr_handle = blk.and(I64, &arr_bits, POINTER_MASK_I64); + let idx_i64 = blk.zext(I32, &idx_i32, I64); + let byte_offset = blk.shl(I64, &idx_i64, "3"); + let with_header = blk.add(I64, &byte_offset, "8"); + let element_addr = blk.add(I64, &arr_handle, &with_header); + let element_ptr = blk.inttoptr(I64, &element_addr); + blk.load(DOUBLE, &element_ptr) + }; + let value = ctx.block().fptosi(DOUBLE, &raw_f64, I32); + let lowered = LoweredValue { + semantic: SemanticKind::JsNumber, + rep: NativeRep::I32, + llvm_ty: I32, + value: value.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "NumericArrayIndexGet", + Some(*arr_id), + "packed_f64_masked_window_load_i32", + &lowered, + Some(BoundsState::Guarded { + guard_id: fact.guard_id.clone(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + vec![raw_f64_layout_fact( + Some(*arr_id), + "consumed", + &fact.guard_id, + None, + )], + Vec::new(), + false, + false, + vec![ + "index_range=static_window_guarded".to_string(), + "length_range=guarded_i32".to_string(), + "storage_layout=raw_f64_numeric_slots".to_string(), + "integer_materialization=fptosi_guarded_dense_i32".to_string(), + ], + ); + Ok(Some(value)) +} + /// Load the packed-loop counter's i32 shadow slot and apply the constant /// index offset. fn load_packed_loop_index_i32(ctx: &mut FnCtx<'_>, i32_slot: &str, offset: i32) -> String { @@ -953,6 +1115,19 @@ pub(crate) fn lower_numeric_index_get_for_number_context( let Expr::IndexGet { object, index } = expr else { return Ok(None); }; + // Masked-window fast path first: the dense range guard proved the whole + // static index window at loop entry, so the read needs neither the static + // layout proof below nor a per-access guard. The fact can only exist for + // a range-loop-eligible binding (never scalar-replaced or aliased). + if let Expr::LocalGet(arr_id) = object.as_ref() { + if let Some(fact) = masked_window_fact_for_index(ctx, *arr_id, index.as_ref()) { + let arr_box = lower_expr(ctx, object)?; + let idx_i32 = lower_expr_as_i32(ctx, index)?; + return Ok(Some(lower_masked_window_index_get( + ctx, *arr_id, &arr_box, &idx_i32, &fact, + ))); + } + } if !is_array_expr(ctx, object) || !expr_has_numeric_pointer_free_array_layout(ctx, object) { return Ok(None); } @@ -1621,6 +1796,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { )); } } + if let Some(fact) = masked_window_fact_for_index(ctx, *arr_id, index.as_ref()) { + let arr_box = lower_expr(ctx, object)?; + let idx_i32 = lower_expr_as_i32(ctx, index)?; + return Ok(lower_masked_window_index_get( + ctx, *arr_id, &arr_box, &idx_i32, &fact, + )); + } } if let (Expr::LocalGet(arr_id), Expr::LocalGet(idx_id)) = (object.as_ref(), index.as_ref()) diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 8eec906691..a5d39d505c 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -662,6 +662,7 @@ pub(crate) struct FnCtx<'a> { /// the array is a live packed raw-f64 plain Array and the loop proof keeps /// `i` in bounds. pub packed_f64_loop_facts: Vec, + pub masked_window_array_facts: Vec, /// #5093: scoped loop-versioning facts for monomorphic class-field loops. /// Pushed only around the FAST clone of `lower_class_field_versioned_for` @@ -1191,6 +1192,33 @@ pub(crate) struct PackedF64LoopFact { /// RHS is numeric bits (side-exiting otherwise) and skip the per-iteration /// store guard — the range guard already proved bounds and mutability. pub allow_holes: bool, + /// True when a *range* guard (hole-tolerant or dense) validated the whole + /// constant-offset index window `[start + min_offset, bound + max_offset)` + /// at loop entry — `arr[i ± c]` loads may use non-zero offsets even + /// without hole tolerance (`allow_holes: false` + `window_validated: true` + /// is the dense range loop: the window is additionally hole-free, so + /// loads carry no hole check at all). + pub window_validated: bool, +} + +/// Read-only masked-index window fact for the dense packed-f64 range loop: +/// the entry guard (`js_typed_feedback_packed_f64_range_loop_guard_dense`) +/// proved `array_local_id` is a plain raw-f64 numeric array whose +/// `[min_idx, max_idx_exclusive)` slots are all in-bounds numbers (no holes). +/// Any read whose index has a static value window inside this range (e.g. +/// `S[x & 1023]`, `S[256 + ((x >>> 16) & 0xff)]` — see +/// `collectors::static_index_window`) lowers to a bare raw-f64 element load. +#[derive(Debug, Clone)] +pub(crate) struct MaskedWindowArrayFact { + pub array_local_id: u32, + pub scope_id: u32, + pub guard_id: String, + pub min_idx: i64, + pub max_idx_exclusive: i64, + /// True in the i32-tier fast copy: the guard additionally proved every + /// window slot holds an i32-representable integer, so loads may + /// materialize elements as `i32` with a bare exact `fptosi`. + pub values_i32: bool, } /// #5093: one fact per (receiver, versioned loop). See diff --git a/crates/perry-codegen/src/expr/range_facts.rs b/crates/perry-codegen/src/expr/range_facts.rs index 1c0ebf749a..5d47d08a78 100644 --- a/crates/perry-codegen/src/expr/range_facts.rs +++ b/crates/perry-codegen/src/expr/range_facts.rs @@ -158,6 +158,121 @@ fn checked_range_div(lhs: IntRange, rhs: IntRange) -> Option { None } +/// Smallest all-ones value covering `value` (`255 → 255`, `256 → 511`, +/// `0 → 0`). An upper bound for `a | b` / `a & b` results whose operands are +/// bounded by `value`: OR/AND cannot set a bit above the highest bit of +/// either operand's cover. +fn ones_cover(value: i64) -> i64 { + debug_assert!(value >= 0); + if value == 0 { + return 0; + } + ((1u64 << (64 - (value as u64).leading_zeros())) - 1) as i64 +} + +/// `a & b` where both operands carry non-negative ranges bounded by +/// `i32::MAX`: `ToInt32` of a value in `[0, 2^31)` cannot wrap or change +/// sign, and AND of two non-negative i32 values is bounded by each operand. +fn checked_range_bitand(lhs: IntRange, rhs: IntRange) -> Option { + if lhs.min >= 0 && rhs.min >= 0 && lhs.max <= i32::MAX as i64 && rhs.max <= i32::MAX as i64 { + return Some(IntRange { + min: 0, + max: lhs.max.min(rhs.max), + }); + } + None +} + +/// `a | b` where both operands carry non-negative ranges bounded by +/// `i32::MAX`: OR cannot clear bits (so it is at least each operand) and +/// cannot set a bit above either operand's ones-cover. +fn checked_range_bitor(lhs: IntRange, rhs: IntRange) -> Option { + if lhs.min >= 0 && rhs.min >= 0 && lhs.max <= i32::MAX as i64 && rhs.max <= i32::MAX as i64 { + return Some(IntRange { + min: lhs.min.max(rhs.min), + max: ones_cover(lhs.max) | ones_cover(rhs.max), + }); + } + None +} + +/// A constant operand of `&` usable as a result mask: a non-negative integer +/// `≤ i32::MAX`, so `ToInt32` leaves it unchanged and its sign bit is clear. +fn bitand_mask_constant(ctx: &FnCtx<'_>, expr: &Expr) -> Option { + let mask = constant_i64_expr(ctx, expr)?; + (0..=i64::from(i32::MAX)).contains(&mask).then_some(mask) +} + +/// Value range of an `object[index]` load from a width-tracked integer-element +/// typed array when the index is provably in bounds: the element kind bounds +/// the value (an in-bounds `Int32Array` read is always an i32 integer, a +/// `Uint8Array` read is `[0, 255]`, …). Out of bounds would read `undefined`, +/// so the same static bounds proof the unchecked native load relies on gates +/// the range. The index range is computed through the SAME `seen` set as the +/// enclosing walk so mutually-recursive local aliases keep their cycle +/// breaker. +fn int_typed_array_load_range( + ctx: &FnCtx<'_>, + object: &Expr, + index: &Expr, + seen: &mut std::collections::HashSet, +) -> Option { + use crate::native_value::{BufferElem, BufferIndexUnit, MaterializationReason}; + if ctx.disable_buffer_fast_path { + return None; + } + let Expr::LocalGet(id) = object else { + return None; + }; + let view = ctx.buffer_view_slots.get(id)?; + if view.index_unit != BufferIndexUnit::Element + || !view.alias.allows_noalias() + || view.scope_idx.is_none() + { + return None; + } + if ctx.closure_captures.contains_key(id) + || matches!( + ctx.buffer_hazard_reasons.get(id), + Some(MaterializationReason::ClosureCapture) + ) + { + return None; + } + let elem_range = match view.elem { + BufferElem::I8 => IntRange { + min: -128, + max: 127, + }, + BufferElem::U8 | BufferElem::U8Clamped => IntRange { min: 0, max: 255 }, + BufferElem::I16 => IntRange { + min: -32768, + max: 32767, + }, + BufferElem::U16 => IntRange { min: 0, max: 65535 }, + BufferElem::I32 => IntRange { + min: i32::MIN as i64, + max: i32::MAX as i64, + }, + BufferElem::U32 => IntRange { + min: 0, + max: u32::MAX as i64, + }, + BufferElem::F32 | BufferElem::F64 => return None, + }; + let index_range = int_range_expr_inner(ctx, index, seen)?; + let length_min = view + .length_source + .as_ref() + .and_then(|source| length_source_range(ctx, source))? + .min; + if index_range.min >= 0 && index_range.max < length_min { + Some(elem_range) + } else { + None + } +} + fn pod_layout_constant_i64(ctx: &FnCtx<'_>, expr: &Expr) -> Option { match expr { Expr::PodLayoutSizeOf { ty } => match layout_decision_for_type(ctx, ty) { @@ -220,7 +335,37 @@ fn int_range_expr_inner( | Expr::PodLayoutAlignOf { .. } | Expr::PodLayoutOffsetOf { .. } => pod_layout_constant_i64(ctx, expr).map(IntRange::exact), Expr::LocalGet(id) => int_range_for_local(ctx, *id, seen), + Expr::IndexGet { object, index } => int_typed_array_load_range(ctx, object, index, seen), Expr::Binary { op, left, right } => { + // Result-shape rules that need no range on one (or either) + // operand. `e & K` with a non-negative i32 constant `K` is + // `ToInt32(e) & K ∈ [0, K]` for EVERY `e` (NaN, fractional, + // negative, non-numeric — `ToInt32` coerces first, the mask + // bounds last), and `e >>> k` is a `ToUint32` result shifted + // right, bounded by the shift amount alone. Both results are + // integral by construction, so they are safe to feed the + // unchecked buffer-bounds proofs (a fractional index would read + // a named property, not an element — these ops cannot produce + // one). This is what lets `S[i & 1023]` / `S[x >>> 24]` / + // `S[0x100 | ((x >> 16) & 0xff)]` (the bcryptjs Blowfish S-box + // shapes) prove bounds against a known view length. + if matches!(op, BinaryOp::BitAnd) { + if let Some(mask) = + bitand_mask_constant(ctx, left).or_else(|| bitand_mask_constant(ctx, right)) + { + return Some(IntRange { min: 0, max: mask }); + } + } + if matches!(op, BinaryOp::UShr) { + // JS `>>>` shifts by `ToUint32(rhs) & 31`; any result is a + // Uint32. A constant shift of `k ∈ [1, 31]` tightens the + // bound to `2^(32-k) - 1`. + let max = match constant_i64_expr(ctx, right).map(|k| (k as u64) & 31) { + Some(k) if k > 0 => (1i64 << (32 - k)) - 1, + _ => i64::from(u32::MAX), + }; + return Some(IntRange { min: 0, max }); + } let lhs = int_range_expr_inner(ctx, left, seen)?; let rhs = int_range_expr_inner(ctx, right, seen)?; match op { @@ -228,6 +373,7 @@ fn int_range_expr_inner( BinaryOp::Sub => checked_range_sub(lhs, rhs), BinaryOp::Mul => checked_range_mul(lhs, rhs), BinaryOp::Div => checked_range_div(lhs, rhs), + // `| 0` keeps the (possibly negative) operand range exactly. BinaryOp::BitOr if rhs.min == 0 && rhs.max == 0 => { if lhs.min >= i32::MIN as i64 && lhs.max <= i32::MAX as i64 { Some(lhs) @@ -235,6 +381,15 @@ fn int_range_expr_inner( None } } + BinaryOp::BitOr if lhs.min == 0 && lhs.max == 0 => { + if rhs.min >= i32::MIN as i64 && rhs.max <= i32::MAX as i64 { + Some(rhs) + } else { + None + } + } + BinaryOp::BitOr => checked_range_bitor(lhs, rhs), + BinaryOp::BitAnd => checked_range_bitand(lhs, rhs), _ => None, } } diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 04778bb368..aae5ae91fa 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -342,6 +342,18 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { I32, &[I64, DOUBLE, I32, I32], ); + // Dense-window variant for the read-only masked-index range loop: the + // window must be hole-free (loads carry no hole check / side exit). + module.declare_function( + "js_typed_feedback_packed_f64_range_loop_guard_dense", + I32, + &[I64, DOUBLE, I32, I32], + ); + module.declare_function( + "js_typed_feedback_packed_f64_range_loop_guard_dense_i32", + I32, + &[I64, DOUBLE, I32, I32], + ); module.declare_function( "js_typed_feedback_packed_u32_array_loop_guard", I32, diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index d37f4e28b5..9ed7179a50 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -364,6 +364,7 @@ fn lower_packed_f64_versioned_for( store_side_exit_label: slow_pre_label.clone(), array_kind: matched.array_kind, allow_holes: false, + window_validated: false, }); lower_for_after_init( ctx, @@ -411,9 +412,13 @@ enum PackedF64RangeLoopBound { #[derive(Clone, Copy)] struct PackedF64RangeArrayAccess { array_id: u32, - /// Smallest / largest constant offset `c` over all `arr[i + c]` accesses. - min_offset: i32, - max_offset: i32, + /// Counter-relative accesses: smallest / largest constant offset `c` over + /// all `arr[i ± c]` accesses. + counter: Option<(i32, i32)>, + /// Merged static index windows `(lo, hi)` over masked accesses + /// (`arr[e & K]`, `arr[K1 + (e >>> k & K2)]`, … — see + /// `collectors::static_index_window`). Dense mode only. + stat: Option<(i64, i64)>, written: bool, } @@ -424,6 +429,13 @@ struct PackedF64RangeLoop { bound: PackedF64RangeLoopBound, /// Per-array access windows, ordered by array local id (deterministic). arrays: Vec, + /// True for the read-only masked-index mode: the body may hold several + /// scalar statements and statically-windowed (`e & K`-shaped) reads, the + /// entry guard is the DENSE variant (window must be hole-free), and the + /// fast loop's loads carry no hole check and no side exit (a + /// mid-iteration side exit could double-apply earlier statement effects + /// on re-execution). + dense: bool, } /// #6011: range-preguarded packed-f64 versioned loop. @@ -530,26 +542,69 @@ fn match_packed_f64_range_loop( }; let mut accesses: std::collections::BTreeMap = std::collections::BTreeMap::new(); - if !packed_f64_range_loop_body_collect(body, counter_id, bound_local, &mut accesses) { - return None; - } + let dense = if packed_f64_range_loop_body_collect(body, counter_id, bound_local, &mut accesses) + { + false + } else { + // The classic shape (one statement, counter-offset indices, stores + // allowed, hole-tolerant with side exits) didn't match. Try the + // read-only DENSE mode: several scalar statements, masked + // statically-windowed indices, no stores, no side exits. + accesses.clear(); + if !packed_f64_range_loop_dense_body_collect(body, counter_id, bound_local, &mut accesses) { + return None; + } + true + }; if accesses.is_empty() { // No tracked array access — nothing for the versioned loop to win. return None; } for access in accesses.values() { let arr_id = access.array_id; - if !packed_loop_array_binding_is_eligible(ctx, arr_id) { + // Written arrays keep the full fact-graph eligibility (below). Reads + // only need a declared number-array binding in addressable storage: + // the range guard re-validates the ACTUAL runtime array — plain-array + // shape, raw-f64 packedness, frozen/descriptor/prototype state, and + // the whole index window — at loop entry, and the matched body admits + // no store/call/closure/await, so nothing can reshape the array (even + // through an alias) between the guard and the last iteration. In + // particular this must NOT consult the materialization-hazard / + // array-kind facts: `mark_unknown_call_escape` blanket-hazards every + // function-local tracked array when the function contains ANY call + // (e.g. a `console.log` after the loop), which would keep every + // locally-built lookup table (`const S: number[] = new Array(1024)` + // + fill loop — the Blowfish S-box shape) off the fast path forever. + // A wrong static hint costs one failed guard → slow loop, never + // correctness. + if access.written { + if !packed_loop_array_binding_is_eligible(ctx, arr_id) { + return None; + } + } else if !packed_loop_array_binding_storage_is_addressable(ctx, arr_id) + || ctx.scalar_replaced_arrays.contains_key(&arr_id) + { return None; } // The guard takes i32 window endpoints; make sure `start + offset` // still fits (bound-side overflow is prevented by the constant cap / // runtime bound range check). - let min_idx = start + i64::from(access.min_offset); - let max_base = start + i64::from(access.max_offset); - if !(i64::from(i32::MIN)..=i64::from(i32::MAX)).contains(&min_idx) - || !(i64::from(i32::MIN)..=i64::from(i32::MAX)).contains(&max_base) - { + if let Some((min_offset, max_offset)) = access.counter { + let min_idx = start + i64::from(min_offset); + let max_base = start + i64::from(max_offset); + if !(i64::from(i32::MIN)..=i64::from(i32::MAX)).contains(&min_idx) + || !(i64::from(i32::MIN)..=i64::from(i32::MAX)).contains(&max_base) + { + return None; + } + } + if let Some((lo, hi)) = access.stat { + // `hi + 1` must fit the guard's i32 `max_idx_exclusive` argument. + if lo < 0 || hi >= i64::from(i32::MAX) { + return None; + } + } + if access.counter.is_none() && access.stat.is_none() { return None; } if access.written { @@ -560,9 +615,7 @@ fn match_packed_f64_range_loop( { return None; } - } else if !local_is_number_array(ctx, arr_id) - || !ctx.native_facts.proves_packed_f64_array(arr_id) - { + } else if !local_is_number_array(ctx, arr_id) { return None; } } @@ -571,6 +624,7 @@ fn match_packed_f64_range_loop( start, bound, arrays: accesses.into_values().collect(), + dense, }) } @@ -584,15 +638,37 @@ fn record_packed_f64_range_access( .entry(array_id) .or_insert(PackedF64RangeArrayAccess { array_id, - min_offset: offset, - max_offset: offset, + counter: None, + stat: None, written, }); - entry.min_offset = entry.min_offset.min(offset); - entry.max_offset = entry.max_offset.max(offset); + entry.counter = Some(match entry.counter { + None => (offset, offset), + Some((min, max)) => (min.min(offset), max.max(offset)), + }); entry.written |= written; } +fn record_packed_f64_range_static_access( + accesses: &mut std::collections::BTreeMap, + array_id: u32, + lo: i64, + hi: i64, +) { + let entry = accesses + .entry(array_id) + .or_insert(PackedF64RangeArrayAccess { + array_id, + counter: None, + stat: None, + written: false, + }); + entry.stat = Some(match entry.stat { + None => (lo, hi), + Some((cur_lo, cur_hi)) => (cur_lo.min(lo), cur_hi.max(hi)), + }); +} + /// `i` → 0, `i + c` / `c + i` → c, `i - c` → -c, with |result| ≤ 64. fn packed_f64_range_loop_index_offset(index: &perry_hir::Expr, counter_id: u32) -> Option { use perry_hir::{BinaryOp, Expr}; @@ -663,10 +739,10 @@ fn packed_f64_range_loop_body_collect( Expr::LocalSet(id, value) => { *id != counter_id && Some(*id) != bound_local - && packed_f64_range_loop_pure_expr_collect(value, counter_id, accesses) + && packed_f64_range_loop_pure_expr_collect(value, counter_id, false, accesses) && !accesses.contains_key(id) } - _ => packed_f64_range_loop_pure_expr_collect(expr, counter_id, accesses), + _ => packed_f64_range_loop_pure_expr_collect(expr, counter_id, false, accesses), } } @@ -722,19 +798,81 @@ fn packed_f64_range_loop_store_collect( let Some(offset) = packed_f64_range_loop_index_offset(index, counter_id) else { return false; }; - if !packed_f64_range_loop_pure_expr_collect(value, counter_id, accesses) { + if !packed_f64_range_loop_pure_expr_collect(value, counter_id, false, accesses) { return false; } record_packed_f64_range_access(accesses, *arr_id, offset, true); true } +/// Body walk for the read-only DENSE range-loop mode: any number of scalar +/// statements — `const a = ` / `sum = ` / `n++` / bare pure +/// expressions — where every tracked array access is a READ with a +/// counter-offset or statically-windowed index. No store to a tracked array, +/// no call/closure/await, and the written scalars must be disjoint from the +/// tracked arrays, the counter, and the bound. Because the fast loop's loads +/// have no side exits, multi-statement bodies are safe: an iteration either +/// runs entirely in the fast copy or entirely in the slow copy. +fn packed_f64_range_loop_dense_body_collect( + body: &[Stmt], + counter_id: u32, + bound_local: Option, + accesses: &mut std::collections::BTreeMap, +) -> bool { + use perry_hir::Expr; + let mut written: std::collections::HashSet = std::collections::HashSet::new(); + for stmt in body { + match stmt { + Stmt::Let { + id, + init: Some(init), + .. + } => { + if !packed_f64_range_loop_pure_expr_collect(init, counter_id, true, accesses) { + return false; + } + written.insert(*id); + } + Stmt::Let { id, init: None, .. } => { + written.insert(*id); + } + Stmt::Expr(Expr::LocalSet(id, value)) => { + if *id == counter_id || Some(*id) == bound_local { + return false; + } + if !packed_f64_range_loop_pure_expr_collect(value, counter_id, true, accesses) { + return false; + } + written.insert(*id); + } + Stmt::Expr(Expr::Update { id, .. }) => { + if *id == counter_id || Some(*id) == bound_local { + return false; + } + written.insert(*id); + } + Stmt::Expr(expr) => { + if !packed_f64_range_loop_pure_expr_collect(expr, counter_id, true, accesses) { + return false; + } + } + _ => return false, + } + } + !accesses.is_empty() + && accesses.values().all(|access| !access.written) + && accesses.keys().all(|arr_id| !written.contains(arr_id)) +} + /// Effect-free expression walk: tracked `a[i ± c]` reads, locals, literals and /// pure arithmetic/Math only. Any store, call, update, closure, or index read /// with an unrecognized receiver/index shape bails the whole match. +/// `allow_static` (dense mode) additionally admits reads whose index carries a +/// static value window (`a[e & K]`, `a[K1 + (e >>> k & K2)]`, …). fn packed_f64_range_loop_pure_expr_collect( expr: &perry_hir::Expr, counter_id: u32, + allow_static: bool, accesses: &mut std::collections::BTreeMap, ) -> bool { use perry_hir::Expr; @@ -743,10 +881,24 @@ fn packed_f64_range_loop_pure_expr_collect( let Expr::LocalGet(arr_id) = object.as_ref() else { return false; }; - let Some(offset) = packed_f64_range_loop_index_offset(index, counter_id) else { + if let Some(offset) = packed_f64_range_loop_index_offset(index, counter_id) { + record_packed_f64_range_access(accesses, *arr_id, offset, false); + return true; + } + if !allow_static { + return false; + } + let Some((lo, hi)) = crate::collectors::static_index_window(index) else { return false; }; - record_packed_f64_range_access(accesses, *arr_id, offset, false); + if lo < 0 || hi >= i64::from(i32::MAX) { + return false; + } + // The index may nest further tracked reads — walk it too. + if !packed_f64_range_loop_pure_expr_collect(index, counter_id, allow_static, accesses) { + return false; + } + record_packed_f64_range_static_access(accesses, *arr_id, lo, hi); true } Expr::LocalGet(_) @@ -758,32 +910,52 @@ fn packed_f64_range_loop_pure_expr_collect( Expr::Binary { left, right, .. } | Expr::Compare { left, right, .. } | Expr::Logical { left, right, .. } => { - packed_f64_range_loop_pure_expr_collect(left, counter_id, accesses) - && packed_f64_range_loop_pure_expr_collect(right, counter_id, accesses) + packed_f64_range_loop_pure_expr_collect(left, counter_id, allow_static, accesses) + && packed_f64_range_loop_pure_expr_collect( + right, + counter_id, + allow_static, + accesses, + ) } Expr::Unary { operand, .. } | Expr::Void(operand) | Expr::TypeOf(operand) | Expr::NumberCoerce(operand) | Expr::BooleanCoerce(operand) => { - packed_f64_range_loop_pure_expr_collect(operand, counter_id, accesses) + packed_f64_range_loop_pure_expr_collect(operand, counter_id, allow_static, accesses) } Expr::Conditional { condition, then_expr, else_expr, } => { - packed_f64_range_loop_pure_expr_collect(condition, counter_id, accesses) - && packed_f64_range_loop_pure_expr_collect(then_expr, counter_id, accesses) - && packed_f64_range_loop_pure_expr_collect(else_expr, counter_id, accesses) + packed_f64_range_loop_pure_expr_collect(condition, counter_id, allow_static, accesses) + && packed_f64_range_loop_pure_expr_collect( + then_expr, + counter_id, + allow_static, + accesses, + ) + && packed_f64_range_loop_pure_expr_collect( + else_expr, + counter_id, + allow_static, + accesses, + ) } Expr::MathImul(left, right) | Expr::MathPow(left, right) => { - packed_f64_range_loop_pure_expr_collect(left, counter_id, accesses) - && packed_f64_range_loop_pure_expr_collect(right, counter_id, accesses) + packed_f64_range_loop_pure_expr_collect(left, counter_id, allow_static, accesses) + && packed_f64_range_loop_pure_expr_collect( + right, + counter_id, + allow_static, + accesses, + ) } - Expr::MathMin(values) | Expr::MathMax(values) => values - .iter() - .all(|expr| packed_f64_range_loop_pure_expr_collect(expr, counter_id, accesses)), + Expr::MathMin(values) | Expr::MathMax(values) => values.iter().all(|expr| { + packed_f64_range_loop_pure_expr_collect(expr, counter_id, allow_static, accesses) + }), Expr::MathAbs(value) | Expr::MathSqrt(value) | Expr::MathFloor(value) @@ -792,7 +964,7 @@ fn packed_f64_range_loop_pure_expr_collect( | Expr::MathTrunc(value) | Expr::MathSign(value) | Expr::MathF16round(value) => { - packed_f64_range_loop_pure_expr_collect(value, counter_id, accesses) + packed_f64_range_loop_pure_expr_collect(value, counter_id, allow_static, accesses) } _ => false, } @@ -804,6 +976,115 @@ fn packed_f64_range_loop_pure_expr_collect( /// guard runs per accessed array, and the AND of the guards picks the fast /// loop (hole-tolerant `PackedF64LoopFact` per array; side exits resume at /// the current `i` in the slow copy) or the slow loop. +/// Emit one range-guard call per accessed array (window endpoints merged +/// from the counter part `[start + min_offset, bound + max_offset)` and the +/// static part `[lo, hi]`), AND-reduced into a single i1. +fn emit_packed_f64_range_guards( + ctx: &mut FnCtx<'_>, + matched: &PackedF64RangeLoop, + bound_i32: &str, + guard_fn: &str, + guard_id: &str, +) -> Result { + let mut all_guards_ok: Option = None; + for access in &matched.arrays { + let arr_box = lower_expr(ctx, &perry_hir::Expr::LocalGet(access.array_id))?; + let feedback_site_id = emit_typed_feedback_register_site( + ctx, + TypedFeedbackKind::ArrayElement, + "array[packed_f64_range_loop]", + TypedFeedbackContract::packed_f64_array_loop(), + ); + let (min_idx, max_idx): (String, String) = match (access.counter, access.stat) { + (Some((min_off, max_off)), None) => ( + (matched.start + i64::from(min_off)).to_string(), + ctx.block().add(I32, bound_i32, &max_off.to_string()), + ), + (None, Some((lo, hi))) => (lo.to_string(), (hi + 1).to_string()), + (Some((min_off, max_off)), Some((lo, hi))) => { + let min_c = (matched.start + i64::from(min_off)).min(lo).to_string(); + let counter_max = ctx.block().add(I32, bound_i32, &max_off.to_string()); + let static_max = (hi + 1).to_string(); + let counter_wins = ctx.block().icmp_sgt(I32, &counter_max, &static_max); + let max_r = ctx.block().select( + crate::types::I1, + &counter_wins, + I32, + &counter_max, + &static_max, + ); + (min_c, max_r) + } + (None, None) => unreachable!("range-loop access with no window"), + }; + let guard_i32 = ctx.block().call( + I32, + guard_fn, + &[ + (I64, &feedback_site_id), + (DOUBLE, &arr_box), + (I32, &min_idx), + (I32, &max_idx), + ], + ); + let guard_ok = ctx.block().icmp_ne(I32, &guard_i32, "0"); + all_guards_ok = Some(match all_guards_ok { + None => guard_ok, + Some(prev) => ctx.block().and(I1, &prev, &guard_ok), + }); + record_packed_f64_loop_guard_artifacts( + ctx, + access.array_id, + &arr_box, + guard_id, + PackedNumericLoopKind::F64, + ); + } + Ok(all_guards_ok.expect("range loop matcher requires >= 1 array")) +} + +/// Push the per-array facts for one fast-loop copy: counter accesses get a +/// `PackedF64LoopFact` (hole-tolerant only in the classic non-dense mode), +/// masked accesses get a `MaskedWindowArrayFact` (`values_i32` selects the +/// i32-tier load lowering). +fn push_packed_f64_range_facts( + ctx: &mut FnCtx<'_>, + matched: &PackedF64RangeLoop, + scope_id: u32, + guard_id: &str, + slow_pre_label: &str, + values_i32: bool, +) { + for access in &matched.arrays { + if access.counter.is_some() { + ctx.packed_f64_loop_facts.push(PackedF64LoopFact { + index_local_id: matched.counter_id, + array_local_id: access.array_id, + scope_id, + guard_id: guard_id.to_string(), + store_side_exit_label: slow_pre_label.to_string(), + array_kind: PackedNumericLoopKind::F64, + // Dense mode proved the window hole-free — loads need no + // hole check / side exit. Classic range mode stays + // hole-tolerant. + allow_holes: !matched.dense, + window_validated: true, + }); + } + if let Some((lo, hi)) = access.stat { + ctx.masked_window_array_facts + .push(crate::expr::MaskedWindowArrayFact { + array_local_id: access.array_id, + scope_id, + guard_id: guard_id.to_string(), + min_idx: lo, + max_idx_exclusive: hi + 1, + values_i32, + }); + } + } +} + fn lower_packed_f64_range_versioned_for( ctx: &mut FnCtx<'_>, init: Option<&Stmt>, @@ -816,8 +1097,30 @@ fn lower_packed_f64_range_versioned_for( }; // The inline load/store fast paths read the counter through its i32 // shadow slot; without one the versioned copy would win nothing. + let mut counter_i32_was_fresh = false; if !ctx.i32_counter_slots.contains_key(&matched.counter_id) { - return Ok(false); + // The Let site only allocates the shadow for *directly* index-used + // locals; a masked index (`S[i & 1023]`) hides the counter from that + // analysis. With a CONSTANT bound the counter provably stays in i32 + // range (the matcher caps constants at `i32::MAX - 64`), so allocate + // the parallel slot here — mirroring the `i < n` local-bound path in + // `lower_for`. Runtime local bounds keep requiring a pre-existing + // slot (their range is only proven inside this lowering, after the + // slot would already be live). + if !matches!(matched.bound, PackedF64RangeLoopBound::Constant(_)) + || !ctx.integer_locals.contains(&matched.counter_id) + { + return Ok(false); + } + let Some(counter_slot) = ctx.locals.get(&matched.counter_id).cloned() else { + return Ok(false); + }; + let i32_slot = ctx.func.alloca_entry(I32); + let cur_dbl = ctx.block().load(DOUBLE, &counter_slot); + let cur_i32 = ctx.block().fptosi(DOUBLE, &cur_dbl, I32); + ctx.block().store(I32, &cur_i32, &i32_slot); + ctx.i32_counter_slots.insert(matched.counter_id, i32_slot); + counter_i32_was_fresh = true; } // Cache loop-invariant module-global reads (e.g. `alpha` in the EMA @@ -896,74 +1199,129 @@ fn lower_packed_f64_range_versioned_for( } }; - let guard_id = "packed_f64_range_loop_guard"; - let mut all_guards_ok: Option = None; - for access in &matched.arrays { - let arr_box = lower_expr(ctx, &perry_hir::Expr::LocalGet(access.array_id))?; - let feedback_site_id = emit_typed_feedback_register_site( + if matched.dense { + // Read-only dense mode: two guard tiers. The i32 tier additionally + // proves every window value is an i32-representable integer, so its + // fast copy materializes loads with a bare exact `fptosi` (bit-mixing + // chains stay in integer registers); the f64 tier keeps raw-double + // loads for float lookup tables. Either failing falls through. + let try_f64_idx = ctx.new_block("packed_f64_range.dense.try_f64"); + let try_f64_label = ctx.block_label(try_f64_idx); + let fast_i32_pre_idx = ctx.new_block("packed_f64_range.loop.fast_i32.preheader"); + let fast_i32_pre_label = ctx.block_label(fast_i32_pre_idx); + + let ok_i32 = emit_packed_f64_range_guards( ctx, - TypedFeedbackKind::ArrayElement, - "array[packed_f64_range_loop]", - TypedFeedbackContract::packed_f64_array_loop(), - ); - let min_idx = (matched.start + i64::from(access.min_offset)).to_string(); - let max_idx = ctx - .block() - .add(I32, &bound_i32, &access.max_offset.to_string()); - let guard_i32 = ctx.block().call( - I32, - "js_typed_feedback_packed_f64_range_loop_guard", - &[ - (I64, &feedback_site_id), - (DOUBLE, &arr_box), - (I32, &min_idx), - (I32, &max_idx), - ], + &matched, + &bound_i32, + "js_typed_feedback_packed_f64_range_loop_guard_dense_i32", + "packed_f64_range_loop_guard_dense_i32", + )?; + ctx.block() + .cond_br(&ok_i32, &fast_i32_pre_label, &try_f64_label); + + ctx.current_block = try_f64_idx; + let ok_f64 = emit_packed_f64_range_guards( + ctx, + &matched, + &bound_i32, + "js_typed_feedback_packed_f64_range_loop_guard_dense", + "packed_f64_range_loop_guard_dense", + )?; + ctx.block() + .cond_br(&ok_f64, &fast_pre_label, &slow_pre_label); + + ctx.current_block = fast_i32_pre_idx; + let scope_i32 = ctx.next_loop_proof_scope_id(); + push_packed_f64_range_facts( + ctx, + &matched, + scope_i32, + "packed_f64_range_loop_guard_dense_i32", + &slow_pre_label, + true, ); - let guard_ok = ctx.block().icmp_ne(I32, &guard_i32, "0"); - all_guards_ok = Some(match all_guards_ok { - None => guard_ok, - Some(prev) => ctx.block().and(I1, &prev, &guard_ok), - }); - record_packed_f64_loop_guard_artifacts( + lower_for_after_init_with_i32_bound( ctx, - access.array_id, - &arr_box, - guard_id, - PackedNumericLoopKind::F64, + init, + condition, + update, + body, + "for.packed_f64_range_fast_i32", + Some((matched.counter_id, bound_i32.clone())), + )?; + ctx.packed_f64_loop_facts + .retain(|fact| fact.scope_id != scope_i32); + ctx.masked_window_array_facts + .retain(|fact| fact.scope_id != scope_i32); + if !ctx.block().is_terminated() { + ctx.block().br(&merge_label); + } + + ctx.current_block = fast_pre_idx; + let scope_f64 = ctx.next_loop_proof_scope_id(); + push_packed_f64_range_facts( + ctx, + &matched, + scope_f64, + "packed_f64_range_loop_guard_dense", + &slow_pre_label, + false, ); - } - let all_guards_ok = all_guards_ok.expect("range loop matcher requires >= 1 array"); - ctx.block() - .cond_br(&all_guards_ok, &fast_pre_label, &slow_pre_label); + lower_for_after_init_with_i32_bound( + ctx, + init, + condition, + update, + body, + "for.packed_f64_range_fast", + Some((matched.counter_id, bound_i32.clone())), + )?; + ctx.packed_f64_loop_facts + .retain(|fact| fact.scope_id != scope_f64); + ctx.masked_window_array_facts + .retain(|fact| fact.scope_id != scope_f64); + if !ctx.block().is_terminated() { + ctx.block().br(&merge_label); + } + } else { + let all_guards_ok = emit_packed_f64_range_guards( + ctx, + &matched, + &bound_i32, + "js_typed_feedback_packed_f64_range_loop_guard", + "packed_f64_range_loop_guard", + )?; + ctx.block() + .cond_br(&all_guards_ok, &fast_pre_label, &slow_pre_label); - let packed_scope_id = ctx.next_loop_proof_scope_id(); + let packed_scope_id = ctx.next_loop_proof_scope_id(); - ctx.current_block = fast_pre_idx; - for access in &matched.arrays { - ctx.packed_f64_loop_facts.push(PackedF64LoopFact { - index_local_id: matched.counter_id, - array_local_id: access.array_id, - scope_id: packed_scope_id, - guard_id: guard_id.to_string(), - store_side_exit_label: slow_pre_label.clone(), - array_kind: PackedNumericLoopKind::F64, - allow_holes: true, - }); - } - lower_for_after_init_with_i32_bound( - ctx, - init, - condition, - update, - body, - "for.packed_f64_range_fast", - Some((matched.counter_id, bound_i32.clone())), - )?; - ctx.packed_f64_loop_facts - .retain(|fact| fact.scope_id != packed_scope_id); - if !ctx.block().is_terminated() { - ctx.block().br(&merge_label); + ctx.current_block = fast_pre_idx; + push_packed_f64_range_facts( + ctx, + &matched, + packed_scope_id, + "packed_f64_range_loop_guard", + &slow_pre_label, + false, + ); + lower_for_after_init_with_i32_bound( + ctx, + init, + condition, + update, + body, + "for.packed_f64_range_fast", + Some((matched.counter_id, bound_i32.clone())), + )?; + ctx.packed_f64_loop_facts + .retain(|fact| fact.scope_id != packed_scope_id); + ctx.masked_window_array_facts + .retain(|fact| fact.scope_id != packed_scope_id); + if !ctx.block().is_terminated() { + ctx.block().br(&merge_label); + } } ctx.current_block = slow_pre_idx; @@ -982,6 +1340,9 @@ fn lower_packed_f64_range_versioned_for( for gid in &global_override_ids { ctx.locals.remove(gid); } + if counter_i32_was_fresh { + ctx.i32_counter_slots.remove(&matched.counter_id); + } ctx.current_block = merge_idx; Ok(true) } @@ -1772,16 +2133,22 @@ fn local_array_element_type<'t>( /// that distinction is what kept a captured `const rows: number[]` off the fast /// loop in a closure while the same code in a plain function got it. fn packed_loop_array_binding_is_eligible(ctx: &FnCtx<'_>, arr_id: u32) -> bool { - let storage_is_addressable = if ctx.closure_captures.contains_key(&arr_id) { + packed_loop_array_binding_storage_is_addressable(ctx, arr_id) + && !ctx.scalar_replaced_arrays.contains_key(&arr_id) + && !ctx.native_facts.has_materialization_hazard(arr_id) +} + +/// The storage half of [`packed_loop_array_binding_is_eligible`]: the binding +/// read is a plain load (stack alloca or `@perry_global_*`), not a capture +/// slot or box. +fn packed_loop_array_binding_storage_is_addressable(ctx: &FnCtx<'_>, arr_id: u32) -> bool { + if ctx.closure_captures.contains_key(&arr_id) { false } else if ctx.locals.contains_key(&arr_id) { !ctx.boxed_vars.contains(&arr_id) } else { ctx.module_globals.contains_key(&arr_id) - }; - storage_is_addressable - && !ctx.scalar_replaced_arrays.contains_key(&arr_id) - && !ctx.native_facts.has_materialization_hazard(arr_id) + } } fn local_is_number_array(ctx: &FnCtx<'_>, local_id: u32) -> bool { diff --git a/crates/perry-runtime/src/array/header.rs b/crates/perry-runtime/src/array/header.rs index a679f84027..d16a1b2e3b 100644 --- a/crates/perry-runtime/src/array/header.rs +++ b/crates/perry-runtime/src/array/header.rs @@ -1086,6 +1086,67 @@ pub(crate) unsafe fn rebuild_array_numeric_raw_f64_allow_holes(arr: *mut ArrayHe true } +/// Dense-window variant of [`rebuild_array_numeric_raw_f64_allow_holes`] for +/// the read-only masked-index range loop: after the hole-tolerant rebuild, +/// additionally require that `[min_idx, max_idx_exclusive)` contains NO holes. +/// That loop's inline loads skip the per-slot hole check entirely (its body +/// may interleave several scalar writes per iteration, so a mid-iteration +/// side exit could double-apply effects on re-execution) — a hole inside the +/// window would leak `TAG_HOLE` bits as a raw double. Holes OUTSIDE the +/// window are fine and keep their raw-f64-or-holes invariant. +pub(crate) unsafe fn rebuild_array_numeric_raw_f64_dense_window( + arr: *mut ArrayHeader, + min_idx: i32, + max_idx_exclusive: i32, +) -> bool { + if !rebuild_array_numeric_raw_f64_allow_holes(arr) { + return false; + } + if array_has_raw_f64_layout_flag(arr) { + // Dense everywhere — no holes anywhere, window included. + return true; + } + let len = (*arr).length as i64; + let min = i64::from(min_idx).max(0); + let max = i64::from(max_idx_exclusive).min(len); + for i in min..max { + if array_slot_bits(arr, i as usize) == crate::value::TAG_HOLE { + return false; + } + } + true +} + +/// i32 tier of [`rebuild_array_numeric_raw_f64_dense_window`]: the window +/// must additionally hold only integers representable in a signed i32, so +/// the guarded loop's inline loads may materialize elements with a bare +/// `fptosi` (exact — no ToInt32 wrap tower) and keep bit-mixing chains like +/// bcrypt's Blowfish F in integer registers. +pub(crate) unsafe fn rebuild_array_numeric_raw_f64_dense_window_i32( + arr: *mut ArrayHeader, + min_idx: i32, + max_idx_exclusive: i32, +) -> bool { + if !rebuild_array_numeric_raw_f64_dense_window(arr, min_idx, max_idx_exclusive) { + return false; + } + let len = (*arr).length as i64; + let min = i64::from(min_idx).max(0); + let max = i64::from(max_idx_exclusive).min(len); + let elements = array_elements_ptr(arr) as *const f64; + for i in min..max { + let value = *elements.add(i as usize); + if !value.is_finite() + || value.fract() != 0.0 + || value < i32::MIN as f64 + || value > i32::MAX as f64 + { + return false; + } + } + true +} + #[inline] pub(crate) unsafe fn set_array_numeric_layout(arr: *mut ArrayHeader, layout: NumericArrayLayout) { if arr.is_null() { diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index 8b4dea22aa..bccc6b37f8 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -61,6 +61,7 @@ pub use self::generic_mutators::{ pub(crate) use self::header::{ array_has_arguments_object_flag, mark_array_as_arguments_object, prune_dead_array_named_property_owners, rebuild_array_numeric_raw_f64_allow_holes, + rebuild_array_numeric_raw_f64_dense_window, rebuild_array_numeric_raw_f64_dense_window_i32, }; pub use self::header::{ js_array_clear_numeric_layout, js_array_is_numeric_f64_layout, js_array_mark_arguments_object, diff --git a/crates/perry-runtime/src/typed_feedback.rs b/crates/perry-runtime/src/typed_feedback.rs index 341adbf142..3d6a0b40fc 100644 --- a/crates/perry-runtime/src/typed_feedback.rs +++ b/crates/perry-runtime/src/typed_feedback.rs @@ -1251,6 +1251,137 @@ fn packed_f64_array_loop_range_guard( } } +/// Dense-window variant of [`packed_f64_array_loop_range_guard`] for the +/// read-only masked-index range loop: identical shape/window validation, but +/// the window must additionally be hole-free (the guarded loop's inline loads +/// carry no hole check and no side exit — its multi-statement body cannot be +/// safely re-executed mid-iteration). +fn packed_f64_array_loop_range_guard_dense( + arr: *const ArrayHeader, + min_idx: i32, + max_idx_exclusive: i32, +) -> bool { + if !plain_array_index_guard(arr, 0, false) { + return false; + } + let raw_addr = normalize_raw_object_addr(arr as u64); + let Some(header) = gc_header_for_user_addr(raw_addr) else { + return false; + }; + unsafe { + let flags = (*header)._reserved; + if flags + & (crate::gc::OBJ_FLAG_FROZEN + | crate::gc::OBJ_FLAG_SEALED + | crate::gc::OBJ_FLAG_NO_EXTEND) + != 0 + { + return false; + } + let arr = raw_addr as *mut ArrayHeader; + let len = (*arr).length; + if len > i32::MAX as u32 { + return false; + } + if min_idx < 0 || i64::from(max_idx_exclusive) > i64::from(len) { + return false; + } + crate::array::rebuild_array_numeric_raw_f64_dense_window(arr, min_idx, max_idx_exclusive) + } +} + +/// i32 tier of [`packed_f64_array_loop_range_guard_dense`]: the window must +/// additionally hold only i32-representable integers, so the guarded loop's +/// inline loads can use a bare exact `fptosi`. +fn packed_f64_array_loop_range_guard_dense_i32( + arr: *const ArrayHeader, + min_idx: i32, + max_idx_exclusive: i32, +) -> bool { + if !packed_f64_array_loop_range_guard_dense(arr, min_idx, max_idx_exclusive) { + return false; + } + let raw_addr = normalize_raw_object_addr(arr as u64); + unsafe { + crate::array::rebuild_array_numeric_raw_f64_dense_window_i32( + raw_addr as *mut ArrayHeader, + min_idx, + max_idx_exclusive, + ) + } +} + +fn packed_f64_range_dense_guard_impl( + site_id: u64, + receiver: f64, + min_idx: i32, + max_idx_exclusive: i32, + guard: fn(*const ArrayHeader, i32, i32) -> bool, +) -> i32 { + let raw_addr = normalize_raw_object_addr(receiver.to_bits()); + if !typed_feedback_enabled() { + return guard(raw_addr as *const ArrayHeader, min_idx, max_idx_exclusive) as i32; + } + let (class_id, heap_type, aux, element_kind) = classify_array(raw_addr, None); + let observation = Observation { + source: ObservationSource::Array, + object_addr: 0, + shape_addr: 0, + key_hash: 0, + class_id, + heap_type, + aux, + value_tag: element_kind, + }; + let pass = guard_observe( + site_id, + TypedFeedbackSiteKind::ArrayElement, + observation, + guard(raw_addr as *const ArrayHeader, min_idx, max_idx_exclusive), + ); + if pass { + 1 + } else { + 0 + } +} + +/// FFI wrapper for [`packed_f64_array_loop_range_guard_dense`] — the entry +/// guard of the read-only masked-index packed-f64 range loop (f64 tier). +#[no_mangle] +pub extern "C" fn js_typed_feedback_packed_f64_range_loop_guard_dense( + site_id: u64, + receiver: f64, + min_idx: i32, + max_idx_exclusive: i32, +) -> i32 { + packed_f64_range_dense_guard_impl( + site_id, + receiver, + min_idx, + max_idx_exclusive, + packed_f64_array_loop_range_guard_dense, + ) +} + +/// FFI wrapper for [`packed_f64_array_loop_range_guard_dense_i32`] — the i32 +/// tier of the dense range-loop guard. +#[no_mangle] +pub extern "C" fn js_typed_feedback_packed_f64_range_loop_guard_dense_i32( + site_id: u64, + receiver: f64, + min_idx: i32, + max_idx_exclusive: i32, +) -> i32 { + packed_f64_range_dense_guard_impl( + site_id, + receiver, + min_idx, + max_idx_exclusive, + packed_f64_array_loop_range_guard_dense_i32, + ) +} + fn packed_i32_array_loop_guard(arr: *const ArrayHeader) -> bool { if !packed_f64_array_loop_guard(arr) { return false; From af2e705b3d395091b20a56a0d13a27e037c895d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 22 Jul 2026 09:06:27 +0200 Subject: [PATCH 2/3] refactor(codegen): split masked-window load helpers into expr/masked_window.rs The masked-window fact consult + the two load-emission tiers pushed expr/index_get.rs past the 2000-line lint cap (check_file_size.sh); move them to a topical sibling module. Pure move plus factoring the shared raw in-window load emission; no behavior change (benchmarks and the edge-case suite are byte-identical before/after). --- .../perry-codegen/src/expr/i32_fast_path.rs | 6 +- crates/perry-codegen/src/expr/index_get.rs | 168 +---------------- .../perry-codegen/src/expr/masked_window.rs | 174 ++++++++++++++++++ crates/perry-codegen/src/expr/mod.rs | 1 + 4 files changed, 182 insertions(+), 167 deletions(-) create mode 100644 crates/perry-codegen/src/expr/masked_window.rs diff --git a/crates/perry-codegen/src/expr/i32_fast_path.rs b/crates/perry-codegen/src/expr/i32_fast_path.rs index 3725e66a88..cdfa7b8d49 100644 --- a/crates/perry-codegen/src/expr/i32_fast_path.rs +++ b/crates/perry-codegen/src/expr/i32_fast_path.rs @@ -69,7 +69,7 @@ fn known_finite_magnitude_bits(ctx: &FnCtx<'_>, e: &Expr) -> Option { // range guard proved every window value is an i32 integer). Expr::IndexGet { object, index } if ta_int_elem_load_is_i32_provable(ctx, object, index) - || super::index_get::masked_window_i32_load_is_provable(ctx, object, index) => + || super::masked_window::masked_window_i32_load_is_provable(ctx, object, index) => { Some(32) } @@ -544,7 +544,7 @@ pub(crate) fn can_lower_expr_as_i32_in_current_region(ctx: &FnCtx<'_>, e: &Expr) } Expr::IndexGet { object, index } => { ta_int_elem_load_is_i32_provable(ctx, object, index) - || super::index_get::masked_window_i32_load_is_provable(ctx, object, index) + || super::masked_window::masked_window_i32_load_is_provable(ctx, object, index) } _ => false, } @@ -762,7 +762,7 @@ fn try_lower_expr_native_i32_structural(ctx: &mut FnCtx<'_>, e: &Expr) -> Result super::lower_typed_array_load(ctx, object, index)? .map(|lowered| i32_from_indexed_get_lowered(ctx, lowered)) } else { - super::index_get::lower_masked_window_index_get_i32(ctx, object, index)? + super::masked_window::lower_masked_window_index_get_i32(ctx, object, index)? } } _ => None, diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index c6a83688ef..2dd99c8492 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -135,168 +135,6 @@ fn packed_f64_loop_fact_for_index( Some((fact, idx_id, offset)) } -/// Look up an active masked-window fact for `(arr, index-expr)`: the index's -/// static value window (`collectors::static_index_window` — the same function -/// the range-loop matcher used, so match-time and lowering-time agree) must -/// sit inside a window the dense range guard validated for this array in the -/// current fast-loop scope. -fn masked_window_fact_for_index( - ctx: &FnCtx<'_>, - arr_id: u32, - index: &Expr, -) -> Option { - let (lo, hi) = crate::collectors::static_index_window(index)?; - ctx.masked_window_array_facts - .iter() - .rev() - .find(|fact| { - fact.array_local_id == arr_id && lo >= fact.min_idx && hi < fact.max_idx_exclusive - }) - .cloned() -} - -/// Emit the raw in-window element load for a masked-window fact: the dense -/// range guard already proved a plain raw-f64 numeric array with every slot -/// in `[min_idx, max_idx_exclusive)` an in-bounds number (no holes), so the -/// load is a bare `header + 8 + idx*8` f64 read — no guard call, no hole -/// check, no side exit. -fn lower_masked_window_index_get( - ctx: &mut FnCtx<'_>, - arr_id: u32, - arr_box: &str, - idx_i32: &str, - fact: &super::MaskedWindowArrayFact, -) -> String { - let value = { - let blk = ctx.block(); - let arr_bits = blk.bitcast_double_to_i64(arr_box); - let arr_handle = blk.and(I64, &arr_bits, POINTER_MASK_I64); - let idx_i64 = blk.zext(I32, idx_i32, I64); - let byte_offset = blk.shl(I64, &idx_i64, "3"); - let with_header = blk.add(I64, &byte_offset, "8"); - let element_addr = blk.add(I64, &arr_handle, &with_header); - let element_ptr = blk.inttoptr(I64, &element_addr); - blk.load(DOUBLE, &element_ptr) - }; - let lowered = LoweredValue { - semantic: SemanticKind::JsNumber, - rep: NativeRep::F64, - llvm_ty: DOUBLE, - value: value.clone(), - }; - ctx.record_lowered_value_with_access_mode_and_facts( - "NumericArrayIndexGet", - Some(arr_id), - "packed_f64_masked_window_load", - &lowered, - Some(BoundsState::Guarded { - guard_id: fact.guard_id.clone(), - }), - None, - Some(BufferAccessMode::CheckedNative), - None, - None, - None, - vec![raw_f64_layout_fact( - Some(arr_id), - "consumed", - &fact.guard_id, - None, - )], - Vec::new(), - false, - false, - vec![ - "index_range=static_window_guarded".to_string(), - "length_range=guarded_i32".to_string(), - "storage_layout=raw_f64_numeric_slots".to_string(), - ], - ); - value -} - -/// True when `object[index]` matches an active i32-tier masked-window fact — -/// the dense-i32 range guard proved every window slot is an i32-representable -/// integer, so the load can produce a native `i32` with a bare exact `fptosi`. -pub(crate) fn masked_window_i32_load_is_provable( - ctx: &FnCtx<'_>, - object: &Expr, - index: &Expr, -) -> bool { - let Expr::LocalGet(arr_id) = object else { - return false; - }; - masked_window_fact_for_index(ctx, *arr_id, index).is_some_and(|fact| fact.values_i32) -} - -/// i32-tier masked-window load: raw in-window f64 element load + bare -/// `fptosi` (exact — the dense-i32 guard proved the value is an i32 integer). -/// Returns `None` when no i32-tier fact covers the access. -pub(crate) fn lower_masked_window_index_get_i32( - ctx: &mut FnCtx<'_>, - object: &Expr, - index: &Expr, -) -> Result> { - let Expr::LocalGet(arr_id) = object else { - return Ok(None); - }; - let Some(fact) = - masked_window_fact_for_index(ctx, *arr_id, index).filter(|fact| fact.values_i32) - else { - return Ok(None); - }; - let arr_box = lower_expr(ctx, object)?; - let idx_i32 = lower_expr_as_i32(ctx, index)?; - let raw_f64 = { - let blk = ctx.block(); - let arr_bits = blk.bitcast_double_to_i64(&arr_box); - let arr_handle = blk.and(I64, &arr_bits, POINTER_MASK_I64); - let idx_i64 = blk.zext(I32, &idx_i32, I64); - let byte_offset = blk.shl(I64, &idx_i64, "3"); - let with_header = blk.add(I64, &byte_offset, "8"); - let element_addr = blk.add(I64, &arr_handle, &with_header); - let element_ptr = blk.inttoptr(I64, &element_addr); - blk.load(DOUBLE, &element_ptr) - }; - let value = ctx.block().fptosi(DOUBLE, &raw_f64, I32); - let lowered = LoweredValue { - semantic: SemanticKind::JsNumber, - rep: NativeRep::I32, - llvm_ty: I32, - value: value.clone(), - }; - ctx.record_lowered_value_with_access_mode_and_facts( - "NumericArrayIndexGet", - Some(*arr_id), - "packed_f64_masked_window_load_i32", - &lowered, - Some(BoundsState::Guarded { - guard_id: fact.guard_id.clone(), - }), - None, - Some(BufferAccessMode::CheckedNative), - None, - None, - None, - vec![raw_f64_layout_fact( - Some(*arr_id), - "consumed", - &fact.guard_id, - None, - )], - Vec::new(), - false, - false, - vec![ - "index_range=static_window_guarded".to_string(), - "length_range=guarded_i32".to_string(), - "storage_layout=raw_f64_numeric_slots".to_string(), - "integer_materialization=fptosi_guarded_dense_i32".to_string(), - ], - ); - Ok(Some(value)) -} - /// Load the packed-loop counter's i32 shadow slot and apply the constant /// index offset. fn load_packed_loop_index_i32(ctx: &mut FnCtx<'_>, i32_slot: &str, offset: i32) -> String { @@ -1120,10 +958,12 @@ pub(crate) fn lower_numeric_index_get_for_number_context( // layout proof below nor a per-access guard. The fact can only exist for // a range-loop-eligible binding (never scalar-replaced or aliased). if let Expr::LocalGet(arr_id) = object.as_ref() { - if let Some(fact) = masked_window_fact_for_index(ctx, *arr_id, index.as_ref()) { + if let Some(fact) = + super::masked_window::masked_window_fact_for_index(ctx, *arr_id, index.as_ref()) + { let arr_box = lower_expr(ctx, object)?; let idx_i32 = lower_expr_as_i32(ctx, index)?; - return Ok(Some(lower_masked_window_index_get( + return Ok(Some(super::masked_window::lower_masked_window_index_get( ctx, *arr_id, &arr_box, &idx_i32, &fact, ))); } diff --git a/crates/perry-codegen/src/expr/masked_window.rs b/crates/perry-codegen/src/expr/masked_window.rs new file mode 100644 index 0000000000..0c2998f866 --- /dev/null +++ b/crates/perry-codegen/src/expr/masked_window.rs @@ -0,0 +1,174 @@ +//! Masked-window array-read lowering for the dense packed-f64 range loop. +//! +//! The dense range guard (`js_typed_feedback_packed_f64_range_loop_guard_dense` +//! / `_dense_i32`, see `stmt/loops.rs`) validates a whole static index window +//! `[min_idx, max_idx_exclusive)` of a plain raw-f64 numeric array at loop +//! entry — hole-free, so in-window reads need no guard call, no hole check, +//! and no side exit. The helpers here consult the per-scope +//! [`MaskedWindowArrayFact`]s that guard establishes and emit the bare +//! in-window element loads (`S[x & 1023]`, `S[256 + ((x >>> 16) & 0xff)]` — +//! the bcryptjs Blowfish S-box shapes). + +use anyhow::Result; +use perry_hir::Expr; + +use crate::nanbox::POINTER_MASK_I64; +use crate::native_value::{BoundsState, BufferAccessMode, LoweredValue, NativeRep, SemanticKind}; +use crate::types::{DOUBLE, I32, I64}; + +use super::{lower_expr, lower_expr_as_i32, raw_f64_layout_fact, FnCtx, MaskedWindowArrayFact}; + +/// Look up an active masked-window fact for `(arr, index-expr)`: the index's +/// static value window (`collectors::static_index_window` — the same function +/// the range-loop matcher used, so match-time and lowering-time agree) must +/// sit inside a window the dense range guard validated for this array in the +/// current fast-loop scope. +pub(crate) fn masked_window_fact_for_index( + ctx: &FnCtx<'_>, + arr_id: u32, + index: &Expr, +) -> Option { + let (lo, hi) = crate::collectors::static_index_window(index)?; + ctx.masked_window_array_facts + .iter() + .rev() + .find(|fact| { + fact.array_local_id == arr_id && lo >= fact.min_idx && hi < fact.max_idx_exclusive + }) + .cloned() +} + +/// Emit the raw in-window f64 element load shared by both tiers: +/// `header + 8 + idx * 8` on the pointer-masked array handle. +fn emit_raw_window_load(ctx: &mut FnCtx<'_>, arr_box: &str, idx_i32: &str) -> String { + let blk = ctx.block(); + let arr_bits = blk.bitcast_double_to_i64(arr_box); + let arr_handle = blk.and(I64, &arr_bits, POINTER_MASK_I64); + let idx_i64 = blk.zext(I32, idx_i32, I64); + let byte_offset = blk.shl(I64, &idx_i64, "3"); + let with_header = blk.add(I64, &byte_offset, "8"); + let element_addr = blk.add(I64, &arr_handle, &with_header); + let element_ptr = blk.inttoptr(I64, &element_addr); + blk.load(DOUBLE, &element_ptr) +} + +/// Emit the raw in-window element load for a masked-window fact: the dense +/// range guard already proved a plain raw-f64 numeric array with every slot +/// in `[min_idx, max_idx_exclusive)` an in-bounds number (no holes), so the +/// load is a bare f64 read — no guard call, no hole check, no side exit. +pub(crate) fn lower_masked_window_index_get( + ctx: &mut FnCtx<'_>, + arr_id: u32, + arr_box: &str, + idx_i32: &str, + fact: &MaskedWindowArrayFact, +) -> String { + let value = emit_raw_window_load(ctx, arr_box, idx_i32); + let lowered = LoweredValue { + semantic: SemanticKind::JsNumber, + rep: NativeRep::F64, + llvm_ty: DOUBLE, + value: value.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "NumericArrayIndexGet", + Some(arr_id), + "packed_f64_masked_window_load", + &lowered, + Some(BoundsState::Guarded { + guard_id: fact.guard_id.clone(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + vec![raw_f64_layout_fact( + Some(arr_id), + "consumed", + &fact.guard_id, + None, + )], + Vec::new(), + false, + false, + vec![ + "index_range=static_window_guarded".to_string(), + "length_range=guarded_i32".to_string(), + "storage_layout=raw_f64_numeric_slots".to_string(), + ], + ); + value +} + +/// True when `object[index]` matches an active i32-tier masked-window fact — +/// the dense-i32 range guard proved every window slot is an i32-representable +/// integer, so the load can produce a native `i32` with a bare exact `fptosi`. +pub(crate) fn masked_window_i32_load_is_provable( + ctx: &FnCtx<'_>, + object: &Expr, + index: &Expr, +) -> bool { + let Expr::LocalGet(arr_id) = object else { + return false; + }; + masked_window_fact_for_index(ctx, *arr_id, index).is_some_and(|fact| fact.values_i32) +} + +/// i32-tier masked-window load: raw in-window f64 element load + bare +/// `fptosi` (exact — the dense-i32 guard proved the value is an i32 integer). +/// Returns `None` when no i32-tier fact covers the access. +pub(crate) fn lower_masked_window_index_get_i32( + ctx: &mut FnCtx<'_>, + object: &Expr, + index: &Expr, +) -> Result> { + let Expr::LocalGet(arr_id) = object else { + return Ok(None); + }; + let Some(fact) = + masked_window_fact_for_index(ctx, *arr_id, index).filter(|fact| fact.values_i32) + else { + return Ok(None); + }; + let arr_box = lower_expr(ctx, object)?; + let idx_i32 = lower_expr_as_i32(ctx, index)?; + let raw_f64 = emit_raw_window_load(ctx, &arr_box, &idx_i32); + let value = ctx.block().fptosi(DOUBLE, &raw_f64, I32); + let lowered = LoweredValue { + semantic: SemanticKind::JsNumber, + rep: NativeRep::I32, + llvm_ty: I32, + value: value.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "NumericArrayIndexGet", + Some(*arr_id), + "packed_f64_masked_window_load_i32", + &lowered, + Some(BoundsState::Guarded { + guard_id: fact.guard_id.clone(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + vec![raw_f64_layout_fact( + Some(*arr_id), + "consumed", + &fact.guard_id, + None, + )], + Vec::new(), + false, + false, + vec![ + "index_range=static_window_guarded".to_string(), + "length_range=guarded_i32".to_string(), + "storage_layout=raw_f64_numeric_slots".to_string(), + "integer_materialization=fptosi_guarded_dense_i32".to_string(), + ], + ); + Ok(Some(value)) +} diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index a5d39d505c..35ebb05eb6 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -1336,6 +1336,7 @@ mod dyn_extern_i18n; mod env_clones; mod fs_await; mod index_get; +mod masked_window; pub(crate) use index_get::packed_f64_loop_index_parts; mod index_set; mod instance_misc1; From cf0cad88aa8bb580c79edfa49ba607e941b8e0a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 22 Jul 2026 09:13:18 +0200 Subject: [PATCH 3/3] fix(codegen): rewire the generic-lower masked-window consult to the split module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module split in the previous commit missed the second consult site (the generic lower IndexGet arm) — it still called the moved functions unqualified and the crate did not compile. Verified with a from-scratch build this time; benchmarks and the edge suite are unchanged. --- crates/perry-codegen/src/expr/index_get.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index 2dd99c8492..e034146767 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -1636,10 +1636,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { )); } } - if let Some(fact) = masked_window_fact_for_index(ctx, *arr_id, index.as_ref()) { + if let Some(fact) = super::masked_window::masked_window_fact_for_index( + ctx, + *arr_id, + index.as_ref(), + ) { let arr_box = lower_expr(ctx, object)?; let idx_i32 = lower_expr_as_i32(ctx, index)?; - return Ok(lower_masked_window_index_get( + return Ok(super::masked_window::lower_masked_window_index_get( ctx, *arr_id, &arr_box, &idx_i32, &fact, )); }