diff --git a/changelog.d/6916-repsel-p4a3-ptr-numarray.md b/changelog.d/6916-repsel-p4a3-ptr-numarray.md new file mode 100644 index 0000000000..900f4915c0 --- /dev/null +++ b/changelog.d/6916-repsel-p4a3-ptr-numarray.md @@ -0,0 +1,9 @@ +**Representation-selection Phase 4a.3 — `Ptr` guard-free numeric-array element access (#6904)** + +Completes the layer deferred from #6915 (RFC §4 `Array` row / §5.7): + +- `collectors/ptr_numarray.rs` proves function-local `number[]` bindings under provenance (`new Array()` / empty `[]`), containment (numeric-key element reads, numeric-by-construction writes, `.length`, numeric `push`, bare `return` — everything else disqualifies, including every length-shrinking/reordering mutator), the density lattice `Dense ⊒ HolesOK ⊒ Boxed`, and a module-wide barrier kill (Phase 3b's §5.2 scan plus any indexed write through a `.prototype` object). The #6915 stale-binding finding is an explicit structural eligibility term: containment excludes every path that could leave the local on a growth-forwarded stub. +- At sites with a per-site in-bounds proof (static index range vs the allocation length, or a bounded-loop fact), element access lowers to slot reload → mask → `gep` → `load`/`store double` — no guard tier, no bounds arms, no barrier, no note. Guard-free reads are ToNumber-context only (`HolesOK` canonicalizes `TAG_HOLE` to the quiet NaN, bit-exact with `ToNumber(undefined)`; bare hole-observing reads stay on the guarded tiers), and guard-free stores require a canonical-raw-f64 RHS. Everything unproven falls back to the #6915 guarded tiers. +- `PERRY_PTR_NUMARRAY_LOCALS` (default on) gates the whole phase and is keyed into the object cache. + +Post-`opt -O3` structural proof: the #6904 histogram inner loop is load/fcmp/select/fadd/store with zero guard instructions, zero runtime calls, and zero bounds checks. Two new gap files cover promotion, every disqualification class, and the barrier-module behavior — byte-exact vs Node under flag on/off, `PERRY_GC_FORCE_EVACUATE=1`, and `PERRY_GEN_GC=0`. diff --git a/crates/perry-codegen/src/collectors/hir_facts.rs b/crates/perry-codegen/src/collectors/hir_facts.rs index d75d726382..37a0f57ef7 100644 --- a/crates/perry-codegen/src/collectors/hir_facts.rs +++ b/crates/perry-codegen/src/collectors/hir_facts.rs @@ -127,6 +127,13 @@ pub(crate) struct ShapeStabilityFacts { /// and unguarded direct method dispatch /// (`lower_call/property_get/dynamic_dispatch.rs`). pub shape_proven_ptr_locals: HashMap, + /// Representation-selection Phase 4a.3: function-locals proven to satisfy + /// the `Ptr` invariants (raw-f64-or-hole slots, never-shrinking + /// length, no stale-binding path) for their entire lifetime + /// (`collectors/ptr_numarray.rs`). Consumers: guard-free element access + /// in `expr/index_get.rs` / `expr/index_set.rs` at sites with an + /// additional per-site in-bounds proof. + pub num_array_locals: HashMap, } #[derive(Debug, Clone, Default)] @@ -309,6 +316,13 @@ impl TypeFacts { self.shape_stability.shape_proven_ptr_locals.get(&local_id) } + /// Representation-selection Phase 4a.3: the numeric-array proof for a + /// local, when it is a proven `Ptr` local + /// (`collectors/ptr_numarray.rs`). + pub(crate) fn num_array_local(&self, local_id: u32) -> Option<&super::NumArrayLocal> { + self.shape_stability.num_array_locals.get(&local_id) + } + pub(crate) fn proves_scalar_replacement(&self, local_id: u32) -> bool { self.shape_stability .scalar_replaceable_object_locals @@ -447,6 +461,17 @@ pub(crate) fn collect_type_facts( module_dispatch, ¬_bigint_locals, ); + // Representation-selection Phase 4a.3: `Ptr` locals. Gated on + // `PERRY_PTR_NUMARRAY_LOCALS`, the module-wide §5.2 barrier scan, and the + // array-specific prototype-indexed-write kill inside the collector. + let num_array_locals = super::ptr_numarray::collect_num_array_locals( + stmts, + boxed_vars, + module_globals, + module_dispatch, + compile_time_constants, + &integer_locals, + ); let graph = TypeFacts { representation: RepresentationFacts { integer_locals: integer_locals.clone(), @@ -485,6 +510,7 @@ pub(crate) fn collect_type_facts( shape_stability: ShapeStabilityFacts { scalar_replaceable_object_locals, shape_proven_ptr_locals, + num_array_locals, }, materialization_hazards, }; diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index 99171291fc..5908e7c569 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 mutation; mod not_bigint_locals; mod pointer_locals; +mod ptr_numarray; mod ptr_shape; mod refs; mod scalar_method_dispatch; @@ -61,6 +62,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 pointer_locals::collect_pointer_typed_locals; +pub(crate) use ptr_numarray::{NumArrayDensity, NumArrayLocal}; pub(crate) use ptr_shape::PtrShapeLocal; pub(crate) use refs::{ collect_let_ids, collect_ref_ids_in_expr, collect_ref_ids_in_stmts, is_clamp_call, diff --git a/crates/perry-codegen/src/collectors/ptr_numarray.rs b/crates/perry-codegen/src/collectors/ptr_numarray.rs new file mode 100644 index 0000000000..3c8944dd26 --- /dev/null +++ b/crates/perry-codegen/src/collectors/ptr_numarray.rs @@ -0,0 +1,1096 @@ +//! Representation-selection Phase 4a.3 (RFC `docs/representation-selection-rfc.md` +//! §4 `Array` row, §5.7): numeric-array locals proven for GUARD-FREE +//! element access (`Ptr`). +//! +//! ## What this proves +//! +//! A function-local `let a = new Array(n)` / `let a: number[] = []` qualifies +//! as a **numeric-array pointer local** when static analysis proves that for +//! the local's entire lifetime: +//! +//! 1. every element slot in `[0, length)` holds canonical raw-f64 number bits +//! or `TAG_HOLE` (never a NaN-boxed pointer/string/bool/undefined), and +//! 2. `length` never shrinks below the allocation length, and +//! 3. the binding can never go stale (no growth path exists that fails to +//! write the live head back to the local slot). +//! +//! Element accesses at sites with an additional per-site in-bounds proof then +//! lower to the bare form — slot reload → mask → `gep +8` → `gep idx<<3` → +//! `load`/`store double` — with NO guard (not even the Phase 4a.1 inline +//! header tests), no barrier, no layout note, and no bounds check. +//! +//! ## Why it is sound (provenance + containment + density) +//! +//! * **Provenance**: the local is initialized by exactly one `Stmt::Let` whose +//! init is `new Array()` (runtime hole-fills every slot, sets +//! `GC_ARRAY_RAW_F64_HOLES`, and stamps the pointer-free GC layout — +//! `js_array_constructor_single`) or an EMPTY array literal `[]` (length 0; +//! nothing to observe until a numeric push). Density: `new Array(n)` ⇒ +//! `HolesOK`; `[]` ⇒ `Dense`. +//! * **Containment**: every use of the local is an element read, an element +//! write whose VALUE is numeric-by-construction (a non-number store would +//! break invariant 1 — such a local is disqualified outright, not just +//! per-site), a numeric-key `PutValueSet` on itself, `.length`, a numeric +//! `push`, or a bare `return ` (the alias escapes only when the +//! function is DONE — no in-function access can race it). Anything else — +//! reassignment, any other bare reference (call arguments, `console.log`, +//! object/array element stores, `JSON.stringify`), closure capture, +//! `pop`/`shift`/`splice`/`unshift`/`copyWithin` (length shrink / +//! reordering), freeze/seal — disqualifies. Non-numeric STRING keys could +//! name `length` (shrink) or `__proto__`, so keyed writes require a +//! provably-numeric key (a number key can only ever name an element or a +//! harmless numeric-string property). +//! * **Self-heal exemption (the #6915 stale-binding finding)**: guard-free +//! consumers have no runtime check that could catch a growth-forwarded +//! stub — unlike the guarded tiers, a stale head here would be silent +//! wrong-value/UAF, not a slow path. The proof must therefore exclude +//! staleness STRUCTURALLY, and rests on three separately-checked legs: +//! +//! 1. **No out-of-function growth.** Containment rejects every bare +//! reference, so the array is never passed to a callee: the Phase 2 +//! specialized-ABI caller-allocated growth pattern (#6915's root cause) +//! cannot arise. +//! 2. **Every in-function growth site writes the live head back to +//! `ctx.locals[id]`.** Audited exhaustively over the growth branches +//! reachable for a local this collector admits (`expr/array_push.rs` +//! guarded-numeric fast + fallback arms, and untyped-inline forwarded + +//! realloc arms — each `js_array_push_f64` / +//! `js_array_numeric_push_f64_unboxed` call is immediately followed by +//! `store double %new_box, ptr %slot`; the in-capacity arm cannot +//! relocate). Element writes reach growth only through +//! `lower_index_set_fast`'s realloc arm, which stores back the same way; +//! the guard-free store path never grows at all (it requires an +//! in-bounds proof). `pop`/`shift`/`splice`/`unshift`/`copyWithin`/ +//! spread-push are disqualified outright, so no other mutator runs. +//! `test_gap_repsel_p4a3_numarray_growth.ts` pins this cross-module +//! invariant as a regression test. +//! 3. **Consumers re-derive the base per access.** Each site reloads the +//! NaN-boxed head from the (shadow-bound) slot — the slot address +//! escapes to the shadow-stack registry, so LLVM cannot cache the load +//! across a call, and GC evacuation rewrites the slot through the same +//! binding. +//! * **In-bounds per site** (checked at lowering time, not here): the index +//! has `int_range` proof `[0, max]` with `max < proven_initial_length` +//! (length can only grow — `pop`-class shrinkers are disqualified — so +//! `idx < initial_length <= current length` holds forever), or the site is +//! a `bounded_index_pairs` loop read. Everything unproven falls back to the +//! Phase 4a.1/4a.2 guarded tiers, which maintain the same invariants. +//! * **Hole observability (density gating)**: guard-free READS are emitted +//! only in ToNumber contexts (the Phase 4a number-context reader), where a +//! `TAG_HOLE` load canonicalized to the quiet NaN is bit-exact with +//! `ToNumber(undefined)`. Bare hole-OBSERVING reads (`x = a[i]` printed, +//! `in`/`Object.keys`/`JSON.stringify` — the latter are call-argument +//! escapes and disqualify anyway) stay on the guarded tiers. +//! * **Module-wide barrier kill**: reuses Phase 3b's §5.2 barrier scan +//! (`Object.defineProperty` family, ANY `delete`, `setPrototypeOf` / +//! `__proto__` writes, `Proxy`, mutating `Reflect.*`) PLUS the +//! array-specific rule: any indexed write through a `.prototype` object +//! anywhere in the module (`Array.prototype[0] = …`) disables all +//! promotion — a polluted prototype changes what a HOLE read observes, and +//! the guard-free read cannot consult the runtime pollution byte. Same +//! module-granularity increment boundary as Phase 3b. +//! +//! Gated by `PERRY_PTR_NUMARRAY_LOCALS` (default on; `0`/`off`/`false` +//! disables — keyed into the object cache). `PERRY_REPSEL_DEBUG=1` prints one +//! line per proven local at compile time. + +use std::collections::{HashMap, HashSet}; + +use perry_hir::types::Type as HirType; +use perry_hir::{BinaryOp, Expr, LogicalOp, Stmt, UnaryOp}; + +use super::ModuleDispatchFacts; + +/// `PERRY_PTR_NUMARRAY_LOCALS` gate. Enabled by default; `=0`/`off`/`false` +/// disables numeric-array pointer-local selection (every access keeps the +/// Phase 4a.1/4a.2 guarded tiers). Keyed into the object cache +/// (`object_cache.rs`). +pub fn ptr_numarray_locals_enabled() -> bool { + use std::sync::OnceLock; + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + !matches!( + std::env::var("PERRY_PTR_NUMARRAY_LOCALS").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ) + }) +} + +fn repsel_debug_enabled() -> bool { + use std::sync::OnceLock; + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| std::env::var("PERRY_REPSEL_DEBUG").as_deref() == Ok("1")) +} + +/// Density lattice `Dense ⊒ HolesOK` (⊒ `Boxed` = not collected at all). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NumArrayDensity { + /// No hole can ever exist in `[0, length)` (empty-literal provenance; + /// growth only through numeric pushes). Guard-free reads may skip the + /// NaN canonicalization entirely. + Dense, + /// Slots are raw-f64-or-`TAG_HOLE` (`new Array(n)` provenance). Guard-free + /// reads are number-context only and canonicalize `TAG_HOLE`/NaN payloads + /// to the quiet NaN (≡ `ToNumber(undefined)` / `ToNumber(NaN)`). + HolesOk, +} + +/// A function-local proven to satisfy the `Ptr` invariants for its +/// entire lifetime. See the module doc for the proof obligations. +#[derive(Debug, Clone)] +pub struct NumArrayLocal { + pub density: NumArrayDensity, + /// The static allocation length. Because every length-shrinking operation + /// disqualifies, `current_length >= proven_initial_length` holds forever, + /// so `index < proven_initial_length` is a permanent in-bounds proof. + pub proven_initial_length: i64, +} + +/// Module-wide array barrier (in ADDITION to +/// [`super::ptr_shape::expr_is_shape_barrier`]): an indexed write through any +/// `.prototype` object. `Array.prototype[3] = x` makes a HOLE read observable +/// through the chain; the guard-free read cannot consult the runtime +/// pollution byte, so any such site in the module kills all promotion. +pub(crate) fn expr_is_numarray_prototype_index_barrier(expr: &Expr) -> bool { + fn is_prototype_object(e: &Expr) -> bool { + matches!(e, Expr::PropertyGet { property, .. } if property == "prototype") + } + match expr { + Expr::IndexSet { object, .. } => is_prototype_object(object), + Expr::PutValueSet { target, .. } => is_prototype_object(target), + _ => false, + } +} + +/// Compile-time visibility: one stderr line per proven local, plus a +/// process-wide running count. Only under `PERRY_REPSEL_DEBUG=1`. +fn note_num_array_local(id: u32, fact: &NumArrayLocal) { + if !repsel_debug_enabled() { + return; + } + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNT: AtomicU64 = AtomicU64::new(0); + let n = COUNT.fetch_add(1, Ordering::Relaxed) + 1; + eprintln!( + "repsel: ptr-numarray local id {id} density {:?} initial_len {} (total {n})", + fact.density, fact.proven_initial_length + ); +} + +/// Entry point: collect the `Ptr` locals of one lowered region. +pub(crate) fn collect_num_array_locals( + stmts: &[Stmt], + boxed_vars: &HashSet, + module_globals: &HashMap, + module_dispatch: &ModuleDispatchFacts, + compile_time_constants: &HashMap, + integer_locals: &HashSet, +) -> HashMap { + if !ptr_numarray_locals_enabled() + || module_dispatch.has_shape_barrier_sites() + || module_dispatch.has_numarray_prototype_index_barriers() + // An UNATTRIBUTABLE prototype reference anywhere in the module + // (`const p = Array.prototype; p[5] = …`, `x.constructor.prototype`, + // …). The direct-form kill above only sees a write whose receiver is + // syntactically `.prototype`; once the prototype object is + // aliased into a local, the write is an ordinary `IndexSet` on that + // local and is invisible to it. `note_prototype_holder` already flags + // every such NAMING site as opaque, so consuming that fact closes the + // alias hole — without it a polluted `Array.prototype[i]` could make a + // HOLE read observable while a guard-free `HolesOK` load (which cannot + // consult the runtime pollution byte) still returned the quiet NaN. + || module_dispatch.has_opaque_prototype_mutation() + { + return HashMap::new(); + } + // Pass 1: single-`Let` provenance candidates. + let mut walk = UseWalk { + boxed_vars, + module_globals, + compile_time_constants, + integer_locals, + candidates: HashMap::new(), + let_counts: HashMap::new(), + let_types: HashMap::new(), + disqualified: HashSet::new(), + }; + walk.collect_candidates(stmts); + if walk.candidates.is_empty() { + return HashMap::new(); + } + // Pass 2: strict use walk (containment + numeric-write proof). + walk.walk_stmts(stmts); + let UseWalk { + candidates, + let_counts, + disqualified, + .. + } = walk; + let mut out = HashMap::new(); + for (id, fact) in candidates { + if disqualified.contains(&id) || let_counts.get(&id).copied().unwrap_or(0) != 1 { + continue; + } + note_num_array_local(id, &fact); + out.insert(id, fact); + } + out +} + +struct UseWalk<'a> { + boxed_vars: &'a HashSet, + module_globals: &'a HashMap, + compile_time_constants: &'a HashMap, + /// Locals proven to hold an integer VALUE (loop counters and friends). + /// They carry `ty: Any` in HIR — without this the overwhelmingly common + /// `arr.push(i * 0.5)` / `arr[i] = …` shapes would never qualify — and an + /// integer value is definitionally a JS Number, so admitting them keeps + /// the numeric-slot invariant. + integer_locals: &'a HashSet, + candidates: HashMap, + let_counts: HashMap, + /// Declared `Let` types in this region (numeric-key / numeric-value + /// trust, matching the project-wide annotation-trust precedent). + let_types: HashMap, + disqualified: HashSet, +} + +impl<'a> UseWalk<'a> { + /// Resolve a static non-negative array-allocation length: an integer + /// literal or a module-level `const` recorded in `compile_time_constants`. + fn static_alloc_length(&self, e: &Expr) -> Option { + let value = match e { + Expr::Integer(v) => *v as f64, + Expr::Number(v) => *v, + Expr::LocalGet(id) => *self.compile_time_constants.get(id)?, + _ => return None, + }; + if !value.is_finite() || value.fract() != 0.0 || !(0.0..=16_000_000.0).contains(&value) { + return None; + } + Some(value as i64) + } + + fn provenance(&self, init: &Expr) -> Option { + match init { + // `new Array()`: runtime hole-fills, sets the + // raw-f64-or-holes flag, stamps pointer-free layout. + Expr::New { + class_name, args, .. + } if class_name == "Array" => match args.as_slice() { + [] => Some(NumArrayLocal { + density: NumArrayDensity::Dense, + proven_initial_length: 0, + }), + [len] => Some(NumArrayLocal { + density: NumArrayDensity::HolesOk, + proven_initial_length: self.static_alloc_length(len)?, + }), + _ => None, + }, + // Empty array literal: length 0, nothing to observe until a + // (numeric-only, by containment) push. + Expr::Array(elems) if elems.is_empty() => Some(NumArrayLocal { + density: NumArrayDensity::Dense, + proven_initial_length: 0, + }), + _ => None, + } + } + + fn collect_candidates(&mut self, stmts: &[Stmt]) { + for_each_stmt(stmts, &mut |s| { + if let Stmt::Let { id, ty, init, .. } = s { + self.let_types.insert(*id, ty.clone()); + if self.boxed_vars.contains(id) || self.module_globals.contains_key(id) { + return; + } + // Only statically array-of-number bindings: the declared type + // is the same trust boundary every numeric fast path uses. + let numeric_array_ty = match ty { + HirType::Array(elem) => { + matches!(elem.as_ref(), HirType::Number | HirType::Int32) + } + HirType::Generic { base, type_args } if base == "Array" => { + type_args.len() == 1 + && matches!(type_args[0], HirType::Number | HirType::Int32) + } + _ => false, + }; + if !numeric_array_ty { + return; + } + if let Some(init) = init { + if let Some(fact) = self.provenance(init) { + self.candidates.insert(*id, fact); + } + } + } + }); + } + + fn disq(&mut self, id: u32) { + self.disqualified.insert(id); + } + + fn is_candidate(&self, id: u32) -> bool { + self.candidates.contains_key(&id) + } + + /// A key that can only ever name an element or a harmless numeric-string + /// property — never `"length"` / `"__proto__"` (ToString of a number can + /// name neither). + fn key_is_provably_numeric(&self, key: &Expr) -> bool { + match key { + Expr::Integer(_) | Expr::Number(_) => true, + Expr::LocalGet(id) => { + self.integer_locals.contains(id) + || matches!( + self.let_types.get(id), + Some(HirType::Number) | Some(HirType::Int32) + ) + } + _ => self.value_is_numeric(u32::MAX, key), + } + } + + /// Numeric-by-construction for stored values: the runtime value is a JS + /// number for every input. `tracked` element reads are number|undefined; + /// they are numeric as arithmetic operands (undefined → NaN) and as the + /// LEFT side of `||`/`??` (undefined never passes through), but NOT bare + /// and NOT under `&&` (a falsy `undefined` left IS the result). + fn value_is_numeric(&self, tracked: u32, e: &Expr) -> bool { + match e { + Expr::Integer(_) | Expr::Number(_) => true, + Expr::NumberCoerce(_) => true, + Expr::LocalGet(id) => { + self.compile_time_constants.contains_key(id) + || self.integer_locals.contains(id) + || matches!( + self.let_types.get(id), + Some(HirType::Number) | Some(HirType::Int32) + ) + } + Expr::Unary { op, operand } => { + matches!(op, UnaryOp::Neg | UnaryOp::Pos | UnaryOp::BitNot) + && self.value_or_element_is_numeric(tracked, operand) + } + Expr::Binary { op, left, right } => { + matches!( + op, + BinaryOp::Add + | BinaryOp::Sub + | BinaryOp::Mul + | BinaryOp::Div + | BinaryOp::Mod + | BinaryOp::BitAnd + | BinaryOp::BitOr + | BinaryOp::BitXor + | BinaryOp::Shl + | BinaryOp::Shr + | BinaryOp::UShr + ) && self.value_or_element_is_numeric(tracked, left) + && self.value_or_element_is_numeric(tracked, right) + } + Expr::Logical { op, left, right } => { + matches!(op, LogicalOp::Or | LogicalOp::Coalesce) + && self.value_or_element_is_numeric(tracked, left) + && self.value_is_numeric(tracked, right) + } + Expr::MathFloor(..) + | Expr::MathCeil(..) + | Expr::MathRound(..) + | Expr::MathTrunc(..) + | Expr::MathAbs(..) + | Expr::MathSqrt(..) + | Expr::MathMin(..) + | Expr::MathMax(..) + | Expr::MathPow(..) + | Expr::MathImul(..) + | Expr::MathRandom => true, + _ => false, + } + } + + /// [`Self::value_is_numeric`] extended with "an element read of the + /// tracked array itself" (number|undefined — valid exactly where a + /// ToNumber/short-circuit context absorbs the undefined). + fn value_or_element_is_numeric(&self, tracked: u32, e: &Expr) -> bool { + if let Expr::IndexGet { object, index } = e { + if let Expr::LocalGet(id) = object.as_ref() { + if *id == tracked && self.key_is_provably_numeric(index) { + return true; + } + } + } + self.value_is_numeric(tracked, e) + } + + fn walk_stmts(&mut self, stmts: &[Stmt]) { + for s in stmts { + self.walk_stmt(s); + } + } + + fn walk_stmt(&mut self, s: &Stmt) { + match s { + Stmt::Let { id, init, .. } => { + if self.is_candidate(*id) { + *self.let_counts.entry(*id).or_insert(0) += 1; + // The provenance init itself contains no use of the local. + return; + } + if let Some(e) = init { + self.walk_expr(e); + } + } + // A bare `return ` hands the alias out only when the + // function is DONE on that path — no later in-function access can + // observe an external mutation through it. + Stmt::Return(Some(Expr::LocalGet(id))) if self.is_candidate(*id) => {} + Stmt::Expr(e) | Stmt::Throw(e) => self.walk_expr(e), + Stmt::Return(opt) => { + if let Some(e) = opt { + self.walk_expr(e); + } + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + self.walk_expr(condition); + self.walk_stmts(then_branch); + if let Some(eb) = else_branch { + self.walk_stmts(eb); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + self.walk_expr(condition); + self.walk_stmts(body); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(init) = init { + self.walk_stmt(init.as_ref()); + } + if let Some(c) = condition { + self.walk_expr(c); + } + if let Some(u) = update { + self.walk_expr(u); + } + self.walk_stmts(body); + } + Stmt::Try { + body, + catch, + finally, + } => { + self.walk_stmts(body); + if let Some(c) = catch { + self.walk_stmts(&c.body); + } + if let Some(f) = finally { + self.walk_stmts(f); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + self.walk_expr(discriminant); + for case in cases { + if let Some(t) = &case.test { + self.walk_expr(t); + } + self.walk_stmts(&case.body); + } + } + Stmt::Labeled { body, .. } => self.walk_stmt(body.as_ref()), + Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) => {} + } + } + + fn walk_expr(&mut self, e: &Expr) { + match e { + // Element read: safe when the key is provably numeric (a + // non-numeric key is an ordinary [[Get]] — harmless for the + // element invariants, but the KEY expression may itself contain a + // bare reference; keep it simple and require numeric). + Expr::IndexGet { object, index } => { + if let Expr::LocalGet(id) = object.as_ref() { + if self.is_candidate(*id) { + if !self.key_is_provably_numeric(index) { + self.disq(*id); + } + self.walk_expr(index); + return; + } + } + self.walk_expr(object); + self.walk_expr(index); + } + // Element write: numeric key + numeric-by-construction value, or + // the local is disqualified outright (a single non-number store + // would break the slot invariant for every guard-free read). + Expr::IndexSet { + object, + index, + value, + } => { + if let Expr::LocalGet(id) = object.as_ref() { + if self.is_candidate(*id) { + if !self.key_is_provably_numeric(index) + || !self.value_is_numeric(*id, value) + { + self.disq(*id); + } + self.walk_expr(index); + self.walk_expr(value); + return; + } + } + self.walk_expr(object); + self.walk_expr(index); + self.walk_expr(value); + } + // Sloppy-mode `a[k] = v` desugars to PutValueSet with + // target == receiver == the local. + Expr::PutValueSet { + target, + key, + value, + receiver, + .. + } => { + if let (Expr::LocalGet(id), Expr::LocalGet(rid)) = + (target.as_ref(), receiver.as_ref()) + { + if id == rid && self.is_candidate(*id) { + if !self.key_is_provably_numeric(key) || !self.value_is_numeric(*id, value) + { + self.disq(*id); + } + self.walk_expr(key); + self.walk_expr(value); + return; + } + } + self.walk_expr(target); + self.walk_expr(key); + self.walk_expr(value); + self.walk_expr(receiver); + } + // `.length` read on the local is safe. Any other property + // access (methods like `sort`/`slice`, `length` WRITES come + // through PropertySet) disqualifies. + Expr::PropertyGet { + object, property, .. + } => { + if let Expr::LocalGet(id) = object.as_ref() { + if self.is_candidate(*id) { + if property != "length" { + self.disq(*id); + } + return; + } + } + self.walk_expr(object); + } + Expr::PropertySet { object, value, .. } => { + if let Expr::LocalGet(id) = object.as_ref() { + if self.is_candidate(*id) { + // `a.length = n` (shrink!) or any expando. + self.disq(*id); + } + } else { + self.walk_expr(object); + } + self.walk_expr(value); + } + Expr::PropertyUpdate { object, .. } => { + if let Expr::LocalGet(id) = object.as_ref() { + if self.is_candidate(*id) { + self.disq(*id); + return; + } + } + self.walk_expr(object); + } + // Numeric push keeps every invariant (canonical store through the + // Phase 4a.1 tiers; growth writes the live head back to the + // slot). A possibly-non-numeric push value disqualifies. + Expr::ArrayPush { array_id, value } => { + if self.is_candidate(*array_id) && !self.value_is_numeric(*array_id, value) { + self.disq(*array_id); + } + self.walk_expr(value); + } + // Length-shrinking / reordering / hole-materializing mutators. + Expr::ArrayPop(id) | Expr::ArrayShift(id) => { + if self.is_candidate(*id) { + self.disq(*id); + } + } + Expr::ArrayPushSpread { array_id, .. } + | Expr::ArrayUnshift { array_id, .. } + | Expr::ArraySplice { array_id, .. } + | Expr::ArrayCopyWithin { array_id, .. } => { + self.disq(*array_id); + perry_hir::walker::walk_expr_children(e, &mut |c| self.walk_expr(c)); + } + Expr::ObjectFreeze(t) | Expr::ObjectSeal(t) | Expr::ObjectPreventExtensions(t) => { + if let Expr::LocalGet(id) = t.as_ref() { + if self.is_candidate(*id) { + self.disq(*id); + return; + } + } + self.walk_expr(t); + } + // Reassignment / bare reference / numeric update = escape or + // rebinding — either way the proof is gone. + Expr::LocalSet(id, v) => { + if self.is_candidate(*id) { + self.disq(*id); + } + self.walk_expr(v); + } + Expr::LocalGet(id) => { + if self.is_candidate(*id) { + self.disq(*id); + } + } + Expr::Update { id, .. } => { + if self.is_candidate(*id) { + self.disq(*id); + } + } + // Closures: captured or body-referenced candidates escape. + Expr::Closure { + body, + captures, + mutable_captures, + .. + } => { + for c in captures.iter().chain(mutable_captures.iter()) { + if self.is_candidate(*c) { + self.disq(*c); + } + } + self.walk_stmts(body); + } + // Everything else: recurse; a bare LocalGet of a candidate in any + // unhandled position hits the LocalGet arm above and escapes. + _ => { + perry_hir::walker::walk_expr_children(e, &mut |c| self.walk_expr(c)); + } + } + } +} + +/// Depth-first statement visitor (Let-collection pre-pass). +fn for_each_stmt(stmts: &[Stmt], f: &mut impl FnMut(&Stmt)) { + fn go(stmts: &[Stmt], f: &mut impl FnMut(&Stmt)) { + for s in stmts { + f(s); + match s { + Stmt::If { + then_branch, + else_branch, + .. + } => { + go(then_branch, f); + if let Some(eb) = else_branch { + go(eb, f); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => go(body, f), + Stmt::For { init, body, .. } => { + if let Some(init) = init { + go(std::slice::from_ref(init.as_ref()), f); + } + go(body, f); + } + Stmt::Try { + body, + catch, + finally, + } => { + go(body, f); + if let Some(c) = catch { + go(&c.body, f); + } + if let Some(fin) = finally { + go(fin, f); + } + } + Stmt::Switch { cases, .. } => { + for case in cases { + go(&case.body, f); + } + } + Stmt::Labeled { body, .. } => go(std::slice::from_ref(body.as_ref()), f), + _ => {} + } + } + } + go(stmts, f); +} + +#[cfg(test)] +mod tests { + use super::*; + + const ARR: u32 = 1; + const OTHER: u32 = 2; + + fn num_array_ty() -> HirType { + HirType::Array(Box::new(HirType::Number)) + } + + fn let_with(id: u32, ty: HirType, init: Expr) -> Stmt { + Stmt::Let { + id, + name: "a".to_string(), + ty, + mutable: false, + init: Some(init), + } + } + + fn new_array(args: Vec) -> Expr { + Expr::New { + class_name: "Array".to_string(), + args, + type_args: vec![], + byte_offset: 0, + cap_args_appended: 0, + } + } + + /// `const a: number[] = new Array(len);` + fn alloc_let(len: i64) -> Stmt { + let_with(ARR, num_array_ty(), new_array(vec![Expr::Integer(len)])) + } + + fn index_get(id: u32, index: Expr) -> Expr { + Expr::IndexGet { + object: Box::new(Expr::LocalGet(id)), + index: Box::new(index), + } + } + + fn index_set(id: u32, index: Expr, value: Expr) -> Expr { + Expr::IndexSet { + object: Box::new(Expr::LocalGet(id)), + index: Box::new(index), + value: Box::new(value), + } + } + + fn property_get(object: Expr, property: &str) -> Expr { + Expr::PropertyGet { + object: Box::new(object), + property: property.to_string(), + byte_offset: 0, + } + } + + fn push(id: u32, value: Expr) -> Expr { + Expr::ArrayPush { + array_id: id, + value: Box::new(value), + } + } + + fn capturing_closure(captures: Vec) -> Expr { + Expr::Closure { + func_id: 0, + params: vec![], + return_type: HirType::Void, + body: vec![], + captures, + mutable_captures: vec![], + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: false, + is_async: false, + is_generator: false, + is_strict: false, + } + } + + fn facts_for(init: Vec) -> ModuleDispatchFacts { + let mut module = perry_hir::Module::new("m"); + module.init = init; + super::super::scalar_method_dispatch::collect_module_dispatch_facts(&module) + } + + fn collect_with(stmts: &[Stmt], facts: &ModuleDispatchFacts) -> HashMap { + collect_num_array_locals( + stmts, + &HashSet::new(), + &HashMap::new(), + facts, + &HashMap::new(), + &HashSet::new(), + ) + } + + fn collect(stmts: &[Stmt]) -> HashMap { + collect_with(stmts, &facts_for(vec![])) + } + + fn is_promoted(stmts: &[Stmt]) -> bool { + collect(stmts).contains_key(&ARR) + } + + #[test] + fn promotes_alloc_and_empty_literal_provenance() { + let alloc = collect(&[ + alloc_let(8), + Stmt::Expr(index_set(ARR, Expr::Integer(0), Expr::Number(1.5))), + ]); + let fact = alloc.get(&ARR).expect("new Array(n) should promote"); + assert_eq!(fact.density, NumArrayDensity::HolesOk); + assert_eq!(fact.proven_initial_length, 8); + + let empty = collect(&[ + let_with(ARR, num_array_ty(), Expr::Array(vec![])), + Stmt::Expr(push(ARR, Expr::Number(2.5))), + ]); + let fact = empty.get(&ARR).expect("[] should promote"); + assert_eq!(fact.density, NumArrayDensity::Dense); + assert_eq!(fact.proven_initial_length, 0); + } + + #[test] + fn promotes_contained_uses() { + // Element read, numeric-valued element write (including a read of the + // same array under `||`-class absorption), `.length`, numeric push, + // and a bare end-of-function return are all contained. + let uses: Vec<(&str, Stmt)> = vec![ + ("element read", Stmt::Expr(index_get(ARR, Expr::Integer(1)))), + ( + "literal element write", + Stmt::Expr(index_set(ARR, Expr::Integer(1), Expr::Number(3.0))), + ), + ( + "read-modify-write", + Stmt::Expr(index_set( + ARR, + Expr::Integer(1), + Expr::Binary { + op: BinaryOp::Add, + left: Box::new(index_get(ARR, Expr::Integer(1))), + right: Box::new(Expr::Integer(1)), + }, + )), + ), + ( + ".length read", + Stmt::Expr(property_get(Expr::LocalGet(ARR), "length")), + ), + ("numeric push", Stmt::Expr(push(ARR, Expr::Number(1.0)))), + ("bare return", Stmt::Return(Some(Expr::LocalGet(ARR)))), + ]; + for (label, stmt) in uses { + assert!( + is_promoted(&[alloc_let(4), stmt]), + "contained use should stay promoted: {label}" + ); + } + } + + #[test] + fn disqualifies_escapes_and_unsafe_mutators() { + // Each row is a single use that must demote the local back to the + // Phase 4a.1/4a.2 guarded tiers. + let cases: Vec<(&str, Stmt)> = vec![ + ( + "bare LocalGet (alias / call arg / JSON.stringify / console.log)", + Stmt::Expr(Expr::LocalGet(ARR)), + ), + ( + "aliasing Let", + let_with(OTHER, num_array_ty(), Expr::LocalGet(ARR)), + ), + ("pop", Stmt::Expr(Expr::ArrayPop(ARR))), + ("shift", Stmt::Expr(Expr::ArrayShift(ARR))), + ( + "splice", + Stmt::Expr(Expr::ArraySplice { + array_id: ARR, + start: Box::new(Expr::Integer(0)), + delete_count: None, + items: vec![], + }), + ), + ( + "unshift", + Stmt::Expr(Expr::ArrayUnshift { + array_id: ARR, + value: Box::new(Expr::Number(1.0)), + }), + ), + ( + "push spread", + Stmt::Expr(Expr::ArrayPushSpread { + array_id: ARR, + source: Box::new(Expr::Array(vec![])), + }), + ), + ( + "length write", + Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::LocalGet(ARR)), + property: "length".to_string(), + value: Box::new(Expr::Integer(0)), + }), + ), + ( + "non-length property read (sort / slice / …)", + Stmt::Expr(property_get(Expr::LocalGet(ARR), "sort")), + ), + ( + "non-numeric element store", + Stmt::Expr(index_set( + ARR, + Expr::Integer(0), + Expr::String("x".to_string()), + )), + ), + ( + "possibly-non-numeric push value", + Stmt::Expr(push(ARR, Expr::String("x".to_string()))), + ), + ( + "string-key read", + Stmt::Expr(index_get(ARR, Expr::String("length".to_string()))), + ), + ( + "string-key write", + Stmt::Expr(index_set( + ARR, + Expr::String("length".to_string()), + Expr::Integer(0), + )), + ), + ( + "reassignment", + Stmt::Expr(Expr::LocalSet(ARR, Box::new(Expr::Array(vec![])))), + ), + ( + "freeze", + Stmt::Expr(Expr::ObjectFreeze(Box::new(Expr::LocalGet(ARR)))), + ), + ( + "seal", + Stmt::Expr(Expr::ObjectSeal(Box::new(Expr::LocalGet(ARR)))), + ), + ("closure capture", Stmt::Expr(capturing_closure(vec![ARR]))), + ]; + for (label, stmt) in cases { + assert!( + !is_promoted(&[alloc_let(4), stmt]), + "must NOT promote with: {label}" + ); + } + } + + #[test] + fn disqualifies_unproven_provenance() { + // Non-static allocation length. + assert!(!is_promoted(&[let_with( + ARR, + num_array_ty(), + new_array(vec![Expr::LocalGet(OTHER)]) + )])); + // Non-empty literal (first-increment scope). + assert!(!is_promoted(&[let_with( + ARR, + num_array_ty(), + Expr::Array(vec![Expr::Number(1.0)]) + )])); + // Re-declared binding: provenance is not single-Let. + assert!(!is_promoted(&[alloc_let(4), alloc_let(4)])); + // Non-numeric element type. + assert!(!is_promoted(&[let_with( + ARR, + HirType::Array(Box::new(HirType::String)), + Expr::Array(vec![]) + )])); + } + + #[test] + fn disqualifies_boxed_and_module_global_bindings() { + let stmts = [alloc_let(4)]; + let facts = facts_for(vec![]); + let boxed: HashSet = [ARR].into_iter().collect(); + assert!(collect_num_array_locals( + &stmts, + &boxed, + &HashMap::new(), + &facts, + &HashMap::new(), + &HashSet::new() + ) + .is_empty()); + + let globals: HashMap = [(ARR, "g".to_string())].into_iter().collect(); + assert!(collect_num_array_locals( + &stmts, + &HashSet::new(), + &globals, + &facts, + &HashMap::new(), + &HashSet::new() + ) + .is_empty()); + } + + #[test] + fn module_barriers_disable_all_promotion() { + let stmts = [alloc_let(4)]; + let barriers: Vec<(&str, Expr)> = vec![ + ( + "direct Array.prototype[i] = v", + Expr::IndexSet { + object: Box::new(property_get( + Expr::ClassRef("Array".to_string()), + "prototype", + )), + index: Box::new(Expr::Integer(5)), + value: Box::new(Expr::Number(1.0)), + }, + ), + ( + "aliased prototype naming site (opaque prototype mutation)", + property_get(Expr::LocalGet(OTHER), "prototype"), + ), + ( + "delete (§5.2 shape barrier)", + Expr::Delete(Box::new(index_get(OTHER, Expr::Integer(0)))), + ), + ]; + for (label, barrier) in barriers { + let facts = facts_for(vec![Stmt::Expr(barrier)]); + assert!( + collect_with(&stmts, &facts).is_empty(), + "barrier must disable promotion: {label}" + ); + } + } +} diff --git a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs index 6ab2037628..517f558a15 100644 --- a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs +++ b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs @@ -57,6 +57,13 @@ pub struct ModuleDispatchFacts { /// ALL `Ptr` promotion in the module. See /// `collectors/ptr_shape.rs` for the rule's soundness discussion. shape_barrier_sites: bool, + /// Representation-selection Phase 4a.3: the module contains an indexed + /// write through a `.prototype` object (`Array.prototype[0] = …`). A + /// polluted prototype changes what a HOLE read observes through the + /// chain, and the guard-free `Ptr` read cannot consult the + /// runtime pollution byte — any such site disables all `Ptr` + /// promotion in the module. See `collectors/ptr_numarray.rs`. + numarray_prototype_index_barriers: bool, } impl Default for ModuleDispatchFacts { @@ -67,6 +74,7 @@ impl Default for ModuleDispatchFacts { prototype_touched_classes: HashSet::new(), opaque_prototype_mutation: true, shape_barrier_sites: true, + numarray_prototype_index_barriers: true, } } } @@ -109,6 +117,21 @@ impl ModuleDispatchFacts { pub(crate) fn has_shape_barrier_sites(&self) -> bool { self.shape_barrier_sites } + + /// Representation-selection Phase 4a.3: does the module contain an + /// indexed write through any `.prototype` object? + pub(crate) fn has_numarray_prototype_index_barriers(&self) -> bool { + self.numarray_prototype_index_barriers + } + + /// Does the module NAME a prototype object it cannot attribute to a + /// declared class (`const p = Array.prototype`, `x.constructor.prototype`, + /// …)? Such a reference can be aliased into a local and written through + /// later, so `Ptr` promotion (whose guard-free reads cannot + /// consult the runtime prototype-pollution byte) must stand down. + pub(crate) fn has_opaque_prototype_mutation(&self) -> bool { + self.opaque_prototype_mutation + } } /// Scan a whole module — top-level init, every function, and every class body @@ -119,6 +142,7 @@ pub fn collect_module_dispatch_facts(hir: &Module) -> ModuleDispatchFacts { prototype_touched_classes: HashSet::new(), opaque_prototype_mutation: false, shape_barrier_sites: false, + numarray_prototype_index_barriers: false, }; note_stmts(&hir.init, &mut facts); @@ -161,6 +185,9 @@ fn note_stmts(stmts: &[Stmt], facts: &mut ModuleDispatchFacts) { if super::ptr_shape::expr_is_shape_barrier(expr) { facts.shape_barrier_sites = true; } + if super::ptr_numarray::expr_is_numarray_prototype_index_barrier(expr) { + facts.numarray_prototype_index_barriers = true; + } }); } @@ -170,6 +197,9 @@ fn note_expr_tree(expr: &Expr, facts: &mut ModuleDispatchFacts) { if super::ptr_shape::expr_is_shape_barrier(node) { facts.shape_barrier_sites = true; } + if super::ptr_numarray::expr_is_numarray_prototype_index_barrier(node) { + facts.numarray_prototype_index_barriers = true; + } }); } @@ -552,6 +582,7 @@ mod tests { prototype_touched_classes: HashSet::new(), opaque_prototype_mutation: false, shape_barrier_sites: false, + numarray_prototype_index_barriers: false, } } diff --git a/crates/perry-codegen/src/expr/binary.rs b/crates/perry-codegen/src/expr/binary.rs index fc96b1a2dc..eedffaeb0d 100644 --- a/crates/perry-codegen/src/expr/binary.rs +++ b/crates/perry-codegen/src/expr/binary.rs @@ -41,7 +41,7 @@ fn lower_arithmetic_operand(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<(String, // a coerced hole (NaN) is indistinguishable from a stored NaN, but // `NaN ?? x` is NaN while `undefined ?? x` is `x`. if let Expr::Logical { op, left, right } = expr { - if crate::type_analysis::is_numeric_expr(ctx, expr) { + if is_numeric_expr(ctx, expr) { let value = lower_numeric_logical_for_number_context(ctx, *op, left, right)?; return Ok((value, true)); } @@ -79,7 +79,7 @@ fn lower_arithmetic_operand(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<(String, /// or can surface a boxed value through a raw-f64 read's cold fallback. fn operand_needs_residual_coerce(ctx: &FnCtx<'_>, expr: &Expr, fallback_coerced: bool) -> bool { !fallback_coerced - && (!crate::type_analysis::is_numeric_expr(ctx, expr) + && (!is_numeric_expr(ctx, expr) || expr_may_return_boxed_value_from_raw_f64_fallback(ctx, expr)) } diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index 7a38ce614e..63fc244487 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -1154,6 +1154,17 @@ pub(crate) fn lower_numeric_index_get_for_number_context( } } + // Repsel Phase 4a.3: guard-free `Ptr` load — supersedes the + // packed/bounded/guarded tiers when the local proof + a per-site + // in-bounds proof both hold. + if let Some(value) = super::ptr_numarray_access::try_lower_num_array_guard_free_get( + ctx, + object.as_ref(), + index.as_ref(), + )? { + return Ok(Some(value)); + } + if let Expr::LocalGet(arr_id) = object.as_ref() { if let Some((fact, idx_id, offset)) = packed_f64_loop_fact_for_index(ctx, *arr_id, index.as_ref()) diff --git a/crates/perry-codegen/src/expr/index_set.rs b/crates/perry-codegen/src/expr/index_set.rs index 0dc06c38b9..4d6c441b80 100644 --- a/crates/perry-codegen/src/expr/index_set.rs +++ b/crates/perry-codegen/src/expr/index_set.rs @@ -1024,6 +1024,22 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // string key on dynamic receiver → object field set, otherwise // bail with a clear error. if is_array_expr(ctx, object) { + // Repsel Phase 4a.3: guard-free `Ptr` store — the + // local proof (raw-f64-or-hole slots forever, length never + // shrinks, binding never stale) + a per-site in-bounds proof + // + a canonical-raw-f64 RHS lower `a[i] = v` to slot reload → + // mask → gep → `store double`, with no guard tier, no bounds + // arms, no barrier, no note, no length bump (in-bounds ⇒ + // length unchanged). Anything unproven falls to the guarded + // tiers below, which maintain the same invariants. + if let Some(value) = super::ptr_numarray_access::try_lower_num_array_guard_free_set( + ctx, + object.as_ref(), + index, + value, + )? { + return Ok(value); + } // Bounded-index fast-fast path: when the surrounding // for-loop has registered `(counter_id, arr_id)` as a // bounded pair (via `lower_for`'s diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index e73d48af9c..56302d5401 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -1479,6 +1479,7 @@ mod env_clones; mod fs_await; mod index_get; mod masked_window; +mod ptr_numarray_access; mod ta_param_f64_read; pub(crate) use index_get::packed_f64_loop_index_parts; pub(crate) use masked_window::masked_window_fact_for_index; diff --git a/crates/perry-codegen/src/expr/ptr_numarray_access.rs b/crates/perry-codegen/src/expr/ptr_numarray_access.rs new file mode 100644 index 0000000000..3140eff20c --- /dev/null +++ b/crates/perry-codegen/src/expr/ptr_numarray_access.rs @@ -0,0 +1,252 @@ +//! Representation-selection Phase 4a.3: guard-free `Ptr` element +//! access lowering (RFC `docs/representation-selection-rfc.md` §4 +//! `Array` row, §5.7). +//! +//! The eligibility proof lives in `collectors/ptr_numarray.rs`; this module +//! holds the per-site consumers: the in-bounds site proof and the guard-free +//! load/store emitters. See the collector's module doc for the full soundness +//! argument (provenance + containment + density gating + the stale-binding +//! exemption). + +use anyhow::Result; +use perry_hir::Expr; + +use crate::nanbox::POINTER_MASK_I64; +use crate::native_value::{ + BoundsProof, BoundsState, BufferAccessMode, LoweredValue, NativeRep, SemanticKind, +}; +use crate::types::{DOUBLE, I1, I32, I64}; + +use super::{lower_expr, lower_expr_as_i32, raw_f64_layout_fact, FnCtx}; + +/// Repsel Phase 4a.3: per-site in-bounds proof for a `Ptr` local — +/// the index's integer range is `[0, max]` with `max <` the static allocation +/// length (length can only grow for a proven local, so the bound is +/// permanent). +pub(crate) fn num_array_index_statically_in_bounds( + ctx: &FnCtx<'_>, + fact: &crate::collectors::NumArrayLocal, + index: &Expr, +) -> bool { + super::range_facts::int_range_expr(ctx, index) + .is_some_and(|range| range.min >= 0 && range.max < fact.proven_initial_length) +} + +/// Repsel Phase 4a.3: the guard-free `Ptr` element load — slot +/// reload → mask → `+8` → `idx<<3` → `load double`, with NO guard tier, no +/// bounds check (the caller proved in-bounds), no runtime call anywhere. +/// `HolesOk` locals canonicalize any NaN payload (a `TAG_HOLE` slot) to the +/// quiet NaN — bit-exact with `ToNumber(undefined)` in the number contexts +/// this is emitted for; `Dense` locals skip even that select. +fn lower_num_array_guard_free_get( + ctx: &mut FnCtx<'_>, + arr_id: u32, + arr_box: &str, + idx_i32: &str, + fact: &crate::collectors::NumArrayLocal, + proof: BoundsProof, +) -> 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); + let raw = blk.load(DOUBLE, &element_ptr); + if fact.density == crate::collectors::NumArrayDensity::HolesOk { + let is_ord = blk.fcmp("ord", &raw, &raw); + blk.select(I1, &is_ord, DOUBLE, &raw, "0x7FF8000000000000") + } else { + raw + } + }; + let lowered = LoweredValue { + semantic: SemanticKind::JsNumber, + rep: NativeRep::F64, + llvm_ty: DOUBLE, + value: value.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "NumArrayIndexGet", + Some(arr_id), + "ptr_numarray.guard_free_load", + &lowered, + Some(BoundsState::Proven { proof }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + vec![raw_f64_layout_fact( + Some(arr_id), + "consumed", + "ptr_numarray_local_proof", + None, + )], + Vec::new(), + false, + false, + vec![ + "index_range=proven_in_bounds".to_string(), + "storage_layout=raw_f64_or_hole_slots".to_string(), + "guard=none_static_proof".to_string(), + ], + ); + value +} + +/// Repsel Phase 4a.3: try the guard-free `Ptr` read for a +/// number-context element load. Returns `None` (fall through to the guarded +/// tiers) unless the receiver is a proven local AND the site carries an +/// in-bounds proof (static range vs the allocation length, or a +/// bounded-loop-pair fact). +pub(crate) fn try_lower_num_array_guard_free_get( + ctx: &mut FnCtx<'_>, + object: &Expr, + index: &Expr, +) -> Result> { + let Expr::LocalGet(arr_id) = object else { + return Ok(None); + }; + let Some(fact) = ctx.native_facts.num_array_local(*arr_id).cloned() else { + return Ok(None); + }; + if num_array_index_statically_in_bounds(ctx, &fact, index) { + let arr_box = lower_expr(ctx, &Expr::LocalGet(*arr_id))?; + let idx_i32 = lower_expr_as_i32(ctx, index)?; + return Ok(Some(lower_num_array_guard_free_get( + ctx, + *arr_id, + &arr_box, + &idx_i32, + &fact, + BoundsProof::MinLength, + ))); + } + if let Expr::LocalGet(idx_id) = index { + if ctx + .bounded_index_pairs + .iter() + .any(|f| f.index_local_id == *idx_id && f.array_local_id == *arr_id) + { + if let Some(i32_slot) = ctx.i32_counter_slots.get(idx_id).cloned() { + let arr_box = lower_expr(ctx, &Expr::LocalGet(*arr_id))?; + let idx_i32 = ctx.block().load(I32, &i32_slot); + return Ok(Some(lower_num_array_guard_free_get( + ctx, + *arr_id, + &arr_box, + &idx_i32, + &fact, + BoundsProof::LoopGuard, + ))); + } + } + } + Ok(None) +} + +/// Repsel Phase 4a.3: try the guard-free `Ptr` store. Returns +/// `None` (fall through to the guarded tiers) unless the receiver is a proven +/// local, the RHS is canonical-raw-f64 by construction, AND the site carries +/// an in-bounds proof (static range vs the allocation length, or a +/// bounded-loop-pair fact). +pub(crate) fn try_lower_num_array_guard_free_set( + ctx: &mut FnCtx<'_>, + object: &Expr, + index: &Expr, + value: &Expr, +) -> Result> { + let Expr::LocalGet(arr_id) = object else { + return Ok(None); + }; + let Some(fact) = ctx.native_facts.num_array_local(*arr_id).cloned() else { + return Ok(None); + }; + // The store must keep the raw-f64-or-hole slot invariant with no runtime + // check, so only a canonical-by-construction RHS qualifies (an INT32-boxed + // or NaN-payload value stored verbatim would corrupt it). + if !crate::type_analysis::expr_produces_canonical_raw_f64(ctx, value) { + return Ok(None); + } + let statically = num_array_index_statically_in_bounds(ctx, &fact, index); + let bounded_slot = if statically { + None + } else { + match index { + Expr::LocalGet(idx_id) + if ctx + .bounded_index_pairs + .iter() + .any(|f| f.index_local_id == *idx_id && f.array_local_id == *arr_id) => + { + ctx.i32_counter_slots.get(idx_id).cloned() + } + _ => None, + } + }; + if !statically && bounded_slot.is_none() { + return Ok(None); + } + // JS evaluation order: target ref → key → value. + let arr_box = lower_expr(ctx, &Expr::LocalGet(*arr_id))?; + let (idx_i32, proof) = if statically { + (lower_expr_as_i32(ctx, index)?, BoundsProof::MinLength) + } else { + ( + ctx.block().load(I32, &bounded_slot.unwrap()), + BoundsProof::LoopGuard, + ) + }; + let val_double = lower_expr(ctx, 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); + // GC_STORE_AUDIT(POINTER_FREE): canonical raw-f64 store under the + // `Ptr` local proof — never a GC pointer, no barrier, no + // layout note, and no length bump (in-bounds ⇒ length unchanged). + blk.store(DOUBLE, &val_double, &element_ptr); + } + let stored = LoweredValue { + semantic: SemanticKind::JsNumber, + rep: NativeRep::F64, + llvm_ty: DOUBLE, + value: val_double.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "NumArrayIndexSet", + Some(*arr_id), + "ptr_numarray.guard_free_store", + &stored, + Some(BoundsState::Proven { proof }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + vec![raw_f64_layout_fact( + Some(*arr_id), + "consumed", + "ptr_numarray_local_proof", + None, + )], + Vec::new(), + false, + false, + vec![ + "index_range=proven_in_bounds".to_string(), + "storage_layout=raw_f64_or_hole_slots".to_string(), + "guard=none_static_proof".to_string(), + ], + ); + Ok(Some(val_double)) +} diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 8c85a77225..2c16c93e4c 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -1006,6 +1006,17 @@ fn compute_object_cache_key_with_env( "env_ptr_shape_locals", env_var("PERRY_PTR_SHAPE_LOCALS").as_deref().unwrap_or(""), ); + // Representation-selection Phase 4a.3 — Ptr locals: + // `=0`/`off`/`false` reverts proven numeric-array locals from guard-free + // element access back to the Phase 4a.1/4a.2 guarded tiers, which changes + // the emitted IR / .o bytes — a warm cache must not serve an object built + // under the other setting. + h.field( + "env_ptr_numarray_locals", + env_var("PERRY_PTR_NUMARRAY_LOCALS") + .as_deref() + .unwrap_or(""), + ); // FEAT_JSCVT ToInt32 (`fjcvtzs` on apple-arm64): flipping it changes // every `toint32_wrap` emission site's IR, so it must key the cache. h.field("env_jscvt", env_var("PERRY_JSCVT").as_deref().unwrap_or("")); diff --git a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs index b2cd9ecef0..287df764d0 100644 --- a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs +++ b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs @@ -624,6 +624,8 @@ fn key_changes_with_codegen_env_vars() { "PERRY_SPECIALIZED_ABI_MAX", // Representation-selection Phase 3b: shape-proven Ptr locals. "PERRY_PTR_SHAPE_LOCALS", + // Representation-selection Phase 4a.3: Ptr locals. + "PERRY_PTR_NUMARRAY_LOCALS", // FEAT_JSCVT single-instruction ToInt32 (apple-arm64). "PERRY_JSCVT", ] { diff --git a/docs/representation-selection-rfc.md b/docs/representation-selection-rfc.md index a51ca4787b..e8d752fc9b 100644 --- a/docs/representation-selection-rfc.md +++ b/docs/representation-selection-rfc.md @@ -70,7 +70,7 @@ roots — their win is *static dispatch and layout*, not root elimination. | `String` | `StringHeader*` | skip untag/retag; **direct** string-helper calls (no `js_jsvalue_to_string` dispatch) | rooted + rewritten | short-string (inline payload) values stay by-value | | `Object(shape S)` | `ObjHeader*` + static shape | **direct field offsets** (no hash lookup), **static method dispatch** | rooted + rewritten | the dominant win for real apps (property access ≫ arithmetic in web workloads); eligibility in §4.6 | | `TypedArray(kind)` | header ptr (+ hoisted data ptr/len in region) | guard-free element access once kind is proven | rooted + rewritten | data-ptr hoisting invalidated at safepoints if backing can move/detach | -| `Array` (`Ptr`) | `ArrayHeader*`; elements are raw f64 in place (the NaN-box of a number IS its double bits; `TAG_HOLE` marks holes) | SHIPPED (Phase 4a.0-4a.2): inline guarded tiers — header-proof tests instead of out-of-line guard calls, zero runtime calls on the fast path. DEFERRED (Phase 4a.3): guard-free element load/store + bare `.length` under a collector proof; no per-access header tests at all | rooted + rewritten | density lattice `Dense ⊒ HolesOK ⊒ Boxed` (§5.7); a hole-OBSERVING read needs the `TAG_HOLE→undefined` select, hole-DEFAULT consumers (`\|\|0`, `??0`, `\|0`, `>>>0`, numeric `+`) admit the 2-instruction NaN-canonical form under the raw-f64-or-holes proof only; growth re-derives the base after any extend | +| `Array` (`Ptr`) | `ArrayHeader*`; elements are raw f64 in place (the NaN-box of a number IS its double bits; `TAG_HOLE` marks holes) | Phase 4a.0-4a.2: inline guarded tiers — header-proof tests instead of out-of-line guard calls, zero runtime calls on the fast path. Phase 4a.3 (`collectors/ptr_numarray.rs`): fully guard-free element load/store under the collector proof at sites with a per-site in-bounds proof — no header tests, no bounds check, no barrier/note | rooted + rewritten | density lattice `Dense ⊒ HolesOK ⊒ Boxed` (§5.7); a hole-OBSERVING read needs the `TAG_HOLE→undefined` select (guard-free reads are therefore number-context only), hole-DEFAULT consumers (`\|\|0`, `??0`, `\|0`, `>>>0`, numeric `+`) admit the 2-instruction NaN-canonical form under the raw-f64-or-holes proof only; growth re-derives the base after any extend | | `Closure/Function` | code ptr + env ptr | static call targets (extends existing `FuncRef`) | env rooted | | | `SmallBigInt` | `i64` | native 64-bit arithmetic | not a root | overflow → boxed BigInt path, exists today | | `Null/Undefined` | singleton tags | fold checks statically | — | | @@ -204,26 +204,36 @@ Unboxed storage extends to heap slots where the *container's* shape is proven an fields need none (another structural win). - Typed-array element reads stop re-boxing when the consumer is typed (the element is raw in memory today; only the access path boxes it). -- **Plain numeric arrays (`Ptr`, Phase 4a).** SHIPPED in Phase 4a.0-4a.2: the - access path uses inline guarded tiers (per-access header-proof tests on the raw-f64 / - raw-f64-or-holes bits instead of out-of-line guard calls; zero runtime calls on the fast - path). DEFERRED to Phase 4a.3 — the following collector contract is a design, NOT yet - implemented: a local `number[]` whose storage is raw-f64 in place would qualify for +- **Plain numeric arrays (`Ptr`, Phase 4a).** Phase 4a.0-4a.2 shipped the inline + guarded tiers (per-access header-proof tests on the raw-f64 / raw-f64-or-holes bits + instead of out-of-line guard calls; zero runtime calls on the fast path). Phase 4a.3 + (`collectors/ptr_numarray.rs`) ships the collector: a local `number[]` qualifies for fully guard-free element access under provenance + containment, exactly like - `Ptr` locals: single-`Let` provenance (`[]` / all-numeric literal / - `new Array(n)`(`.fill(num)`)), every use a numeric element read/write, `.length`, or numeric - push/pop, and the module-wide §5.2 barrier kill extended with the array-specific barriers - (indexed writes to `Array.prototype`/`Object.prototype`, `setPrototypeOf` on arrays, - `delete arr[i]`, `arr.length = n`, and the reordering mutators `sort`/`reverse`/ - `copyWithin`/`splice`/`shift`/`unshift` on the local). Eligibility carries a **density - lattice** `Dense ⊒ HolesOK ⊒ Boxed`: `Dense` (no hole can exist — literal provenance with - proven in-bounds/append-only writes) drops the hole select entirely; `HolesOK` keeps the - `TAG_HOLE` select for hole-observing reads while hole-default consumers (`||0`-class) use - the proof-gated 2-instruction canonical-NaN form (this consumer form DID ship in 4a.2, - inside the guarded tiers); anything that can store a non-numeric value demotes to - `Boxed`. Hole-vs-undefined observability (`in`, `Object.keys`, `JSON.stringify`) is - preserved by keeping `TAG_HOLE` in storage and materializing `undefined` only at the - read edge. + `Ptr` locals — single-`Let` provenance (`new Array()` or the empty + literal `[]`; the first-increment scope excludes non-empty literals, `.fill` chains, and + param-sized allocations), every use a numeric-key element read, an element write whose + value is numeric-by-construction, `.length`, a numeric `push`, or a bare `return`; and + the module-wide §5.2 barrier kill extended with the array-specific barrier (any indexed + write through a `.prototype` object — a polluted prototype changes what a HOLE read + observes, and the guard-free read cannot consult the runtime pollution byte). + Length-shrinking / reordering / hole-materializing mutators (`arr.length = n`, `pop`/ + `shift`/`splice`/`unshift`/`copyWithin`, `sort`/`reverse` — the latter arrive as + disqualifying method calls) and `delete` (module-wide, via §5.2) all demote. The + **stale-binding exemption** is part of eligibility: containment means no callee ever + receives the array (the Phase 2 specialized-ABI caller-allocated growth pattern cannot + occur), every in-function growth site writes the live head back to the local slot, and + consumers re-derive the base from the (shadow-bound) slot per access. Eligibility + carries a **density lattice** `Dense ⊒ HolesOK ⊒ Boxed`: `Dense` (empty-literal + provenance; growth only through numeric pushes — no hole can exist) drops the hole + handling entirely; `HolesOK` (`new Array(n)`) emits guard-free reads ONLY in ToNumber + contexts, where the proof-gated 2-instruction canonical-NaN form is bit-exact + (`TAG_HOLE` → quiet NaN ≡ `ToNumber(undefined)`); anything that can store a non-numeric + value demotes to `Boxed` (stays on the guarded tiers). Guard-free stores additionally + require a canonical-raw-f64 RHS and a per-site in-bounds proof (static index range vs + the allocation length — permanent because length can only grow — or a bounded-loop + fact). Hole-vs-undefined observability (`in`, `Object.keys`, `JSON.stringify`) is + preserved structurally: those surfaces reference the local as a bare value and + therefore disqualify it, and bare (non-ToNumber) element reads never lower guard-free. ## 6. Phasing (one design; each phase sound on its own) diff --git a/test-files/test_gap_repsel_p4a3_numarray_barriers.ts b/test-files/test_gap_repsel_p4a3_numarray_barriers.ts new file mode 100644 index 0000000000..3cb465de49 --- /dev/null +++ b/test-files/test_gap_repsel_p4a3_numarray_barriers.ts @@ -0,0 +1,76 @@ +// Test: repsel Phase 4a.3 — module-wide barrier kill for Ptr. +// This module CONTAINS barriers (an indexed Array.prototype write and an +// Object.defineProperty site), so NO local may be promoted to guard-free +// access: a hole read must observe the polluted prototype, and a +// defineProperty'd index must divert reads. Everything must stay byte-exact +// vs `node --experimental-strip-types` (the guarded tiers consult the +// runtime pollution byte / descriptor bit; a wrongly-promoted local would +// return undefined/qNaN instead). + +// Pollute Array.prototype AFTER some accesses, then observe through holes. +function holesSeeProto(): string { + const c: number[] = new Array(4); + c[0] = (c[0] || 0) + 1; + const before = "" + c[2]; + (Array.prototype as any)[2] = 777; + const after = "" + c[2]; // hole -> reads through the polluted prototype + const viaOr = (c[2] as any) || -1; // 777 is truthy + delete (Array.prototype as any)[2]; + return before + " " + after + " " + viaOr; +} +console.log(holesSeeProto()); + +// defineProperty accessor on an index of the SAME shape the histogram uses. +function definedIndex(): string { + const c: number[] = new Array(4); + c[0] = 5; + let gets = 0; + Object.defineProperty(c, 1, { + get() { + gets++; + return 42; + }, + }); + const sum = (c[0] || 0) + (c[1] as any) + (c[1] as any); + return sum + " " + gets; +} +console.log(definedIndex()); + +// ALIASED prototype write: the pollution happens through a local holding +// `Array.prototype`, so the receiver of the indexed write is an ordinary +// local — invisible to the direct-form `.prototype[i] = …` barrier. The +// module-wide `opaque_prototype_mutation` fact (set where the prototype is +// NAMED) is what must stand the promotion down; otherwise a guard-free +// HolesOK read would return the quiet NaN where JS observes the inherited +// value. +function aliasedProtoWrite(): string { + const c: number[] = new Array(4); + c[0] = 1; + const p: any = Array.prototype; // naming site -> opaque prototype mutation + p[3] = 555; + const viaHole = "" + c[3]; // inherited 555, NOT undefined + const viaOr = (c[3] as any) || -1; // 555 is truthy + const viaSum = (c[0] || 0) + ((c[3] as any) || 0); + delete p[3]; + const afterDelete = "" + c[3]; + return viaHole + " " + viaOr + " " + viaSum + " " + afterDelete; +} +console.log(aliasedProtoWrite()); + +// The histogram shape still computes exactly under the module-wide kill. +function histogram(data: number[], size: number): number[] { + const counts: number[] = new Array(size); + const mask = size - 1; + for (let i = 0; i < data.length; i++) { + const v = data[i] & mask; + counts[v] = (counts[v] || 0) + 1; + } + return counts; +} +const data: number[] = []; +let seed = 4242; +for (let i = 0; i < 2000; i++) { + seed = (seed * 48271) % 2147483647; + data.push(seed); +} +console.log(histogram(data, 16).join(",")); diff --git a/test-files/test_gap_repsel_p4a3_numarray_growth.ts b/test-files/test_gap_repsel_p4a3_numarray_growth.ts new file mode 100644 index 0000000000..def67e3483 --- /dev/null +++ b/test-files/test_gap_repsel_p4a3_numarray_growth.ts @@ -0,0 +1,102 @@ +// Test: repsel Phase 4a.3 — a PROMOTED (guard-free) numeric-array local whose +// backing storage is reallocated by push-driven growth must never be read +// through a stale head. Guard-free consumers have no runtime check that could +// catch a growth-forwarded stub, so this pins the cross-module invariant the +// eligibility proof depends on: every in-function growth site writes the live +// head back to the local slot, and every consumer re-derives the base from +// that slot per access. +// +// Each function below keeps its array fully contained (no bare references, no +// callee ever receives it), so the collector promotes it; growth then happens +// via `push` past the initial capacity, and the reads AFTER growth must see +// the relocated storage. Validated byte-for-byte against +// `node --experimental-strip-types`, flag on/off and under +// PERRY_GC_FORCE_EVACUATE=1. +export {}; + +// 1) Dense `[]` provenance: many growths (capacity doublings), bounded-loop +// reads after the last growth. +function growThenRead(n: number): number { + const a: number[] = []; + for (let i = 0; i < n; i++) { + a.push(i * 0.5); + } + let sum = 0; + for (let i = 0; i < a.length; i++) { + sum += a[i] || 0; + } + return sum + a.length; +} +console.log(growThenRead(1)); +console.log(growThenRead(2)); +console.log(growThenRead(9)); // crosses the small-capacity boundary +console.log(growThenRead(1000)); + +// 2) Interleaved growth and reads: every read is preceded by a push that may +// have relocated the storage, so a cached head would surface immediately. +function interleaved(n: number): number { + const a: number[] = []; + let acc = 0; + for (let i = 0; i < n; i++) { + a.push(i + 0.25); + acc += a[0] || 0; // element 0 after each (possibly relocating) push + acc += a[i] || 0; // the element just pushed + } + return acc; +} +console.log(interleaved(1)); +console.log(interleaved(64)); +console.log(interleaved(513)); + +// 3) Growth followed by guard-free WRITES through the same binding, then +// reads: a stale head would write into freed storage. +function growWriteRead(n: number): number { + const a: number[] = []; + for (let i = 0; i < n; i++) { + a.push(0); + } + for (let i = 0; i < a.length; i++) { + a[i] = (a[i] || 0) + i * 2; + } + let sum = 0; + for (let i = 0; i < a.length; i++) { + sum += a[i] || 0; + } + return sum; +} +console.log(growWriteRead(3)); +console.log(growWriteRead(300)); + +// 4) `new Array(n)` provenance with statically-in-bounds accesses, then growth +// past the allocation length via push, then reads of BOTH the original +// in-bounds region and the grown region. +function allocThenGrow(): string { + const a: number[] = new Array(4); + a[0] = 1.5; + a[3] = 2.5; + const beforeStatic = (a[0] || 0) + (a[3] || 0); + for (let i = 0; i < 200; i++) { + a.push(i * 0.125); + } + const afterStatic = (a[0] || 0) + (a[3] || 0); // same slots, relocated + let tail = 0; + for (let i = 0; i < a.length; i++) { + tail += a[i] || 0; + } + return beforeStatic + " " + afterStatic + " " + tail + " " + a.length; +} +console.log(allocThenGrow()); + +// 5) Holes survive relocation: a `new Array(n)` local grown by push must keep +// its hole slots holey (JSON/`in` are observability surfaces, so they run on a +// separate NON-promoted array built from the same values). +function holesSurviveGrowth(): string { + const a: number[] = new Array(3); + a[1] = 7; + for (let i = 0; i < 40; i++) { + a.push(i); + } + const probe = (a[0] ?? -1) + "," + (a[1] || -1) + "," + (a[2] ?? -1) + "," + (a[42] || -1); + return probe + " len=" + a.length; +} +console.log(holesSurviveGrowth()); diff --git a/test-files/test_gap_repsel_p4a3_ptr_numarray.ts b/test-files/test_gap_repsel_p4a3_ptr_numarray.ts new file mode 100644 index 0000000000..74e73d71cb --- /dev/null +++ b/test-files/test_gap_repsel_p4a3_ptr_numarray.ts @@ -0,0 +1,175 @@ +// Test: repsel Phase 4a.3 — Ptr guard-free element access. +// This module contains NO shape/prototype barriers, so eligible locals are +// promoted; every behavior here must be byte-exact vs +// `node --experimental-strip-types` whether or not promotion happened +// (PERRY_PTR_NUMARRAY_LOCALS=0 must produce identical output). + +// --- the driving shape: new Array(n) histogram, returned --- +function histogram(data: number[], size: number): number[] { + const counts: number[] = new Array(size); + const mask = size - 1; + for (let i = 0; i < data.length; i++) { + const v = data[i] & mask; + counts[v] = (counts[v] || 0) + 1; + } + return counts; +} +const data: number[] = []; +let seed = 99991; +for (let i = 0; i < 5000; i++) { + seed = (seed * 48271) % 2147483647; + data.push(seed); +} +const h = histogram(data, 32); +console.log(h.join(",")); +let total = 0; +for (let i = 0; i < h.length; i++) total += h[i] || 0; +console.log(total); + +// --- literal-length alloc, static in-bounds reads/writes, holes kept --- +function pointProbe(): number { + const c: number[] = new Array(8); + c[0] = 1.5; + c[3] = -0; + c[5] = 0 / 0; // NaN + // statically in-bounds number-context reads over values, -0, NaN, holes + let acc = (c[0] || 9) + (c[1] || 9) + (c[5] || 9); // 1.5 + 9 + 9 + acc += c[2] * 2; // hole -> undefined -> NaN + if (Number.isNaN(acc)) acc = -1; + acc += Object.is(c[3] || 7, 7) ? 100 : 0; // -0 falsy + acc += c[3] ?? 55; // -0 not nullish -> -0 + acc += c[6] ?? 55; // hole -> 55 + return acc; +} +console.log(pointProbe()); + +// --- hole observability on a promoted local: bare reads stay exact --- +function bareReads(): void { + const c: number[] = new Array(4); + c[1] = (c[1] || 0) + 2; + console.log(c[0], c[1], c[3]); // undefined 2 undefined +} +bareReads(); + +// --- Dense provenance: [] + numeric pushes + bounded-loop consumers --- +function pushAndSum(n: number): number { + const out: number[] = []; + for (let i = 0; i < n; i++) { + out.push(i * 0.5); + } + let s = 0; + for (let i = 0; i < out.length; i++) { + s += out[i] || 0; + } + for (let i = 0; i < out.length; i++) { + out[i] = (out[i] || 0) * 2; + } + let s2 = 0; + for (let i = 0; i < out.length; i++) { + s2 += out[i] || 0; + } + return s + s2; +} +console.log(pushAndSum(1000)); + +// --- disqualification cases: each must simply stay byte-exact --- + +// alias escape (bare reference) +function aliasCase(): string { + const a: number[] = new Array(3); + a[0] = 4; + const b = a; // bare LocalGet -> not promoted + b[1] = 5; + return JSON.stringify(a); +} +console.log(aliasCase()); + +// call-argument escape + JSON/keys/in observability +function escapeCase(): string { + const a: number[] = new Array(3); + a[1] = 8; + const keys = Object.keys(a).join("|"); + const has0 = 0 in a; + return JSON.stringify(a) + " " + keys + " " + has0; +} +console.log(escapeCase()); + +// non-numeric store poisons the local (never promoted) +function mixedStore(): string { + const a: number[] = new Array(3); + a[0] = 1; + (a as any)[1] = "x"; + return JSON.stringify(a) + " " + ((a[1] as any) || "fallback"); +} +console.log(mixedStore()); + +// length shrink + reordering mutators +function shrinkCase(): string { + const a: number[] = new Array(4); + a[0] = 3; + a[1] = 1; + a[2] = 2; + a.length = 2; + return JSON.stringify(a) + " " + (a[2] ?? -1); +} +console.log(shrinkCase()); +function popCase(): number { + const a: number[] = []; + a.push(1.5); + a.push(2.5); + const p = a.pop() || 0; + return p + (a[1] ?? 100); +} +console.log(popCase()); +function sortCase(): string { + const a: number[] = new Array(3); + a[0] = 3; + a[1] = 1; + a[2] = 2; + a.sort(); + return a.join(","); +} +console.log(sortCase()); + +// sparse-extend beyond the allocation length (not in-bounds-proven) +function sparseCase(): string { + const a: number[] = new Array(2); + a[0] = 1; + a[6] = 7; + return JSON.stringify(a) + " " + a.length + " " + (3 in a); +} +console.log(sparseCase()); + +// fractional / out-of-range keys (property writes, not elements) +function fractionalCase(): string { + const a: number[] = new Array(2); + a[0] = 1; + (a as any)[0.5] = 9; + (a as any)[-1] = 8; + return JSON.stringify(a) + " " + (a as any)[0.5] + " " + (a as any)[-1] + " " + a.length; +} +console.log(fractionalCase()); + +// specialized-callee growth (call-arg escape -> guarded tiers + self-heal) +function growInto(a: number[], n: number): void { + for (let i = 0; i < n; i++) { + a.push(i * 0.25); + } +} +function calleeGrowth(): number { + const owned: number[] = [1.5]; + growInto(owned, 100); + let s = 0; + for (let i = 0; i < owned.length; i++) s += owned[i] || 0; + owned[3] = (owned[3] || 0) + 1; + return s + owned.length + (owned[3] || 0); +} +console.log(calleeGrowth()); + +// zero-length edge: new Array(0) and [] with no writes +function emptyCase(): number { + const a: number[] = new Array(0); + const b: number[] = []; + return a.length + b.length + (a[0] ?? 5) + (b[0] ?? 6); +} +console.log(emptyCase());