diff --git a/changelog.d/7296-index-range-proof.md b/changelog.d/7296-index-range-proof.md new file mode 100644 index 0000000000..baef79f86e --- /dev/null +++ b/changelog.d/7296-index-range-proof.md @@ -0,0 +1,59 @@ +### Fixed + +- **Array indexing: prove ranges for strided counters and constant-argument parameters (#7286).** + `numeric_index_needs_runtime_key` (`crates/perry-codegen/src/expr/index_get.rs`) demotes an + array read *and* write to the fully opaque `js_array_get_index_or_string` / + `js_typed_feedback_array_set_index_or_string` runtime-key helpers unless the index carries a + `[0, i32::MAX]` range proof — no inline header test, no inline load, nothing LLVM can see + through. Two ubiquitous shapes could not produce one: + + * **Strided induction counters.** Only `i++` with an integer-literal start was classified, so + `for (let j = i * i; j < LIMIT; j = j + i)` — the whole inner loop of `11_prime_sieve`, + ~2.12M stores in its timed region — was demoted. + * **Numeric parameters.** A parameter arrives as a bare NaN-boxed `double` with no fact + attached, and ranges compose through `+`/`*`, so one unbounded leaf poisons the whole index + expression: `matmul(a, b, c, size: number)`'s `size` alone sank all three arrays in + `16_matrix_multiply` (4 opaque calls per innermost iteration × 256³ = 67.1M calls). + + New `crates/perry-codegen/src/stmt/counter_range.rs` proves a **monotone-induction range**: a + counter whose only writer is the loop's own update slot and whose step never decreases it is + bounded above by the guard (re-evaluated at every body entry) and below by its initial value. + It admits `j = j + ` alongside `i++`, requiring a non-negative *integral* start, a + non-negative *integral* stride, and the stride's operands loop-invariant and not + closure-captured. It also now refuses a fact when the loop **body** writes the counter — a + hole the old `i++` path had, since `for (let i = 0; i < 10; i++) { a[i]; i = -5; }` re-enters + the body with `i === -4` while the fact still claimed `[0, 9]`. + + New `crates/perry-codegen/src/collectors/param_ranges.rs` derives **interprocedural range + summaries** for numeric parameters: a meet of the argument constants over *every* call site + of a function whose entire call graph is visible in the module. Any unresolved reference + poisons it — exported, reflected onto `globalThis`, a `FuncRef` used as a value, an + `Expr::Closure` sharing the id, an arity mismatch, a rest/default/`arguments` parameter, a + parameter written or rebound anywhere at any depth, a duplicated `FuncId`, or one + non-constant argument. The summary feeds `int_range_for_local` as a last resort, which is + what lets the *existing* affine `a * b + c` composition in `int_range_expr` finally fire: + `i * size + k` proves `[0, 65535]` once `size` is pinned to 256. + + Measured on an M1 Max (release build both arms, identical runtime archives, + `PERRY_NO_AUTO_OPTIMIZE=1`), with byte-identical checksums: `16_matrix_multiply` + **693 → 70 ms (9.9×)** and `11_prime_sieve` **118 → 31 ms (3.8×)**, taking + `16_matrix_multiply` from 17.3× behind Node to 1.8×. `15_mandelbrot` (22 ms) and + `05_fibonacci` (407 ms) are unchanged, + and all 30 suite benchmarks produce identical output. In the IR, `matmul`'s innermost block + goes from two `js_array_get_index_or_string` + two `js_number_coerce` calls to **zero `js_*` + calls** — every remaining helper sits on a cold guard/fallback edge — and `prime_sieve`'s + `sieve[j] = false` loses both the opaque store helper and the array-pointer re-anchoring + that followed it. + + This is deliberately **not** about the index being an `i32`: `(i * size + k) | 0` *is* a + genuine `i32` and buys nothing, because `ToInt32`'s `[-2^31, 2^31-1]` has `min < 0`. What + was missing is non-negativity plus an upper bound. The `& 0x7fffffff` mask used to measure + the prize in #7286 is a diagnostic device only and is not shipped — it changes semantics for + negative and `>= 2^31` indices. + + Covered by `test-files/test_gap_array_index_range_proof.ts` (16 cases, byte-identical to Node + 26.5.1: negative / fractional / `NaN` / `-0` indices, `2^32-2` vs `2^32-1` vs `2^32`, indices + above `i32::MAX`, an affine index that overflows `i32`, holey and sparse arrays, a body write + to the counter, a body write to the stride, a callee that reassigns its parameter, a callee + that escapes as a value, a non-integral stride, and an `+Infinity` start) plus 23 new + `cargo-test`-visible `--lib` unit tests for the two admission sets. diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index b20e1d389a..e1ea96807d 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -1039,6 +1039,7 @@ pub(super) fn compile_closure( clamp_u8_functions: &cross_module.clamp_u8_functions, integer_returning_functions: &cross_module.returns_int_functions, i32_identity_functions: &cross_module.i32_identity_functions, + param_int_ranges: &cross_module.param_int_ranges, typed_f64_functions: &cross_module.typed_f64_functions, typed_i32_functions: &cross_module.typed_i32_functions, typed_string_functions: &cross_module.typed_string_functions, diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index be3605438f..4ca37d538f 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -855,6 +855,7 @@ pub(super) fn compile_module_entry( clamp_u8_functions: &cross_module.clamp_u8_functions, integer_returning_functions: &cross_module.returns_int_functions, i32_identity_functions: &cross_module.i32_identity_functions, + param_int_ranges: &cross_module.param_int_ranges, typed_f64_functions: &cross_module.typed_f64_functions, typed_i32_functions: &cross_module.typed_i32_functions, typed_string_functions: &cross_module.typed_string_functions, @@ -1517,6 +1518,7 @@ pub(super) fn compile_module_entry( clamp_u8_functions: &cross_module.clamp_u8_functions, integer_returning_functions: &cross_module.returns_int_functions, i32_identity_functions: &cross_module.i32_identity_functions, + param_int_ranges: &cross_module.param_int_ranges, typed_f64_functions: &cross_module.typed_f64_functions, typed_i32_functions: &cross_module.typed_i32_functions, typed_string_functions: &cross_module.typed_string_functions, diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index b755bf5b10..52a210ed6c 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -838,6 +838,7 @@ pub(super) fn compile_function( clamp_u8_functions: &cross_module.clamp_u8_functions, integer_returning_functions: &cross_module.returns_int_functions, i32_identity_functions: &cross_module.i32_identity_functions, + param_int_ranges: &cross_module.param_int_ranges, typed_f64_functions: &cross_module.typed_f64_functions, typed_i32_functions: &cross_module.typed_i32_functions, typed_string_functions: &cross_module.typed_string_functions, diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index c56f1a2ab8..61bd43b43e 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -570,6 +570,7 @@ pub(super) fn compile_method( clamp_u8_functions: &cross_module.clamp_u8_functions, integer_returning_functions: &cross_module.returns_int_functions, i32_identity_functions: &cross_module.i32_identity_functions, + param_int_ranges: &cross_module.param_int_ranges, typed_f64_functions: &cross_module.typed_f64_functions, typed_i32_functions: &cross_module.typed_i32_functions, typed_string_functions: &cross_module.typed_string_functions, @@ -1626,6 +1627,7 @@ pub(super) fn compile_static_method( clamp_u8_functions: &cross_module.clamp_u8_functions, integer_returning_functions: &cross_module.returns_int_functions, i32_identity_functions: &cross_module.i32_identity_functions, + param_int_ranges: &cross_module.param_int_ranges, typed_f64_functions: &cross_module.typed_f64_functions, typed_i32_functions: &cross_module.typed_i32_functions, typed_string_functions: &cross_module.typed_string_functions, diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index c1b739c641..d192c9b864 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -1173,6 +1173,12 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> } } + // #7286 lever (c): interprocedural integer ranges for numeric function + // parameters, computed once per module from the same folded top-level + // `const` map the call-site arguments resolve through. + let param_int_ranges_summary = + crate::collectors::collect_param_int_ranges(hir, &compile_time_constants); + // Issue #235: per-method explicit-param-count map covering BOTH local // classes (from `hir.classes`) AND imported classes (from // `opts.imported_classes`). Every method-call dispatch site in @@ -1707,6 +1713,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> .filter(|f| crate::collectors::returns_i32_identity_arg(f)) .map(|f| f.id) .collect(), + param_int_ranges: param_int_ranges_summary, // Phase 2 spec-ABI plans are selected AFTER the i64-specialization // pass (mutual exclusion), below; start empty here. spec_abi_functions: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index 293d351ef4..69306eed32 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -737,6 +737,13 @@ pub(crate) struct CrossModuleCtx { pub returns_int_functions: std::collections::HashSet, /// Single-argument integer helpers that return the argument coerced to i32. pub i32_identity_functions: std::collections::HashSet, + /// #7286 lever (c): LocalId of a numeric function parameter → the + /// meet-over-all-call-sites integer range proven for it. Seeds + /// `int_range_expr`, which is what lets `a[i * size + k]` in + /// `16_matrix_multiply` leave the opaque runtime-key helper. Populated + /// only for functions whose entire call graph is visible in this module + /// (see `collectors/param_ranges.rs` for the proof obligations). + pub param_int_ranges: crate::collectors::ParamIntRanges, /// Representation-selection Phase 2 (`codegen/spec_abi.rs`): FuncId → /// specialization plan for functions with an emitted full-body specialized /// entry (internal linkage, named by `spec_function_name`). Mutually diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index 5aeb8d6ea7..4e217c28fb 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -23,6 +23,7 @@ mod local_refs; mod loop_bounded_i32; mod mutation; mod not_bigint_locals; +mod param_ranges; mod pointer_locals; mod proven_this; #[cfg(test)] @@ -66,6 +67,7 @@ pub(crate) use integer_locals::{ }; 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 param_ranges::{collect_param_int_ranges, ParamIntRanges}; pub(crate) use pointer_locals::collect_pointer_typed_locals; pub(crate) use proven_this::{ method_proven_this, prune_colliding_clones, pshape_method_name, diff --git a/crates/perry-codegen/src/collectors/param_ranges.rs b/crates/perry-codegen/src/collectors/param_ranges.rs new file mode 100644 index 0000000000..f3c9de174b --- /dev/null +++ b/crates/perry-codegen/src/collectors/param_ranges.rs @@ -0,0 +1,594 @@ +//! Interprocedural integer-range summaries for numeric function parameters +//! (#7286, lever (c)). +//! +//! `numeric_index_has_integer_array_index_proof` admits an array index only +//! when its range is provably inside `[0, i32::MAX]`. Ranges compose through +//! `+`/`*`, so **one unbounded leaf poisons the whole index expression** — and +//! a numeric *parameter* is always unbounded, because it arrives as a bare +//! NaN-boxed `double` with no fact attached. In `16_matrix_multiply`, +//! `matmul(a, b, c, size: number)` is exactly that: `size` has no range, so +//! `a[i * size + k]`, `b[k * size + j]` and `c[i * size + j]` all fall to the +//! opaque `js_array_get_index_or_string` / +//! `js_typed_feedback_array_set_index_or_string` helpers — 67.1M calls at +//! 256³. +//! +//! This pre-pass closes that hole for the narrow case where the whole call +//! graph of a parameter is visible and every caller passes an integer +//! constant. It is a **meet over all call sites**, and *any* unresolved +//! reference to the function poisons it entirely. +//! +//! # Proof obligations +//! +//! A `(function, parameter)` pair earns a range only when all of these hold: +//! +//! 1. **Every call is visible.** The function is not exported (so no other +//! module can call it), is never reflected onto the global object, and its +//! `FuncId` appears *only* as the callee of a direct `Expr::Call`. Any +//! other occurrence — a bare `Expr::FuncRef` used as a value, an +//! `Expr::Closure` with the same id, a `CallSpread` whose arity is unknown +//! — poisons the function. +//! 2. **Arity is fixed.** No rest parameter, no default value, no `arguments` +//! object, and every call site passes exactly `params.len()` arguments. +//! Otherwise the slot can hold `undefined`, whose "range" is nothing. +//! 3. **The parameter is never rebound.** It is never a `LocalSet` / `Update` +//! target and never re-declared by a body `Stmt::Let` (`var` hoisting +//! reuses a parameter's id for `function f(x) { var x = … }`), at any depth +//! including inside closure bodies. +//! 4. **Every argument is an integer constant** — a literal, or a read of a +//! top-level `const` already folded into `compile_time_constants`. +//! +//! Under-approximation is free: a missing summary just keeps today's +//! runtime-key helper, which is the correct answer, not a failure. + +use std::collections::{HashMap, HashSet}; + +use perry_hir::{Expr, Function, Module, Param, Stmt}; + +use crate::expr::IntRange; + +/// LocalId of a parameter → its meet-over-call-sites integer range. +pub(crate) type ParamIntRanges = HashMap; + +#[derive(Default)] +struct Scan { + /// FuncIds that can be entered by something other than a visible direct + /// call, so their argument set is not knowable. + poisoned: HashSet, + /// FuncId → per-argument-position constant, one entry per direct call + /// site. `None` where the argument is not an integer constant. + call_sites: HashMap>>>, + /// Locals written (`LocalSet` / `GlobalSet` / `Update`) at any depth. + writes: HashSet, + /// Locals re-declared by a `Stmt::Let` at any depth. + rebinds: HashSet, +} + +fn constant_arg(expr: &Expr, module_constants: &HashMap) -> Option { + let value = match expr { + Expr::Integer(n) => return Some(*n), + Expr::Number(n) => *n, + Expr::LocalGet(id) => *module_constants.get(id)?, + _ => return None, + }; + if !value.is_finite() || value.fract() != 0.0 { + return None; + } + let min = i64::MIN as f64; + let max = i64::MAX as f64; + (value >= min && value <= max).then(|| value as i64) +} + +fn visit_expr(expr: &Expr, module_constants: &HashMap, scan: &mut Scan) { + match expr { + Expr::Call { callee, args, .. } => { + if let Expr::FuncRef(fid) = callee.as_ref() { + let site: Vec> = args + .iter() + .map(|arg| constant_arg(arg, module_constants)) + .collect(); + scan.call_sites.entry(*fid).or_default().push(site); + for arg in args { + visit_expr(arg, module_constants, scan); + } + return; + } + } + // A `FuncRef` anywhere other than a direct callee is the function + // escaping as a value: it can be stored, passed, `.call`ed, or + // returned, and then invoked with arguments this pass cannot see. + Expr::FuncRef(fid) => { + scan.poisoned.insert(*fid); + return; + } + Expr::Closure { + func_id, + params, + body, + .. + } => { + scan.poisoned.insert(*func_id); + // `hir.functions` also carries nested closures, so this body is + // normally walked twice — but do not depend on that flattening + // invariant for a soundness property. A write to a captured + // parameter through a closure has to reach `scan.writes`. + visit_params(params, module_constants, scan); + visit_stmts(body, module_constants, scan); + } + Expr::LocalSet(id, _) | Expr::GlobalSet(id, _) | Expr::Update { id, .. } => { + scan.writes.insert(*id); + } + _ => {} + } + perry_hir::walker::walk_expr_children(expr, &mut |child| { + visit_expr(child, module_constants, scan) + }); +} + +fn visit_stmts(stmts: &[Stmt], module_constants: &HashMap, scan: &mut Scan) { + for stmt in stmts { + visit_stmt(stmt, module_constants, scan); + } +} + +fn visit_stmt(stmt: &Stmt, module_constants: &HashMap, scan: &mut Scan) { + match stmt { + Stmt::Let { id, init, .. } => { + scan.rebinds.insert(*id); + if let Some(init) = init { + visit_expr(init, module_constants, scan); + } + } + Stmt::Expr(e) | Stmt::Throw(e) | Stmt::Return(Some(e)) => { + visit_expr(e, module_constants, scan) + } + Stmt::Return(None) + | Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) => {} + Stmt::If { + condition, + then_branch, + else_branch, + } => { + visit_expr(condition, module_constants, scan); + visit_stmts(then_branch, module_constants, scan); + if let Some(body) = else_branch { + visit_stmts(body, module_constants, scan); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + visit_expr(condition, module_constants, scan); + visit_stmts(body, module_constants, scan); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(init) = init { + visit_stmt(init, module_constants, scan); + } + if let Some(condition) = condition { + visit_expr(condition, module_constants, scan); + } + if let Some(update) = update { + visit_expr(update, module_constants, scan); + } + visit_stmts(body, module_constants, scan); + } + Stmt::Labeled { body, .. } => visit_stmt(body, module_constants, scan), + Stmt::Try { + body, + catch, + finally, + } => { + visit_stmts(body, module_constants, scan); + if let Some(catch) = catch { + if let Some((id, _)) = &catch.param { + scan.rebinds.insert(*id); + } + visit_stmts(&catch.body, module_constants, scan); + } + if let Some(body) = finally { + visit_stmts(body, module_constants, scan); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + visit_expr(discriminant, module_constants, scan); + for case in cases { + if let Some(test) = &case.test { + visit_expr(test, module_constants, scan); + } + visit_stmts(&case.body, module_constants, scan); + } + } + } +} + +fn visit_params(params: &[Param], module_constants: &HashMap, scan: &mut Scan) { + for param in params { + if let Some(default) = ¶m.default { + visit_expr(default, module_constants, scan); + } + } +} + +/// Callee-side admission: fixed arity, no `arguments` object, and a shape +/// whose entry is only ever the direct-call path. +fn function_shape_is_summarizable(f: &Function) -> bool { + !f.is_exported + && !f.is_async + && !f.is_generator + && !f.params.is_empty() + && f.params + .iter() + .all(|p| !p.is_rest && p.default.is_none() && p.arguments_object.is_none()) +} + +/// Meet the argument constants seen at every call site of every summarizable +/// function into a per-parameter [`IntRange`]. +pub(crate) fn collect_param_int_ranges( + hir: &Module, + module_constants: &HashMap, +) -> ParamIntRanges { + let mut scan = Scan::default(); + visit_stmts(&hir.init, module_constants, &mut scan); + for f in &hir.functions { + visit_params(&f.params, module_constants, &mut scan); + visit_stmts(&f.body, module_constants, &mut scan); + } + for c in &hir.classes { + for f in c + .constructor + .iter() + .chain(c.methods.iter()) + .chain(c.static_methods.iter()) + .chain(c.getters.iter().map(|(_, g)| g)) + .chain(c.setters.iter().map(|(_, s)| s)) + .chain(c.computed_members.iter().map(|cm| &cm.function)) + { + visit_params(&f.params, module_constants, &mut scan); + visit_stmts(&f.body, module_constants, &mut scan); + } + for cm in &c.computed_members { + visit_expr(&cm.key_expr, module_constants, &mut scan); + } + for field in c.fields.iter().chain(c.static_fields.iter()) { + if let Some(key) = &field.key_expr { + visit_expr(key, module_constants, &mut scan); + } + if let Some(init) = &field.init { + visit_expr(init, module_constants, &mut scan); + } + } + } + + // A Script's top-level `function` declarations become own properties of + // the global object when the program mentions `globalThis`, at which point + // `globalThis.f(…)` is a call site this pass cannot enumerate. Mirrors the + // exact condition codegen reflects under (`codegen/entry.rs`). + if hir.references_global_this { + for (_, fid) in &hir.script_global_functions { + scan.poisoned.insert(*fid); + } + } + + // `Function::is_exported` is only set by `export function f() {}`. The + // `export { f }` alias path records the export in `exported_functions` + // WITHOUT flipping that flag, and an exported function is callable from a + // module this per-module pass never sees. + for (_, fid) in &hir.exported_functions { + scan.poisoned.insert(*fid); + } + let exported_names: HashSet<&str> = hir + .exports + .iter() + .filter_map(|export| match export { + perry_hir::Export::Named { local, .. } => Some(local.as_str()), + _ => None, + }) + .collect(); + + // `specialize.rs` copies `f.id` verbatim when monomorphizing, so a FuncId + // is not guaranteed unique. Two entries sharing an id would meet each + // other's call sites against the wrong parameter list. + let mut func_id_counts: HashMap = HashMap::new(); + for f in &hir.functions { + *func_id_counts.entry(f.id).or_default() += 1; + } + + let mut ranges = ParamIntRanges::new(); + for f in &hir.functions { + if scan.poisoned.contains(&f.id) + || exported_names.contains(f.name.as_str()) + || func_id_counts.get(&f.id).copied() != Some(1) + || !function_shape_is_summarizable(f) + { + continue; + } + let Some(sites) = scan.call_sites.get(&f.id) else { + continue; + }; + if sites.is_empty() || sites.iter().any(|site| site.len() != f.params.len()) { + continue; + } + for (idx, param) in f.params.iter().enumerate() { + if scan.writes.contains(¶m.id) || scan.rebinds.contains(¶m.id) { + continue; + } + let mut min = i64::MAX; + let mut max = i64::MIN; + let mut proven = true; + for site in sites { + match site[idx] { + Some(value) => { + min = min.min(value); + max = max.max(value); + } + None => { + proven = false; + break; + } + } + } + if proven { + ranges.insert(param.id, IntRange { min, max }); + } + } + } + ranges +} + +#[cfg(test)] +mod tests { + use super::*; + use perry_hir::types::Type; + + const SIZE_CONST: u32 = 1; + const PARAM_A: u32 = 10; + const PARAM_SIZE: u32 = 11; + const MATMUL: u32 = 5; + + fn param(id: u32, name: &str) -> Param { + Param { + id, + name: name.to_string(), + ty: Type::Number, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } + } + + fn matmul_fn(body: Vec) -> Function { + Function { + id: MATMUL, + name: "matmul".to_string(), + type_params: Vec::new(), + params: vec![param(PARAM_A, "a"), param(PARAM_SIZE, "size")], + return_type: Type::Void, + body, + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } + } + + fn call(args: Vec) -> Stmt { + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::FuncRef(MATMUL)), + args, + type_args: Vec::new(), + byte_offset: 0, + }) + } + + fn module(init: Vec, functions: Vec) -> Module { + let mut hir = Module::new("test"); + hir.functions = functions; + hir.init = init; + hir + } + + fn constants() -> HashMap { + HashMap::from([(SIZE_CONST, 256.0)]) + } + + /// Lever (c): `matmul(a, SIZE)` with a module-level `const SIZE = 256` + /// pins `size` to exactly 256, which is what lets `i * size + k` prove + /// `[0, 65535]`. + #[test] + fn single_constant_call_site_pins_the_parameter() { + let hir = module( + vec![call(vec![Expr::Integer(0), Expr::LocalGet(SIZE_CONST)])], + vec![matmul_fn(Vec::new())], + ); + let ranges = collect_param_int_ranges(&hir, &constants()); + assert_eq!(ranges.get(&PARAM_SIZE), Some(&IntRange::exact(256))); + assert_eq!(ranges.get(&PARAM_A), Some(&IntRange::exact(0))); + } + + /// Two call sites meet into the covering interval. + #[test] + fn multiple_call_sites_meet() { + let hir = module( + vec![ + call(vec![Expr::Integer(0), Expr::Integer(4)]), + call(vec![Expr::Integer(0), Expr::Integer(64)]), + ], + vec![matmul_fn(Vec::new())], + ); + let ranges = collect_param_int_ranges(&hir, &constants()); + assert_eq!(ranges.get(&PARAM_SIZE), Some(&IntRange { min: 4, max: 64 })); + } + + /// One non-constant argument leaves the parameter unproven. + #[test] + fn one_unresolved_call_site_poisons_the_parameter() { + let hir = module( + vec![ + call(vec![Expr::Integer(0), Expr::Integer(4)]), + call(vec![Expr::Integer(0), Expr::LocalGet(99)]), + ], + vec![matmul_fn(Vec::new())], + ); + assert!(collect_param_int_ranges(&hir, &constants()) + .get(&PARAM_SIZE) + .is_none()); + } + + /// The function escaping as a value means unseen call sites. + #[test] + fn func_ref_used_as_a_value_poisons_the_function() { + let mut init = vec![call(vec![Expr::Integer(0), Expr::Integer(4)])]; + init.push(Stmt::Expr(Expr::Call { + callee: Box::new(Expr::FuncRef(77)), + args: vec![Expr::FuncRef(MATMUL)], + type_args: Vec::new(), + byte_offset: 0, + })); + let hir = module(init, vec![matmul_fn(Vec::new())]); + assert!(collect_param_int_ranges(&hir, &constants()).is_empty()); + } + + /// An exported function can be called from another module, which this + /// per-module pass never sees. + #[test] + fn exported_function_is_not_summarized() { + let mut f = matmul_fn(Vec::new()); + f.is_exported = true; + let hir = module( + vec![call(vec![Expr::Integer(0), Expr::Integer(4)])], + vec![f], + ); + assert!(collect_param_int_ranges(&hir, &constants()).is_empty()); + } + + /// A rest parameter makes the arity — and therefore the slot's value — + /// unknowable at the callee. + #[test] + fn rest_parameter_is_not_summarized() { + let mut f = matmul_fn(Vec::new()); + f.params[1].is_rest = true; + let hir = module( + vec![call(vec![Expr::Integer(0), Expr::Integer(4)])], + vec![f], + ); + assert!(collect_param_int_ranges(&hir, &constants()).is_empty()); + } + + /// A call site with fewer arguments leaves the slot `undefined`. + #[test] + fn arity_mismatch_is_not_summarized() { + let hir = module( + vec![call(vec![Expr::Integer(0)])], + vec![matmul_fn(Vec::new())], + ); + assert!(collect_param_int_ranges(&hir, &constants()).is_empty()); + } + + /// A parameter the body reassigns no longer holds the caller's value. + #[test] + fn reassigned_parameter_is_not_summarized() { + let body = vec![Stmt::Expr(Expr::LocalSet( + PARAM_SIZE, + Box::new(Expr::Integer(-1)), + ))]; + let hir = module( + vec![call(vec![Expr::Integer(0), Expr::Integer(4)])], + vec![matmul_fn(body)], + ); + let ranges = collect_param_int_ranges(&hir, &constants()); + assert!(ranges.get(&PARAM_SIZE).is_none()); + // The untouched sibling parameter keeps its summary. + assert_eq!(ranges.get(&PARAM_A), Some(&IntRange::exact(0))); + } + + /// `function f(x) { var x = … }` rebinds the parameter's slot id. + #[test] + fn rebound_parameter_is_not_summarized() { + let body = vec![Stmt::Let { + id: PARAM_SIZE, + name: "size".to_string(), + ty: Type::Number, + mutable: true, + init: Some(Expr::Integer(-1)), + }]; + let hir = module( + vec![call(vec![Expr::Integer(0), Expr::Integer(4)])], + vec![matmul_fn(body)], + ); + assert!(collect_param_int_ranges(&hir, &constants()) + .get(&PARAM_SIZE) + .is_none()); + } + + /// A never-called function gets no summary (an empty meet is not `[⊥, ⊤]`). + #[test] + fn uncalled_function_is_not_summarized() { + let hir = module(Vec::new(), vec![matmul_fn(Vec::new())]); + assert!(collect_param_int_ranges(&hir, &constants()).is_empty()); + } + + /// `export { matmul }` records the export WITHOUT setting `is_exported`. + #[test] + fn alias_exported_function_is_not_summarized() { + let mut hir = module( + vec![call(vec![Expr::Integer(0), Expr::Integer(4)])], + vec![matmul_fn(Vec::new())], + ); + hir.exported_functions = vec![("matmul".to_string(), MATMUL)]; + assert!(collect_param_int_ranges(&hir, &constants()).is_empty()); + } + + /// Same, seen only through the name-based `Export::Named` list. + #[test] + fn named_export_by_local_name_is_not_summarized() { + let mut hir = module( + vec![call(vec![Expr::Integer(0), Expr::Integer(4)])], + vec![matmul_fn(Vec::new())], + ); + hir.exports = vec![perry_hir::Export::Named { + local: "matmul".to_string(), + exported: "mm".to_string(), + }]; + assert!(collect_param_int_ranges(&hir, &constants()).is_empty()); + } + + /// Reflected onto `globalThis`, the function is reachable by name. + #[test] + fn global_this_reflected_function_is_not_summarized() { + let mut hir = module( + vec![call(vec![Expr::Integer(0), Expr::Integer(4)])], + vec![matmul_fn(Vec::new())], + ); + hir.references_global_this = true; + hir.script_global_functions = vec![("matmul".to_string(), MATMUL)]; + assert!(collect_param_int_ranges(&hir, &constants()).is_empty()); + } + + /// A fractional argument is not an integer range. + #[test] + fn fractional_argument_is_not_summarized() { + let hir = module( + vec![call(vec![Expr::Integer(0), Expr::Number(1.5)])], + vec![matmul_fn(Vec::new())], + ); + assert!(collect_param_int_ranges(&hir, &constants()) + .get(&PARAM_SIZE) + .is_none()); + } +} diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 06c5f1c25d..7c7cf0d3ac 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -1144,6 +1144,11 @@ pub(crate) struct FnCtx<'a> { pub clamp_u8_functions: &'a std::collections::HashSet, pub integer_returning_functions: &'a std::collections::HashSet, pub i32_identity_functions: &'a std::collections::HashSet, + /// #7286 lever (c): parameter LocalId → interprocedural integer range, + /// consulted by `int_range_for_local` as the last resort. Entries exist + /// only for parameters proven never to be written or rebound anywhere in + /// the module, so no per-statement invalidation is needed. + pub param_int_ranges: &'a crate::collectors::ParamIntRanges, pub typed_f64_functions: &'a std::collections::HashSet, pub typed_i32_functions: &'a std::collections::HashSet, pub typed_string_functions: &'a std::collections::HashSet, diff --git a/crates/perry-codegen/src/expr/range_facts.rs b/crates/perry-codegen/src/expr/range_facts.rs index 67930b4969..7669e0268b 100644 --- a/crates/perry-codegen/src/expr/range_facts.rs +++ b/crates/perry-codegen/src/expr/range_facts.rs @@ -319,6 +319,10 @@ fn int_range_for_local( .and_then(|value| f64_to_i64_constant(*value)) .map(IntRange::exact) }; + // #7286 lever (c): last resort — an interprocedural summary for a numeric + // parameter. Without it one unbounded parameter leaf poisons the whole + // index expression and demotes EVERY array access in the function. + let result = result.or_else(|| ctx.param_int_ranges.get(&id).copied()); seen.remove(&id); result } diff --git a/crates/perry-codegen/src/stmt/counter_range.rs b/crates/perry-codegen/src/stmt/counter_range.rs new file mode 100644 index 0000000000..9dc1ff5e44 --- /dev/null +++ b/crates/perry-codegen/src/stmt/counter_range.rs @@ -0,0 +1,612 @@ +//! Monotone-induction range proof for `for`-loop counters (#7286). +//! +//! A `for` counter whose only writer is the loop's own update slot and whose +//! step never decreases it is bounded on both sides for the whole body: +//! +//! * **above** by the loop guard, which is re-evaluated at every body entry +//! (`j < LIMIT` ⟹ `j <= LIMIT - 1` inside the body), and +//! * **below** by its initial value, because the step is monotone +//! non-decreasing. +//! +//! The resulting [`IntRangeFact`] is what +//! `expr::index_get::numeric_index_has_integer_array_index_proof` needs to let +//! `sieve[j]` lower to the inline guarded array diamond instead of the fully +//! opaque `js_array_get_index_or_string` / +//! `js_typed_feedback_array_set_index_or_string` runtime-key helpers. Before +//! #7286 only the `i++`-with-integer-literal-start shape produced a fact, so +//! `for (let j = i * i; j < LIMIT; j = j + i)` — the inner loop of +//! `11_prime_sieve`, ~2.12M stores in its timed region — was demoted. +//! +//! **Non-negativity plus an upper bound is the whole proof obligation.** Being +//! an `i32` is neither necessary nor sufficient: `(i * size + k) | 0` is a +//! genuine `i32` and still fails, because `ToInt32`'s range `[-2^31, 2^31-1]` +//! has `min < 0` and a negative index is a named property, not an element. +//! +//! The ctx-dependent queries are behind [`CounterRangeFacts`] so the admission +//! rules are unit-testable without constructing a `FnCtx` (per CLAUDE.md, +//! `crates/*/tests/*.rs` integration suites do not run per-PR). + +use perry_hir::{BinaryOp, CompareOp, Expr, Stmt, UpdateOp}; + +use crate::expr::{IntRange, IntRangeFact}; + +use super::loops::{expr_mutates_local, stmts_mutate_local}; + +/// The `FnCtx`-derived facts the counter-range classifier consults. +pub(crate) trait CounterRangeFacts { + /// Provable integer range of `expr` at the `for` statement. + fn int_range(&self, expr: &Expr) -> Option; + /// True when `id` is known to hold a non-negative *integral* Number at + /// this program point (`ctx.nonnegative_integer_locals`). + fn is_nonnegative_integer_local(&self, id: u32) -> bool; + /// True when SOME closure can write `id`. + /// + /// The syntactic `stmts_mutate_local` walk only sees closures *declared + /// inside* the loop. A closure declared **outside** the loop and merely + /// *called* inside it writes the local without appearing anywhere in the + /// loop's statements, so neither the counter nor the stride may be trusted + /// when that is possible. + /// + /// `ctx.boxed_vars` is exactly that set: `collect_module_boxed_vars` + /// unions "captured by a closure AND mutated" over every body in the + /// module *before* any lowering starts, and HIR LocalIds are globally + /// unique within a module. (`closure_captures` is NOT — it is populated + /// only while lowering INSIDE a closure; every ordinary-body `FnCtx` + /// constructs it empty, so a guard built on it alone is inert.) + fn is_closure_writable(&self, id: u32) -> bool; +} + +impl CounterRangeFacts for crate::expr::FnCtx<'_> { + fn int_range(&self, expr: &Expr) -> Option { + crate::expr::int_range_expr(self, expr) + } + + fn is_nonnegative_integer_local(&self, id: u32) -> bool { + self.nonnegative_integer_locals.contains(&id) + } + + fn is_closure_writable(&self, id: u32) -> bool { + self.boxed_vars.contains(&id) + || self.prealloc_boxes.contains(&id) + || self.closure_captures.contains_key(&id) + } +} + +/// How the loop's update slot advances the counter. +#[derive(Debug)] +pub(crate) enum CounterStep<'a> { + /// `i++` — stride exactly `+1`. + Increment, + /// `j = j + ` / `j = + j`. The stride still has to be + /// proven non-negative and loop-invariant. + Add(&'a Expr), +} + +/// Match the update slot against the two monotone step shapes. +pub(crate) fn counter_step(update: &Expr, counter_id: u32) -> Option> { + match update { + Expr::Update { + id, + op: UpdateOp::Increment, + .. + } if *id == counter_id => Some(CounterStep::Increment), + Expr::LocalSet(id, value) if *id == counter_id => match value.as_ref() { + Expr::Binary { + op: BinaryOp::Add, + left, + right, + } => match (left.as_ref(), right.as_ref()) { + (Expr::LocalGet(l), _) if *l == counter_id => Some(CounterStep::Add(right)), + (_, Expr::LocalGet(r)) if *r == counter_id => Some(CounterStep::Add(left)), + _ => None, + }, + _ => None, + }, + _ => None, + } +} + +/// True when `expr` provably evaluates to a **non-negative integral** Number +/// (or to `+Infinity`, which the loop guard rejects before the body runs). +/// +/// IEEE-754 `+`/`*` of non-negative integral doubles is again non-negative and +/// integral: below `2^53` the result is exact, and at or above it the whole +/// representable grid is integral. Neither can produce a negative or +/// fractional value, so the induction's lower bound survives every rounding. +/// Overflow saturates to `+Infinity`, and `Infinity < bound` is false, so an +/// overflowed counter never enters the body the fact is attached to. +fn is_nonnegative_integer_valued(facts: &F, expr: &Expr) -> bool { + match expr { + Expr::Integer(n) => *n >= 0, + Expr::Number(n) => n.is_finite() && n.fract() == 0.0 && *n >= 0.0, + Expr::LocalGet(id) => { + facts.is_nonnegative_integer_local(*id) + || facts.int_range(expr).is_some_and(|range| range.min >= 0) + } + Expr::Binary { + op: BinaryOp::Add | BinaryOp::Mul, + left, + right, + } => { + is_nonnegative_integer_valued(facts, left) + && is_nonnegative_integer_valued(facts, right) + } + _ => facts.int_range(expr).is_some_and(|range| range.min >= 0), + } +} + +fn collect_local_gets(expr: &Expr, out: &mut Vec) { + if let Expr::LocalGet(id) = expr { + out.push(*id); + } + perry_hir::walker::walk_expr_children(expr, &mut |child| collect_local_gets(child, out)); +} + +/// True when every local read by `expr` keeps its value for the whole loop: +/// not written by the guard, the update slot or the body, and not reachable +/// through a closure capture (which could be written by a callee). +fn operands_are_loop_invariant( + facts: &F, + expr: &Expr, + cond: &Expr, + update: Option<&Expr>, + body: &[Stmt], +) -> bool { + let mut ids = Vec::new(); + collect_local_gets(expr, &mut ids); + ids.iter().all(|id| { + !facts.is_closure_writable(*id) + && !expr_mutates_local(cond, *id) + && update.is_none_or(|expr| !expr_mutates_local(expr, *id)) + && !stmts_mutate_local(body, *id) + }) +} + +/// Prove `[min, max]` for a `for`-loop counter over the whole loop body. +/// +/// Returns `None` — the correct answer, not a failure — whenever any part of +/// the proof is missing; the access then keeps the runtime-key helper. +pub(crate) fn classify_for_counter_range( + init: Option<&Stmt>, + cond: Option<&Expr>, + update: Option<&Expr>, + body: &[Stmt], + facts: &F, + scope_id: u32, +) -> Option { + let (counter_id, start) = match init? { + Stmt::Let { + id, + init: Some(start), + .. + } => (*id, start), + _ => return None, + }; + let cond = cond?; + let Expr::Compare { op, left, right } = cond else { + return None; + }; + if !matches!(op, CompareOp::Lt | CompareOp::Le) { + return None; + } + if !matches!(left.as_ref(), Expr::LocalGet(id) if *id == counter_id) { + return None; + } + let step = counter_step(update?, counter_id)?; + + // The update slot must be the counter's ONLY writer. The fact is attached + // to the whole body, so a body write invalidates it from the second + // iteration on: `for (let i = 0; i < 10; i++) { a[i]; i = -5; }` re-enters + // the body with `i == -4` while the fact still claims `[0, 9]`. + // + // `is_closure_writable` covers the writer the syntactic walk cannot see: a + // closure over the counter declared OUTSIDE the loop (a `var` counter is + // function-scoped, and a captured+mutated `let` counter is boxed too) and + // merely called from inside it. + if facts.is_closure_writable(counter_id) + || expr_mutates_local(cond, counter_id) + || stmts_mutate_local(body, counter_id) + { + return None; + } + + // Upper bound from the guard. Only an exactly-known bound is admitted, so + // the value the guard compares against is the same one the fact quotes. + // + // No `is_closure_writable` check is needed here: an exact `int_range` + // for a binding can only come from a top-level `const` + // (`compile_time_constants`), a non-`mutable` `Let` alias, or a + // degenerate enclosing-loop fact — and the first two are unassignable by + // ECMAScript, while the third is itself produced by this function, whose + // counter guard above already rejects closure-writable counters. + if let Expr::LocalGet(bound_id) = right.as_ref() { + if expr_mutates_local(cond, *bound_id) + || update.is_some_and(|expr| expr_mutates_local(expr, *bound_id)) + || stmts_mutate_local(body, *bound_id) + { + return None; + } + } + let bound_range = facts.int_range(right)?; + if bound_range.min != bound_range.max { + return None; + } + let upper = bound_range + .max + .checked_sub(if matches!(op, CompareOp::Lt) { 1 } else { 0 })?; + + // A non-literal stride is re-read every iteration, so the compile-time + // "non-negative integer" fact has to still hold on iteration N. + if let CounterStep::Add(stride) = step { + if !is_nonnegative_integer_valued(facts, stride) + || !operands_are_loop_invariant(facts, stride, cond, update, body) + { + return None; + } + } + + // Lower bound: monotone steps keep the counter at or above its initial + // value. A provable start range pins the exact minimum; otherwise a + // provably non-negative start still gives `>= 0`, which is the half of + // the proof array indexing actually needs. + let lower = if let Some(range) = facts.int_range(start) { + range.min + } else if is_nonnegative_integer_valued(facts, start) { + 0 + } else { + return None; + }; + + (lower <= upper).then_some(IntRangeFact { + local_id: counter_id, + scope_id, + range: IntRange { + min: lower, + max: upper, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::{HashMap, HashSet}; + + #[derive(Default)] + struct TestFacts { + nonnegative: HashSet, + constants: HashMap, + captured: HashSet, + } + + impl CounterRangeFacts for TestFacts { + fn int_range(&self, expr: &Expr) -> Option { + match expr { + Expr::Integer(n) => Some(IntRange::exact(*n)), + Expr::LocalGet(id) => self.constants.get(id).copied().map(IntRange::exact), + _ => None, + } + } + + fn is_nonnegative_integer_local(&self, id: u32) -> bool { + self.nonnegative.contains(&id) + } + + fn is_closure_writable(&self, id: u32) -> bool { + self.captured.contains(&id) + } + } + + const COUNTER: u32 = 7; + const STRIDE: u32 = 3; + const LIMIT: u32 = 9; + + fn add(left: Expr, right: Expr) -> Expr { + Expr::Binary { + op: BinaryOp::Add, + left: Box::new(left), + right: Box::new(right), + } + } + + fn mul(left: Expr, right: Expr) -> Expr { + Expr::Binary { + op: BinaryOp::Mul, + left: Box::new(left), + right: Box::new(right), + } + } + + fn let_stmt(id: u32, init: Expr) -> Stmt { + Stmt::Let { + id, + name: format!("v{id}"), + ty: perry_hir::types::Type::Number, + mutable: true, + init: Some(init), + } + } + + fn lt(left: Expr, right: Expr) -> Expr { + Expr::Compare { + op: CompareOp::Lt, + left: Box::new(left), + right: Box::new(right), + } + } + + /// `j = j + ` + fn strided_update(stride: Expr) -> Expr { + Expr::LocalSet(COUNTER, Box::new(add(Expr::LocalGet(COUNTER), stride))) + } + + fn sieve_facts() -> TestFacts { + TestFacts { + nonnegative: HashSet::from([STRIDE]), + constants: HashMap::from([(LIMIT, 1_000_000)]), + captured: HashSet::new(), + } + } + + /// Lever (a): `for (let j = i * i; j < LIMIT; j = j + i)` — the whole + /// `11_prime_sieve` inner loop. Neither `i * i` nor `i` has a range, only + /// non-negativity, and the upper bound comes from the guard. + #[test] + fn strided_counter_with_nonnegative_start_and_stride_is_bounded() { + let init = let_stmt(COUNTER, mul(Expr::LocalGet(STRIDE), Expr::LocalGet(STRIDE))); + let cond = lt(Expr::LocalGet(COUNTER), Expr::LocalGet(LIMIT)); + let update = strided_update(Expr::LocalGet(STRIDE)); + let fact = classify_for_counter_range( + Some(&init), + Some(&cond), + Some(&update), + &[], + &sieve_facts(), + 42, + ) + .expect("monotone strided counter should be bounded"); + assert_eq!(fact.local_id, COUNTER); + assert_eq!(fact.scope_id, 42); + assert_eq!(fact.range.min, 0); + assert_eq!(fact.range.max, 999_999); + } + + /// A stride with no non-negativity proof could walk the counter down past + /// zero (`j = j + step` with `step === -1` visits `-1`, which is the + /// property `"-1"`, not element 0). + #[test] + fn unproven_stride_is_rejected() { + let init = let_stmt(COUNTER, Expr::Integer(0)); + let cond = lt(Expr::LocalGet(COUNTER), Expr::LocalGet(LIMIT)); + let update = strided_update(Expr::LocalGet(STRIDE)); + let mut facts = sieve_facts(); + facts.nonnegative.remove(&STRIDE); + assert!(classify_for_counter_range( + Some(&init), + Some(&cond), + Some(&update), + &[], + &facts, + 0 + ) + .is_none()); + } + + /// The stride is re-read every iteration: a body that rewrites it voids + /// the compile-time non-negativity fact. + #[test] + fn stride_mutated_by_body_is_rejected() { + let init = let_stmt(COUNTER, Expr::Integer(0)); + let cond = lt(Expr::LocalGet(COUNTER), Expr::LocalGet(LIMIT)); + let update = strided_update(Expr::LocalGet(STRIDE)); + let body = vec![Stmt::Expr(Expr::LocalSet( + STRIDE, + Box::new(Expr::Integer(-1)), + ))]; + assert!(classify_for_counter_range( + Some(&init), + Some(&cond), + Some(&update), + &body, + &sieve_facts(), + 0 + ) + .is_none()); + } + + /// A captured stride can be written by a closure declared outside the loop + /// and merely called inside it, which the syntactic walk cannot see. + #[test] + fn closure_captured_stride_is_rejected() { + let init = let_stmt(COUNTER, Expr::Integer(0)); + let cond = lt(Expr::LocalGet(COUNTER), Expr::LocalGet(LIMIT)); + let update = strided_update(Expr::LocalGet(STRIDE)); + let mut facts = sieve_facts(); + facts.captured.insert(STRIDE); + assert!(classify_for_counter_range( + Some(&init), + Some(&cond), + Some(&update), + &[], + &facts, + 0 + ) + .is_none()); + } + + /// Same for the COUNTER: a closure that can write it is a writer the + /// loop's own statements never mention. + #[test] + fn closure_writable_counter_is_rejected() { + let init = let_stmt(COUNTER, Expr::Integer(0)); + let cond = lt(Expr::LocalGet(COUNTER), Expr::Integer(10)); + let update = Expr::Update { + id: COUNTER, + op: UpdateOp::Increment, + prefix: false, + }; + let mut facts = TestFacts::default(); + facts.captured.insert(COUNTER); + assert!(classify_for_counter_range( + Some(&init), + Some(&cond), + Some(&update), + &[], + &facts, + 0 + ) + .is_none()); + } + + /// `for (let i = 0; i < 10; i++) { a[i]; i = -5; }` keeps looping with a + /// negative `i`. The fact must not survive a body write to the counter. + #[test] + fn counter_written_by_body_is_rejected() { + let init = let_stmt(COUNTER, Expr::Integer(0)); + let cond = lt(Expr::LocalGet(COUNTER), Expr::Integer(10)); + let update = Expr::Update { + id: COUNTER, + op: UpdateOp::Increment, + prefix: false, + }; + let body = vec![Stmt::Expr(Expr::LocalSet( + COUNTER, + Box::new(Expr::Integer(-5)), + ))]; + assert!(classify_for_counter_range( + Some(&init), + Some(&cond), + Some(&update), + &body, + &TestFacts::default(), + 0 + ) + .is_none()); + } + + /// The classic `i++` shape keeps its exact `[start, bound - 1]` fact. + #[test] + fn increment_counter_keeps_exact_range() { + let init = let_stmt(COUNTER, Expr::Integer(2)); + let cond = lt(Expr::LocalGet(COUNTER), Expr::Integer(10)); + let update = Expr::Update { + id: COUNTER, + op: UpdateOp::Increment, + prefix: false, + }; + let fact = classify_for_counter_range( + Some(&init), + Some(&cond), + Some(&update), + &[], + &TestFacts::default(), + 0, + ) + .expect("literal-start increment loop keeps its fact"); + assert_eq!(fact.range.min, 2); + assert_eq!(fact.range.max, 9); + } + + /// A decrementing counter has no lower bound from the guard, so no fact. + #[test] + fn decrementing_counter_is_rejected() { + let init = let_stmt(COUNTER, Expr::Integer(10)); + let cond = lt(Expr::LocalGet(COUNTER), Expr::Integer(100)); + let update = Expr::Update { + id: COUNTER, + op: UpdateOp::Decrement, + prefix: false, + }; + assert!(classify_for_counter_range( + Some(&init), + Some(&cond), + Some(&update), + &[], + &TestFacts::default(), + 0 + ) + .is_none()); + } + + /// A non-constant guard bound (`j < n` for unknown `n`) proves nothing + /// about the upper end. + #[test] + fn unknown_bound_is_rejected() { + let init = let_stmt(COUNTER, Expr::Integer(0)); + let cond = lt(Expr::LocalGet(COUNTER), Expr::LocalGet(LIMIT)); + let update = strided_update(Expr::Integer(1)); + let mut facts = sieve_facts(); + facts.constants.remove(&LIMIT); + assert!(classify_for_counter_range( + Some(&init), + Some(&cond), + Some(&update), + &[], + &facts, + 0 + ) + .is_none()); + } + + /// A fractional start would make every iterate fractional, and `a[0.5]` + /// is the property `"0.5"` — not element 0. + #[test] + fn fractional_start_is_rejected() { + let init = let_stmt(COUNTER, Expr::Number(0.5)); + let cond = lt(Expr::LocalGet(COUNTER), Expr::LocalGet(LIMIT)); + let update = strided_update(Expr::LocalGet(STRIDE)); + assert!(classify_for_counter_range( + Some(&init), + Some(&cond), + Some(&update), + &[], + &sieve_facts(), + 0 + ) + .is_none()); + } + + /// `j = j - i` is not an `Add`, so it never reaches the stride rules. + #[test] + fn subtracting_update_is_rejected() { + let init = let_stmt(COUNTER, Expr::Integer(0)); + let cond = lt(Expr::LocalGet(COUNTER), Expr::LocalGet(LIMIT)); + let update = Expr::LocalSet( + COUNTER, + Box::new(Expr::Binary { + op: BinaryOp::Sub, + left: Box::new(Expr::LocalGet(COUNTER)), + right: Box::new(Expr::LocalGet(STRIDE)), + }), + ); + assert!(classify_for_counter_range( + Some(&init), + Some(&cond), + Some(&update), + &[], + &sieve_facts(), + 0 + ) + .is_none()); + } + + /// An update that assigns some *other* local is not a step at all. + #[test] + fn update_of_other_local_is_rejected() { + let init = let_stmt(COUNTER, Expr::Integer(0)); + let cond = lt(Expr::LocalGet(COUNTER), Expr::LocalGet(LIMIT)); + let update = Expr::LocalSet( + STRIDE, + Box::new(add(Expr::LocalGet(STRIDE), Expr::Integer(1))), + ); + assert!(classify_for_counter_range( + Some(&init), + Some(&cond), + Some(&update), + &[], + &sieve_facts(), + 0 + ) + .is_none()); + } +} diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 5477656bcb..88a16accad 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -4,7 +4,7 @@ use super::*; use crate::expr::{ array_kind_fact, effect_fact, emit_typed_feedback_register_site, nanbox_pointer_inline, - raw_f64_layout_fact, BoundedIndexPair, IntRangeFact, PackedF64LoopFact, PackedNumericLoopKind, + raw_f64_layout_fact, BoundedIndexPair, PackedF64LoopFact, PackedNumericLoopKind, TypedFeedbackContract, TypedFeedbackKind, }; use crate::loop_purity::body_needs_asm_barrier; @@ -4966,9 +4966,14 @@ fn lower_for_after_init_with_i32_bound( } } } - if let Some(fact) = - classify_for_counter_range(init, condition, update, body, ctx, loop_proof_scope_id) - { + if let Some(fact) = super::counter_range::classify_for_counter_range( + init, + condition, + update, + body, + ctx, + loop_proof_scope_id, + ) { ctx.int_range_facts.push(fact); } @@ -5912,7 +5917,7 @@ fn update_is_absent_or_counter_increment( }) } -fn stmts_mutate_local(stmts: &[perry_hir::Stmt], local_id: u32) -> bool { +pub(super) fn stmts_mutate_local(stmts: &[perry_hir::Stmt], local_id: u32) -> bool { stmts.iter().any(|stmt| stmt_mutates_local(stmt, local_id)) } @@ -5994,7 +5999,7 @@ fn stmt_mutates_local(stmt: &perry_hir::Stmt, local_id: u32) -> bool { } } -fn expr_mutates_local(expr: &perry_hir::Expr, local_id: u32) -> bool { +pub(super) fn expr_mutates_local(expr: &perry_hir::Expr, local_id: u32) -> bool { use perry_hir::Expr; match expr { Expr::LocalSet(id, value) => *id == local_id || expr_mutates_local(value, local_id), @@ -6019,68 +6024,6 @@ fn expr_mutates_local(expr: &perry_hir::Expr, local_id: u32) -> bool { } } -fn classify_for_counter_range( - init: Option<&perry_hir::Stmt>, - cond: Option<&perry_hir::Expr>, - update: Option<&perry_hir::Expr>, - body: &[perry_hir::Stmt], - ctx: &crate::expr::FnCtx<'_>, - scope_id: u32, -) -> Option { - use perry_hir::{CompareOp, Expr, Stmt, UpdateOp}; - let (counter_id, start) = match init? { - Stmt::Let { - id, - init: Some(Expr::Integer(start)), - .. - } => (*id, *start), - _ => return None, - }; - let Expr::Compare { op, left, right } = cond? else { - return None; - }; - if !matches!(op, CompareOp::Lt | CompareOp::Le) { - return None; - } - if !matches!(left.as_ref(), Expr::LocalGet(id) if *id == counter_id) { - return None; - } - if !matches!( - update?, - Expr::Update { - id, - op: UpdateOp::Increment, - .. - } if *id == counter_id - ) { - return None; - } - if let Expr::LocalGet(bound_id) = right.as_ref() { - if !local_bound_is_loop_invariant(cond?, update, body, *bound_id) { - return None; - } - } - let bound_range = crate::expr::int_range_expr(ctx, right)?; - if bound_range.min != bound_range.max { - return None; - } - let upper = bound_range - .max - .checked_sub(if matches!(op, CompareOp::Lt) { 1 } else { 0 })?; - if start <= upper { - Some(IntRangeFact { - local_id: counter_id, - scope_id, - range: crate::expr::IntRange { - min: start, - max: upper, - }, - }) - } else { - None - } -} - fn first_blocking_loop_effect(effects: I) -> LoopArrayLengthEffect where I: IntoIterator, diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index ae65b69164..43ab68894e 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -11,6 +11,7 @@ use crate::expr::{lower_expr, lower_expr_value, materialize_js_value, FnCtx}; use crate::native_value::{LoweredValue, MaterializationReason}; use crate::types::DOUBLE; +mod counter_range; mod if_stmt; mod let_buffer_views; mod let_stmt; diff --git a/test-files/test_gap_array_index_range_proof.ts b/test-files/test_gap_array_index_range_proof.ts new file mode 100644 index 0000000000..d45ed36337 --- /dev/null +++ b/test-files/test_gap_array_index_range_proof.ts @@ -0,0 +1,174 @@ +// #7286: array-index range proofs (monotone strided induction + affine +// indices + interprocedural parameter ranges). Every case below either +// exercises a newly-proven fast path with edge-case values, or is a shape the +// proof must REFUSE — a wrong index proof is a memory-safety bug, so the +// refusals matter as much as the admissions. + +// --- A. the 11_prime_sieve shape: `j = i * i; j = j + i` over a dense array +const a: number[] = []; +for (let i = 0; i < 12; i++) { + a[i] = 0; +} +for (let i = 2; i * i < 12; i++) { + for (let j = i * i; j < 12; j = j + i) { + a[j] = a[j] + 1; + } +} +console.log("A:" + a.join(",")); + +// --- B. same shape over a HOLEY array: reads must still see undefined holes +const b: number[] = []; +b[11] = 1; +let bOut = ""; +for (let j = 2; j < 12; j = j + 3) { + bOut = bOut + j + "=" + b[j] + ";"; +} +console.log("B:" + bOut + "len=" + b.length); + +// --- C. `<=` guard, stride > 1, last iteration exactly at the bound +const c = [0, 1, 2, 3, 4, 5, 6]; +let cOut = ""; +for (let j = 0; j <= 6; j = j + 2) { + cOut = cOut + c[j] + ";"; +} +console.log("C:" + cOut); + +// --- D. negative / fractional / NaN indices are PROPERTIES, not elements +const d = [1, 2, 3]; +console.log("D1:" + d[-1] + "," + d[1.5] + "," + d[NaN] + "," + d[-0]); +d[-1] = 91; +d[1.5] = 92; +d[NaN] = 93; +console.log("D2:" + d.length + "," + d[-1] + "," + d[1.5] + "," + d[NaN]); +console.log("D3:" + Object.keys(d).join("|")); + +// --- E. indices at and beyond the array-index limit +const e: number[] = []; +e[0] = 1; +e[4294967294] = 2; // 2^32-2 — the LAST real array index +console.log("E1:" + e.length + "," + e[4294967294]); +e[4294967295] = 3; // 2^32-1 — a property, NOT an index +console.log("E2:" + e.length + "," + e[4294967295]); +e[4294967296] = 4; // 2^32 — a property +console.log("E3:" + e.length + "," + e[4294967296]); +const eBig: number[] = []; +eBig[3000000000] = 5; // > i32::MAX but a valid index +console.log("E4:" + eBig.length + "," + eBig[3000000000]); + +// --- F. affine `i * size + k` where the product overflows i32 +function affine(arr: number[], size: number): string { + let out = ""; + for (let i = 0; i < 2; i++) { + for (let k = 0; k < 2; k++) { + out = out + arr[i * size + k] + ";"; + } + } + return out; +} +const f = [1, 2, 3, 4, 5, 6]; +console.log("F1:" + affine(f, 2)); +console.log("F2:" + affine(f, 2000000000)); // i*size+k reaches 2e9 and 2e9+1 +console.log("F3:" + affine(f, 4000000000)); // 4e9 > 2^32-2 — properties + +// --- G. interprocedural parameter range: two constant call sites meet +function pick(arr: number[], n: number): number { + return arr[n]; +} +const g = [10, 20, 30]; +console.log("G:" + pick(g, 0) + "," + pick(g, 2)); + +// --- H. a parameter the callee reassigns must NOT inherit the caller's range +function reassigned(arr: number[], n: number): string { + n = -1; + return "" + arr[n]; +} +console.log("H:" + reassigned(g, 0)); + +// --- I. a body write to the counter breaks the induction: the loop re-enters +// with a NEGATIVE counter, which must read the "-1" property (undefined). +const i1 = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; +let iOut = ""; +for (let j = 0; j < 10; j = j + 2) { + iOut = iOut + i1[j] + ";"; + if (j === 4) { + j = -3; + } +} +console.log("I:" + iOut); + +// --- J. a stride the body rewrites to a NEGATIVE value must not be trusted: +// the counter walks back past zero and every further read is the "-N" property. +const j1 = [0, 1, 2, 3, 4, 5, 6, 7, 8]; +let jStride = 3; +let jSteps = 0; +let jOut = ""; +for (let j = 0; j < 9; j = j + jStride) { + jOut = jOut + j + ":" + j1[j] + ";"; + jSteps = jSteps + 1; + if (jSteps === 3) { + jStride = -4; + } + if (jSteps >= 7) { + break; + } +} +console.log("J:" + jOut); + +// --- J2. a stride rewritten upward only shortens the loop +let j2Stride = 2; +let j2Out = ""; +for (let j = 0; j < 9; j = j + j2Stride) { + j2Out = j2Out + j + ":" + j1[j] + ";"; + if (j >= 4) { + j2Stride = 100; + } +} +console.log("J2:" + j2Out); + +// --- K. the callee escapes as a value, so its parameter has unseen call sites +function escaping(arr: number[], n: number): number { + return arr[n]; +} +const kRef = escaping; +console.log("K:" + kRef(g, 1) + "," + escaping(g, 0) + "," + kRef(g, -1)); + +// --- L. holes vs. explicit undefined through the strided path +const l = new Array(6); +l[3] = 7; +let lOut = ""; +for (let j = 0; j < 6; j = j + 1) { + lOut = lOut + (j in l) + "/" + l[j] + ";"; +} +console.log("L:" + lOut); + +// --- M. a strided counter whose start is huge: the guard rejects immediately +const m = [1, 2, 3]; +let mCount = 0; +for (let j = 1e300 * 1e300; j < 3; j = j + 1) { + mCount = mCount + 1; +} +console.log("M:" + mCount + "," + m[0]); + +// --- N. non-integral stride keeps fractional indices (property reads) +const n1 = [0, 1, 2, 3]; +let nOut = ""; +for (let j = 0; j < 3; j = j + 0.5) { + nOut = nOut + j + "=" + n1[j] + ";"; +} +console.log("N:" + nOut); + +// --- O. a sparse array grown through a proven strided store +const o: number[] = []; +for (let j = 5; j < 20; j = j + 5) { + o[j] = j; +} +console.log("O:" + o.length + "," + JSON.stringify(o)); + +// --- P. string-keyed reads on the same array still resolve as properties +const p: number[] = [1, 2, 3]; +(p as unknown as Record)["x"] = 9; +let pOut = ""; +for (let j = 0; j < 3; j = j + 1) { + pOut = pOut + p[j] + ";"; +} +console.log("P:" + pOut + (p as unknown as Record)["x"] + "," + p.length);