From 5f696e5b875868cb14612ef7b49218be9ffb6cd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 3 Oct 2026 12:06:24 +0200 Subject: [PATCH 1/2] perf: loop regions admit real loop bodies over arrays (#10741) The loop tiers admitted only single-statement, call-free bodies, so a multi-statement, branching loop over arrays ran every element access through its full guarded tier: the issue's particle step cost 995 instructions per step over number[] and 661 over Float64Array. Loop regions (#11680) now take these loops: - An index is proven when it is the loop counter of `for (...; i < B; i++)` with every write of i and B accounted for (body, condition and update), or a body-local copy of it (the compound-assignment spill makes two). The guard checks the entry value of i and B <= min(length, capacity) once. - An array the region stores into, or reads by the counter in a Number context, is guarded as a dense raw-f64 array (or, unless declared a plain Array, an owning Float64Array). A bare read is one load typed as a Number (a typed slot's NaN is canonicalised); a store of a value proven a canonical double is one store. - Pure Math.* over primitives no longer stales the facts. - A statement that may run JS sets the dirty flag right after it: the accesses after it take the guarded tier and the next iteration re-checks, instead of the loop being refused. stepA 995 -> 74 instr/step (node 45), stepF 661 -> 95 (node 55); tsc and Zod flat. --- changelog.d/PENDING-10741-loop-admission.md | 24 + crates/perry-codegen/src/expr/index_get.rs | 4 +- .../src/expr/index_get/guarded_array.rs | 144 ++++- crates/perry-codegen/src/expr/index_set.rs | 8 + crates/perry-codegen/src/expr/mod.rs | 6 +- .../perry-codegen/src/expr/proxy_reflect.rs | 4 + .../src/expr/region_array_loop_tests.rs | 400 +++++++++++++ crates/perry-codegen/src/stmt/mod.rs | 1 + .../src/stmt/region_loop/arrays.rs | 539 +++++++++++++++--- .../src/stmt/region_loop/bare.rs | 1 + .../perry-codegen/src/stmt/region_loop/mod.rs | 80 ++- .../stmt/region_loop/numeric_expression.rs | 1 + .../src/stmt/region_loop/plan.rs | 229 +++++++- .../src/stmt/stable_packed_loop.rs | 5 + test-files/test_gap_region_counter_loops.ts | 293 ++++++++++ 15 files changed, 1624 insertions(+), 115 deletions(-) create mode 100644 changelog.d/PENDING-10741-loop-admission.md create mode 100644 crates/perry-codegen/src/expr/region_array_loop_tests.rs create mode 100644 test-files/test_gap_region_counter_loops.ts diff --git a/changelog.d/PENDING-10741-loop-admission.md b/changelog.d/PENDING-10741-loop-admission.md new file mode 100644 index 0000000000..4dff60bf98 --- /dev/null +++ b/changelog.d/PENDING-10741-loop-admission.md @@ -0,0 +1,24 @@ +**perf: loop regions admit real loop bodies over arrays (#10741)** + +The loop tiers admitted only single-statement, call-free bodies, so a +multi-statement, branching loop over arrays got none of the region win: every +element access ran its full guarded tier. The issue's particle step (`x[i] += +vx[i]; ...` over 400 elements) cost 995 instructions per step over `number[]` +(19x node) and 661 over `Float64Array` (10x node). + +Loop regions (#11680) now take these loops: + +- An index is proven when it is the loop COUNTER of `for (...; i < B; i++)`, + with every write of `i` and `B` accounted for (the update writes `i`; + nothing else in the body, condition or update writes `i` or `B`), or a + body-local copy of it (the compound-assignment spill of `a[i] += v` makes + two). The guard checks the entry value of `i` and `B <= length` once. +- An array the region stores into, or reads by the counter in a Number + context, is guarded as a dense raw-f64 array (or, unless it is declared a + plain Array, an owning `Float64Array`): a bare read is one `load double` + typed as a Number (a typed slot's NaN is canonicalised), and an element + store of a value proven a canonical double is one `store double`. +- Pure `Math.*` over primitives no longer stales the region's facts. +- A statement that may run JS (a call) no longer refuses the loop: F-body sets + the region's dirty flag right after it, the accesses after it in that + iteration take the guarded tier, and the next iteration re-checks. diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index 799f1f1504..91642fdf7c 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -47,7 +47,9 @@ pub(crate) use foreign_counter::{ packed_f64_loop_index_parts, packed_f64_loop_offset_read, }; use foreign_counter::{affine_packed_loop_read, emit_affine_index_i64, foreign_packed_loop_read}; -pub(crate) use guarded_array::emit_array_region_guard; +pub(crate) use guarded_array::{ + emit_array_region_guard, emit_typed_f64_region_guard, ArrayRegionDense, +}; mod inline_dyn_typed_array; use guarded_array::{ diff --git a/crates/perry-codegen/src/expr/index_get/guarded_array.rs b/crates/perry-codegen/src/expr/index_get/guarded_array.rs index b928c6e72a..05439012c7 100644 --- a/crates/perry-codegen/src/expr/index_get/guarded_array.rs +++ b/crates/perry-codegen/src/expr/index_get/guarded_array.rs @@ -229,6 +229,15 @@ fn emit_array_guard_word_ok(blk: &mut crate::block::LlBlock, word: &str) -> Stri blk.icmp_eq(I32, &masked, ARRAY_READ_GUARD_EXPECT_I32) } +/// What a [`emit_array_region_guard`] receiver needs beyond the read facts. +#[derive(Clone, Copy)] +pub(crate) struct ArrayRegionDense<'a> { + /// The region stores elements: the integrity bits must be clear too. + pub(crate) store: bool, + /// The counter's bound (an `f64`): `bound <= length`. + pub(crate) len_bound: Option<&'a str>, +} + /// A loop region's array guard (#11650 regions, array slice S3): the S1 guard /// word, the prototype facts a hole read needs, and `max_index Stri /// `base_slot`; F-body's element reads then load `base + 8 * idx` and select /// `undefined` for a hole. Returns the `i1` pass flag. The receiver is tested /// against the heap band before anything is dereferenced. +/// +/// #10741: a receiver whose region also STORES elements, or indexes them by +/// the loop counter, needs more, and `dense` asks for it: the store word +/// (integrity bits clear), the dense raw-f64 layout bit (every slot in +/// `[0, length)` a canonical double), and the bounds against `length` +/// as well as `capacity` (`max_index < min(length, capacity)`, and +/// `len_bound <= min(length, capacity)` for the counter's bound, an `f64`). A bare read is then one `load double` +/// and a bare store of a proven double one `store double`: neither changes +/// the length, the layout or any pointer the collector traces. pub(crate) fn emit_array_region_guard( ctx: &mut FnCtx<'_>, recv_box: &str, max_index: u32, + dense: Option>, base_slot: &str, ) -> String { let deref_idx = ctx.new_block("rloop.arr.deref"); @@ -262,7 +281,23 @@ pub(crate) fn emit_array_region_guard( let blk = ctx.block(); let handle = blk.add(I64, &band_offset, "1048576"); let word = emit_array_guard_word(blk, &handle); - let word_ok = emit_array_guard_word_ok(blk, &word); + let word_ok = match dense { + None => emit_array_guard_word_ok(blk, &word), + Some(d) => { + let mask = if d.store { + ARRAY_STORE_GUARD_MASK_I32 + } else { + ARRAY_READ_GUARD_MASK_I32 + }; + let masked = blk.and(I32, &word, mask); + let ok = blk.icmp_eq(I32, &masked, ARRAY_READ_GUARD_EXPECT_I32); + // GC_ARRAY_RAW_F64_LAYOUT (0x80 in `_reserved`, the word's + // upper half): dense canonical raw f64, no holes. + let f64_bit = blk.and(I32, &word, "8388608"); // 0x80 << 16 + let is_f64 = blk.icmp_ne(I32, &f64_bit, "0"); + blk.and(I1, &ok, &is_f64) + } + }; blk.cond_br(&word_ok, &cap_label, &join_label); handle }; @@ -275,7 +310,27 @@ pub(crate) fn emit_array_region_guard( let capacity_addr = blk.add(I64, &handle, "4"); let capacity_ptr = blk.inttoptr(I64, &capacity_addr); let capacity = blk.load(I32, &capacity_ptr); - let fits = blk.icmp_ult(I32, &max_index.to_string(), &capacity); + let fits = match dense { + None => blk.icmp_ult(I32, &max_index.to_string(), &capacity), + Some(d) => { + // #9784: the logical length does not prove the backing + // store (a presized array's capacity can be smaller): every + // slot the region touches is below BOTH. + let length_ptr = blk.inttoptr(I64, &handle); + let length = blk.load(I32, &length_ptr); + let shorter = blk.icmp_ult(I32, &length, &capacity); + let limit = blk.select(I1, &shorter, I32, &length, &capacity); + let fits = blk.icmp_ult(I32, &max_index.to_string(), &limit); + match d.len_bound { + Some(bound) => { + let len_f = blk.uitofp(I32, &limit, DOUBLE); + let within = blk.fcmp("ole", bound, &len_f); + blk.and(I1, &fits, &within) + } + None => fits, + } + } + }; let pass = blk.and(I1, &proto_ok, &fits); let base = blk.array_elements_addr_with_capacity(&handle, &capacity); blk.br(&join_label); @@ -299,6 +354,91 @@ pub(crate) fn emit_array_region_guard( pass } +/// The `Float64Array` twin of [`emit_array_region_guard`]'s dense facts +/// (#10741): a heap pointer whose GC header names a typed array (never +/// forwarded: typed arrays live in the non-moving space), of element kind +/// `Float64`, with inline storage (`PERRY_TA_VIEW_GUARD == 0`: no aliasing +/// view exists, so every typed array's elements start at payload `+16`), and +/// the same bounds against its `length` (payload `+0`). On a pass it stores +/// the element base into `base_slot`; F-body's reads and stores are then the +/// same raw `f64` slots as a dense array's, its reads canonicalising a NaN +/// (a typed array may hold any NaN payload). A typed array's length and kind +/// never change and it is never moved, so nothing but a view creation (JS) +/// can invalidate the facts. +pub(crate) fn emit_typed_f64_region_guard( + ctx: &mut FnCtx<'_>, + recv_box: &str, + max_index: u32, + len_bound: Option<&str>, + base_slot: &str, +) -> String { + let deref_idx = ctx.new_block("rloop.ta.deref"); + let len_idx = ctx.new_block("rloop.ta.len"); + let join_idx = ctx.new_block("rloop.ta.join"); + let deref_label = ctx.block_label(deref_idx); + let len_label = ctx.block_label(len_idx); + let join_label = ctx.block_label(join_idx); + let pre_label = ctx.block().label.clone(); + let band_offset = { + let blk = ctx.block(); + let bits = blk.bitcast_double_to_i64(recv_box); + let band_offset = blk.sub(I64, &bits, HEAP_POINTER_BAND_BASE_I64); + let in_band = blk.icmp_ult(I64, &band_offset, HEAP_POINTER_BAND_SPAN_I64); + blk.cond_br(&in_band, &deref_label, &join_label); + band_offset + }; + ctx.current_block = deref_idx; + let handle = { + let blk = ctx.block(); + let handle = blk.add(I64, &band_offset, "1048576"); + let word = emit_array_guard_word(blk, &handle); + // obj_type GC_TYPE_TYPED_ARRAY (11), not forwarded (0x80 in byte 1). + let masked = blk.and(I32, &word, "33023"); // 0x80FF + let is_ta = blk.icmp_eq(I32, &masked, "11"); + let kind_addr = blk.add(I64, &handle, "8"); + let kind_ptr = blk.inttoptr(I64, &kind_addr); + let kind = blk.load(I8, &kind_ptr); + let is_f64 = blk.icmp_eq(I8, &kind, "7"); // KIND_FLOAT64 + let vg = blk.load(I64, "@PERRY_TA_VIEW_GUARD"); + let no_views = blk.icmp_eq(I64, &vg, "0"); + let ok = blk.and(I1, &is_ta, &no_views); + let ok = blk.and(I1, &ok, &is_f64); + blk.cond_br(&ok, &len_label, &join_label); + handle + }; + ctx.current_block = len_idx; + let (pass, base) = { + let blk = ctx.block(); + let length_ptr = blk.inttoptr(I64, &handle); + let length = blk.load(I32, &length_ptr); + let mut fits = blk.icmp_ult(I32, &max_index.to_string(), &length); + if let Some(bound) = len_bound { + let len_f = blk.uitofp(I32, &length, DOUBLE); + let within = blk.fcmp("ole", bound, &len_f); + fits = blk.and(I1, &fits, &within); + } + let base = blk.add(I64, &handle, "16"); + blk.br(&join_label); + (fits, base) + }; + ctx.current_block = join_idx; + let blk = ctx.block(); + let pass = blk.phi( + I1, + &[ + ("false", &pre_label), + ("false", &deref_label), + (&pass, &len_label), + ], + ); + let base = blk.phi( + I64, + &[("0", &pre_label), ("0", &deref_label), (&base, &len_label)], + ); + blk.store(I64, &base, base_slot); + pass +} + pub(super) fn lower_guarded_array_index_get( ctx: &mut FnCtx<'_>, arr_box: &str, diff --git a/crates/perry-codegen/src/expr/index_set.rs b/crates/perry-codegen/src/expr/index_set.rs index a15f75a567..ac8e91bffc 100644 --- a/crates/perry-codegen/src/expr/index_set.rs +++ b/crates/perry-codegen/src/expr/index_set.rs @@ -517,6 +517,14 @@ pub(crate) fn lower( index, value, } => { + // Step 4b / #10741: a planned-bare element store inside a loop + // region (a dense raw-f64 array, the index and value proven). + if let Some(result) = crate::stmt::region_loop::try_lower_bare_index_set(ctx, expr)? { + if value_discarded { + return Ok(double_literal(0.0)); + } + return Ok(result); + } if let Some(result) = super::typed_array_rmw::try_lower_guarded_uint32_add( ctx, object, diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 17af279d8e..bbbe0139c5 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -233,6 +233,8 @@ mod index_set_barrier_tests; mod instanceof_imported_rhs_tests; mod record_value; #[cfg(test)] +mod region_array_loop_tests; +#[cfg(test)] mod region_loop_tests; mod repsel_gates; mod scalar_slot_root; @@ -3082,8 +3084,8 @@ mod unary_bigint_tests; mod unary_bitnot_tests; pub(crate) use index_get::{ affine_counter_occurrences, affine_index_fits_i64, emit_affine_index_i64_with, - emit_array_region_guard, numeric_index_has_integer_array_index_proof, - packed_f64_loop_index_parts, + emit_array_region_guard, emit_typed_f64_region_guard, + numeric_index_has_integer_array_index_proof, packed_f64_loop_index_parts, ArrayRegionDense, }; pub(crate) use masked_window::masked_window_fact_for_index; /// Rooting coverage for the computed-store arms the TS corpora cannot reach diff --git a/crates/perry-codegen/src/expr/proxy_reflect.rs b/crates/perry-codegen/src/expr/proxy_reflect.rs index 2fb4fbd147..ed1c9faab8 100644 --- a/crates/perry-codegen/src/expr/proxy_reflect.rs +++ b/crates/perry-codegen/src/expr/proxy_reflect.rs @@ -1422,6 +1422,10 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { { return Ok(v); } + // #10741: a planned-bare element store (`a[i] = v`). + if let Some(v) = crate::stmt::region_loop::try_lower_bare_index_set(ctx, expr)? { + return Ok(v); + } } if let Expr::String(property) = key.as_ref() { if matches!(property.as_str(), "caller" | "arguments") diff --git a/crates/perry-codegen/src/expr/region_array_loop_tests.rs b/crates/perry-codegen/src/expr/region_array_loop_tests.rs new file mode 100644 index 0000000000..42acba08f8 --- /dev/null +++ b/crates/perry-codegen/src/expr/region_array_loop_tests.rs @@ -0,0 +1,400 @@ +//! #10741: loop regions over arrays indexed by the loop COUNTER, with +//! multi-statement bodies, element stores and calls — what the emitted IR +//! must contain. +//! +//! The runtime half (values equal node's when a call reshapes the array mid +//! loop, throws, or the counter/bound is written elsewhere) is +//! `test-files/test_gap_region_counter_loops.ts`. These tests pin what no +//! output can show: that the loop IS admitted (bare `load`/`store double` in +//! F-body, no per-access guard call), that the guard tests the facts the bare +//! accesses rely on, and that a loop whose counter or bound some other +//! statement writes is NOT admitted. + +use super::class_field_barrier_tests::ir_opts; +use crate::{compile_module, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{ + BinaryOp, CompareOp, Expr, Function, Module, ModuleInitKind, Param, Stmt, UnaryOp, UpdateOp, +}; + +const A: u32 = 1; +const B: u32 = 2; +const N: u32 = 3; +const F: u32 = 4; +const I: u32 = 5; + +fn opts() -> CompileOptions { + let mut o = ir_opts(); + o.is_entry_module = false; + o +} + +fn param(id: u32, name: &str, ty: Type) -> Param { + Param { + id, + name: name.to_string(), + ty, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +fn at(arr: u32) -> Expr { + Expr::IndexGet { + object: Box::new(Expr::LocalGet(arr)), + index: Box::new(Expr::LocalGet(I)), + } +} + +/// `arr[i] = value` (the `PutValueSet` form assignment statements lower to). +fn set(arr: u32, value: Expr) -> Stmt { + Stmt::Expr(Expr::PutValueSet { + target: Box::new(Expr::LocalGet(arr)), + key: Box::new(Expr::LocalGet(I)), + value: Box::new(value), + receiver: Box::new(Expr::LocalGet(arr)), + strict: false, + }) +} + +fn add(l: Expr, r: Expr) -> Expr { + Expr::Binary { + op: BinaryOp::Add, + left: Box::new(l), + right: Box::new(r), + } +} + +/// `a[i] = a[i] + b[i]; if (a[i] > 100) b[i] = -b[i];` — two statements, a +/// branch, three element reads and two element stores. +fn physics_body() -> Vec { + vec![ + set(A, add(at(A), at(B))), + Stmt::If { + condition: Expr::Compare { + op: CompareOp::Gt, + left: Box::new(at(A)), + right: Box::new(Expr::Number(100.0)), + }, + then_branch: vec![set( + B, + Expr::Unary { + op: UnaryOp::Neg, + operand: Box::new(at(B)), + }, + )], + else_branch: None, + }, + ] +} + +/// `function probe(a, b, n, f) { for (let i = 0; i < n; i++) { body } return 0; }` +/// with `a`/`b` of type `elem`, and `update` replacing `i++` when given. +fn probe_ir(name: &str, elem: Type, body: Vec, update: Option) -> String { + let mut m = Module::new(name); + m.functions = vec![Function { + id: 1, + name: "probe".to_string(), + type_params: Vec::new(), + params: vec![ + param(A, "a", elem.clone()), + param(B, "b", elem), + param(N, "n", Type::Number), + param(F, "f", Type::Any), + ], + return_type: Type::Number, + body: vec![ + Stmt::For { + init: Some(Box::new(Stmt::Let { + id: I, + name: "i".to_string(), + ty: Type::Number, + mutable: true, + init: Some(Expr::Integer(0)), + })), + condition: Some(Expr::Compare { + op: CompareOp::Lt, + left: Box::new(Expr::LocalGet(I)), + right: Box::new(Expr::LocalGet(N)), + }), + update: Some(update.unwrap_or(Expr::Update { + id: I, + op: UpdateOp::Increment, + prefix: false, + })), + body, + }, + Stmt::Return(Some(Expr::Integer(0))), + ], + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }]; + m.init_kind = ModuleInitKind::Eager; + String::from_utf8(compile_module(&m, opts()).expect("module compiles")).expect("UTF-8 IR") +} + +fn number_array() -> Type { + Type::Array(Box::new(Type::Number)) +} + +/// The probe function's text, split into (label, lines) blocks. +fn probe_blocks(ir: &str) -> Vec<(String, Vec)> { + let mut out: Vec<(String, Vec)> = Vec::new(); + let mut in_fn = false; + for line in ir.lines() { + if line.starts_with("define ") && line.contains("probe") { + in_fn = true; + out.push(("entry".to_string(), Vec::new())); + continue; + } + if !in_fn { + continue; + } + if line.starts_with('}') { + in_fn = false; + continue; + } + if !line.starts_with(' ') && line.ends_with(':') { + out.push((line.trim_end_matches(':').to_string(), Vec::new())); + } else if let Some(b) = out.last_mut().filter(|_| !line.trim().is_empty()) { + b.1.push(line.trim().to_string()); + } + } + out +} + +/// Lines of the blocks reachable from an F-body entry (`rloop.fast*`) +/// without leaving through the split's join. +fn f_body(ir: &str) -> Vec { + let blocks = probe_blocks(ir); + let succ = |lines: &[String]| -> Vec { + lines + .last() + .map(|t| { + t.split("label %") + .skip(1) + .map(|x| { + x.chars() + .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '.') + .collect() + }) + .collect() + }) + .unwrap_or_default() + }; + let mut seen: Vec = Vec::new(); + let mut work: Vec = blocks + .iter() + .filter(|(l, _)| l.starts_with("rloop.fast")) + .map(|(l, _)| l.clone()) + .collect(); + while let Some(l) = work.pop() { + if l.starts_with("rloop.join") || seen.contains(&l) { + continue; + } + if let Some((_, lines)) = blocks.iter().find(|(b, _)| *b == l) { + work.extend(succ(lines)); + } + seen.push(l); + } + let mut out = Vec::new(); + for (label, lines) in &blocks { + if seen.contains(label) { + out.push(format!("{label}:")); + out.extend(lines.iter().cloned()); + } + } + out +} + +#[test] +fn a_multi_statement_counter_loop_is_admitted_with_bare_element_accesses() { + let ir = probe_ir("rarr_physics", number_array(), physics_body(), None); + let f = f_body(&ir); + assert!(!f.is_empty(), "the loop formed no region:\n{ir}"); + let f_text = f.join("\n"); + assert!( + f_text.contains("store double"), + "F-body must store the element as a raw double:\n{f_text}" + ); + assert!( + !f_text.contains("index_get_guard") && !f_text.contains("index_set_guard"), + "F-body must not guard an element access:\n{f_text}" + ); + assert!( + !f_text.contains("@js_dynamic_neg(") + && !f_text.contains("@js_rel_gt(") + && !f_text.contains("@js_dynamic_string_or_number_add("), + "a bare element read is a Number: no dynamic operator in F-body:\n{f_text}" + ); + // The facts the bare accesses rely on, in the preheader guard: the dense + // raw-f64 layout bit, the store word (integrity bits), the bound against + // the length. + assert!( + ir.contains(", 8388608"), + "the guard must test GC_ARRAY_RAW_F64_LAYOUT:\n{ir}" + ); + assert!( + ir.contains(", 67600639"), + "a storing region must test the integrity bits:\n{ir}" + ); + assert!( + ir.contains("fcmp ole double"), + "the guard must test the counter's bound against the length:\n{ir}" + ); +} + +#[test] +fn a_float64array_receiver_reads_canonicalise_nan() { + let ir = probe_ir( + "rarr_typed", + Type::Named("Float64Array".to_string()), + physics_body(), + None, + ); + let f_text = f_body(&ir).join("\n"); + assert!(!f_text.is_empty(), "the loop formed no region:\n{ir}"); + assert!( + ir.contains("rloop.ta"), + "a receiver not declared a plain Array must also admit a Float64Array:\n{ir}" + ); + assert!( + f_text.contains("fcmp ord double"), + "a Float64Array read may hold any NaN payload: F must canonicalise it:\n{f_text}" + ); + let plain = probe_ir("rarr_plain", number_array(), physics_body(), None); + assert!( + !plain.contains("rloop.ta"), + "a receiver declared a plain Array admits only a dense array:\n{plain}" + ); +} + +#[test] +fn a_call_in_the_body_sets_the_dirty_flag_and_the_next_iteration_rechecks() { + let mut body = physics_body(); + body.insert( + 1, + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::LocalGet(F)), + args: vec![Expr::LocalGet(A)], + type_args: Vec::new(), + byte_offset: 0, + }), + ); + let ir = probe_ir("rarr_call", number_array(), body, None); + let f = f_body(&ir); + assert!( + !f.is_empty(), + "a call must end the facts, not refuse the loop:\n{ir}" + ); + assert!( + ir.contains("rloop.recheck"), + "the iteration after the call must re-check the facts:\n{ir}" + ); + // After the call, F-body stores `true` into the dirty flag, and an + // element access after it is not bare (the guarded tier is back). + let f_text = f.join("\n"); + assert!( + f.iter().any(|l| l.starts_with("store i1 true")), + "F-body must set the dirty flag after the call:\n{f_text}" + ); + assert!( + f.iter() + .any(|l| l.contains("index_get_guard") || l.contains("@js_rel_gt(")), + "an element access after the call must not be bare:\n{f_text}" + ); + assert!( + f.iter().any(|l| l.starts_with("store double")), + "the accesses before the call stay bare:\n{f_text}" + ); +} + +/// C1's shape for the counter: every write of the counter and of its bound +/// must be accounted for, in the body AND the condition AND the update. +#[test] +fn a_counter_or_bound_written_outside_the_update_is_not_a_region_index() { + // `i` also written in the body. + let mut body = physics_body(); + body.push(Stmt::Expr(Expr::Update { + id: I, + op: UpdateOp::Increment, + prefix: false, + })); + let ir = probe_ir("rarr_counter_body", number_array(), body, None); + assert!( + !ir.contains("rloop.fast"), + "a counter written in the body is not a region index:\n{ir}" + ); + // The bound written in the update (`i++, n = n + 1`). + let update = Expr::Sequence(vec![ + Expr::Update { + id: I, + op: UpdateOp::Increment, + prefix: false, + }, + Expr::LocalSet(N, Box::new(add(Expr::LocalGet(N), Expr::Integer(1)))), + ]); + let ir = probe_ir( + "rarr_bound_update", + number_array(), + physics_body(), + Some(update), + ); + assert!( + !ir.contains("rloop.fast"), + "a bound written in the update is not a region bound:\n{ir}" + ); + // The bound written in the body. + let mut body = physics_body(); + body.push(Stmt::Expr(Expr::LocalSet( + N, + Box::new(add(Expr::LocalGet(N), Expr::Integer(1))), + ))); + let ir = probe_ir("rarr_bound_body", number_array(), body, None); + assert!( + !ir.contains("rloop.fast"), + "a bound written in the body is not a region bound:\n{ir}" + ); +} + +#[test] +fn a_store_of_a_value_not_proven_a_number_is_not_bare() { + // `b[i] = b[i] + 1; a[i] = "s";` — the first store is bare, the second + // must be today's store (it clears the array's raw-f64 layout). + let body = vec![ + set(B, add(at(B), Expr::Integer(1))), + set(A, Expr::String("s".to_string())), + ]; + let ir = probe_ir("rarr_string_store", number_array(), body, None); + let f = f_body(&ir); + let f_text = f.join("\n"); + assert!(!f.is_empty(), "the loop formed no region:\n{ir}"); + // Bare stores are emitted in the F-body's own blocks; today's store + // lowers inside its `idxset.*` blocks. + let mut label = ""; + let mut bare_stores = 0; + for l in &f { + if l.ends_with(':') { + label = l; + } else if l.starts_with("store double") && !label.starts_with("idxset") { + bare_stores += 1; + } + } + assert_eq!( + bare_stores, 1, + "only `b[i] = b[i] + 1` may be a bare raw store:\n{f_text}" + ); + assert!( + f.iter() + .any(|l| l.contains("index_set") && l.contains("call ")), + "the string store must take today's store:\n{f_text}" + ); +} diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index 0262d4b66f..210c97be8d 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -271,6 +271,7 @@ fn lower_stmts_inner(ctx: &mut FnCtx<'_>, stmts: &[Stmt], emit_shadow_clears: bo continue; } lower_stmt(ctx, &stmts[i])?; + region_loop::after_stmt(ctx, &stmts[i]); // Representation-selection Phase 2: a TOP-LEVEL `Stmt::Let` of a // pre-pass-proven typed-array binding makes the binding "ready" — the // dominance mirror of the collector's sequential judgment. Later call diff --git a/crates/perry-codegen/src/stmt/region_loop/arrays.rs b/crates/perry-codegen/src/stmt/region_loop/arrays.rs index 4a29d8b5ac..8797a34b8f 100644 --- a/crates/perry-codegen/src/stmt/region_loop/arrays.rs +++ b/crates/perry-codegen/src/stmt/region_loop/arrays.rs @@ -32,6 +32,18 @@ //! F-body path that collects must leave through a re-check (the dirty flag or //! an every-iteration re-check), so the next iteration never reads through a //! stale base. +//! +//! # Counter-indexed accesses and stores (#10741) +//! +//! An index is also proven when it is the loop's COUNTER (`for (...; i < B; +//! i++)`, see [`Env`]), or a body-local copy of it (`a[i] += v` spills its +//! base and key into `const` temps), so a real loop body over `a[i]` gets +//! the region too. Such a receiver, and any receiver the region STORES +//! into, is guarded as a DENSE raw-f64 array (`emit_array_region_guard`'s +//! `dense` facts): a bare read is one `load double` that is a Number by +//! construction ([`is_f64_index_read`]), and a store of a proven double is +//! one `store double` ([`try_lower_bare_index_set`]) that changes neither +//! the length, nor the layout, nor anything the collector traces. use super::*; use crate::inst::LlInst; @@ -44,12 +56,277 @@ const MAX_STATIC_INDEX: i64 = 1 << 24; #[derive(Clone)] pub(crate) struct ArrayRecv { pub(super) recv: Recv, - /// Every planned read's index lies in `[0, max_index]`. + /// Every planned static-index access lies in `[0, max_index]`. pub(super) max_index: u32, + /// The receiver's accesses need the dense raw-f64 facts (see the module + /// doc): it is stored into, or indexed by the loop counter. + pub(super) dense: bool, + /// The region stores into it. + pub(super) store: bool, + /// A dense receiver not statically a plain `Array` may also be an owning + /// `Float64Array` (`emit_typed_f64_region_guard`): its reads then + /// canonicalise a NaN. + pub(super) typed: bool, + /// Some access is indexed by this loop counter (its bound is guarded). + pub(super) counter: Option, + /// Body-local `const` copies of the binding (see [`Env`]). + pub(super) aliases: Vec, /// `i64` alloca: the element base, valid while the region's `valid` flag is. pub(super) base_slot: String, } +impl ArrayRecv { + fn is(&self, object: &Expr) -> bool { + match object { + Expr::LocalGet(id) => self.recv == Recv::Local(*id) || self.aliases.contains(id), + _ => false, + } + } +} + +/// What a candidate array's accesses need, over every access in the body. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct ArrayUse { + /// The largest static index (`0` when there is none). + pub(super) max_index: u32, + /// Some access is indexed by the loop counter. + pub(super) counter: bool, + /// Some access stores. + pub(super) store: bool, +} + +impl ArrayUse { + /// The dense raw-f64 facts are required (module doc). + pub(super) fn dense(&self) -> bool { + self.counter || self.store + } +} + +/// `for (...; i < B; i++)`: the counter `i`, written by the update and +/// nowhere else (not in the body, not in the condition), and its bound `B`, +/// an integer literal or a plain local nothing in the loop writes. At the +/// top of every iteration `i < B` held and, `i` being an integer `>= 0` at +/// the guard and only ever incremented, `0 <= i <= B - 1`: the guard checks +/// the entry value and `B <= length` once. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Counter { + pub(super) id: u32, + pub(super) bound: Bound, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Bound { + Lit(i64), + Local(u32), +} + +/// The loop facts an index proof may use: the counter, and the body-local +/// copies (`const t = x;`) of a binding the loop never writes, each declared +/// once at the body's top level, never written again, and used only by the +/// statements after its declaration — so every use sees the value of the +/// binding it copies (the compound-assignment spill `a[i] += v` makes two). +#[derive(Clone, Default)] +pub(crate) struct Env { + pub(super) counter: Option, + pub(super) aliases: HashMap, +} + +impl Env { + pub(super) fn resolve(&self, id: u32) -> u32 { + self.aliases.get(&id).copied().unwrap_or(id) + } + + /// The array receiver an access's `object` names. + pub(super) fn array(&self, object: &Expr) -> Option { + match object { + Expr::LocalGet(id) => Some(Recv::Local(self.resolve(*id))), + _ => None, + } + } + + /// A proven index: `Some(Some(c))` static in `[0, c]`, `Some(None)` the + /// counter. + pub(super) fn index(&self, e: &Expr) -> Option> { + if let Some(c) = static_index_max(e) { + return Some(Some(c)); + } + match (self.counter, e) { + (Some(c), Expr::LocalGet(id)) if self.resolve(*id) == c.id => Some(None), + _ => None, + } + } +} + +/// An element store: `IndexSet`, or the `o[k] = v` reference form +/// `PutValueSet` with a computed key and the target as its own receiver. +/// `(object, index, value)`. +pub(super) fn element_store(e: &Expr) -> Option<(&Expr, &Expr, &Expr)> { + match e { + Expr::IndexSet { + object, + index, + value, + } => Some((object, index, value)), + Expr::PutValueSet { + target, + key, + value, + receiver, + .. + } if !matches!(key.as_ref(), Expr::String(_)) + && matches!((target.as_ref(), receiver.as_ref()), + (Expr::LocalGet(a), Expr::LocalGet(b)) if a == b) => + { + Some((target, key, value)) + } + _ => None, + } +} + +/// The binding is DECLARED a plain `Array` (a hint, not a fact): its region +/// then admits only a dense array at the guard, and its reads skip the NaN +/// canonicalisation a `Float64Array` slot needs. A typed array passed there +/// fails the guard and runs today's loop. +pub(super) fn declared_plain_array(ctx: &FnCtx<'_>, r: Recv) -> bool { + use perry_hir::types::Type as HirType; + let Recv::Local(id) = r else { + return false; + }; + if ctx.reassigned_locals.contains(&id) { + return false; + } + match crate::type_analysis::static_type_of(ctx, &Expr::LocalGet(id)) { + Some(HirType::Array(_)) | Some(HirType::Tuple(_)) => true, + Some(HirType::Generic { ref base, .. }) => base == "Array", + _ => false, + } +} + +/// A local whose value only this function's visible writes can change. +fn plain_local(ctx: &FnCtx<'_>, id: u32) -> bool { + (ctx.locals.contains_key(&id) || ctx.local_slot_reps.contains_key(&id)) + && !ctx.boxed_vars.contains(&id) + && !ctx.prealloc_boxes.contains(&id) + && !ctx.tdz_boxes.contains(&id) + && !ctx.module_globals.contains_key(&id) + && !ctx.closure_captures.contains_key(&id) +} + +fn mentions(e: &Expr, id: u32) -> bool { + let hit = match e { + Expr::LocalGet(x) | Expr::LocalSet(x, _) => *x == id, + Expr::Update { id: x, .. } => *x == id, + _ => false, + }; + let mut found = hit; + if !found { + perry_hir::walker::walk_expr_children(e, &mut |c| found |= mentions(c, id)); + } + found +} + +fn stmt_mentions(s: &Stmt, id: u32) -> bool { + perry_hir::walker::stmt_any_expr(s, &mut |e| mentions(e, id)) +} + +/// The [`Env`] of a loop. +pub(super) fn loop_env( + ctx: &FnCtx<'_>, + cond: Option<&Expr>, + body: &[Stmt], + update: Option<&Expr>, +) -> Env { + let mut ctl: Vec<&Expr> = Vec::new(); + ctl.extend(cond); + ctl.extend(update); + // EVERY write in the loop: body, condition and update. + let written = assigned(body, &ctl); + let counter = (|| { + let id = match update? { + Expr::Update { + id, + op: perry_hir::UpdateOp::Increment, + .. + } => *id, + _ => return None, + }; + let Expr::Compare { + op: CompareOp::Lt, + left, + right, + } = cond? + else { + return None; + }; + if !matches!(left.as_ref(), Expr::LocalGet(x) if *x == id) || !plain_local(ctx, id) { + return None; + } + // The update is the counter's only write. + if assigned(body, &[cond?]).contains(&id) { + return None; + } + let bound = match right.as_ref() { + Expr::Integer(k) if (0..=i64::from(i32::MAX)).contains(k) => Bound::Lit(*k), + Expr::Number(n) if n.fract() == 0.0 && (0.0..=f64::from(i32::MAX)).contains(n) => { + Bound::Lit(*n as i64) + } + Expr::LocalGet(b) if *b != id && !written.contains(b) && plain_local(ctx, *b) => { + Bound::Local(*b) + } + _ => return None, + }; + Some(Counter { id, bound }) + })(); + let mut aliases = HashMap::new(); + for (k, s) in body.iter().enumerate() { + let Stmt::Let { + id, + init: Some(Expr::LocalGet(src)), + .. + } = s + else { + continue; + }; + let src_ok = counter.is_some_and(|c| c.id == *src) + || (!written.contains(src) && receiver_eligible(ctx, Recv::Local(*src))); + let decls = body + .iter() + .filter(|x| matches!(x, Stmt::Let { id: y, .. } if y == id)) + .count(); + // Written only by its declaration (`assigned` counts the `Let`, so + // look for any other write), used only after it. + let rewritten = body.iter().enumerate().any(|(j, x)| { + j != k + && perry_hir::walker::stmt_any_expr(x, &mut |e| { + let mut w = false; + fn writes(e: &Expr, id: u32, w: &mut bool) { + match e { + Expr::LocalSet(x, _) | Expr::Update { id: x, .. } if *x == id => { + *w = true + } + _ => {} + } + perry_hir::walker::walk_expr_children(e, &mut |c| writes(c, id, w)); + } + writes(e, *id, &mut w); + w + }) + }); + let used_before = + body[..k].iter().any(|x| stmt_mentions(x, *id)) || ctl.iter().any(|e| mentions(e, *id)); + if src_ok + && decls == 1 + && !rewritten + && !used_before + && !ctx.boxed_vars.contains(id) + && !ctx.closure_captures.contains_key(id) + { + aliases.insert(*id, *src); + } + } + Env { counter, aliases } +} + /// `[0, c]` when `e` is `x & c` / `c & x` (`c >= 0`, the result of ToInt32 /// masking) or the literal `c`. pub(super) fn static_index_max(e: &Expr) -> Option { @@ -90,76 +367,58 @@ fn quiet(ctx: &FnCtx<'_>, e: &Expr) -> bool { } /// The array candidates of a loop region: eligible bindings the loop never -/// assigns and never reads by static key, with the largest static index of -/// their reads. +/// assigns and never reads by static key, with what their proven-index +/// accesses need. pub(super) fn candidates( ctx: &FnCtx<'_>, cond: Option<&Expr>, body: &[Stmt], update: Option<&Expr>, -) -> HashMap { - let mut out: HashMap = HashMap::new(); + env: &Env, +) -> HashMap { + let mut out: HashMap = HashMap::new(); if cond.is_some_and(|c| !quiet(ctx, c)) || update.is_some_and(|u| !quiet(ctx, u)) { return out; } - let mut reads: Vec<(u32, u32)> = Vec::new(); - fn e_walk(e: &Expr, out: &mut Vec<(u32, u32)>) { - if let Expr::IndexGet { object, index } = e { - if let (Expr::LocalGet(id), Some(c)) = (object.as_ref(), static_index_max(index)) { - out.push((*id, c)); - } - } - perry_hir::walker::walk_expr_children(e, &mut |c| e_walk(c, out)); - } - fn s_walk(s: &Stmt, out: &mut Vec<(u32, u32)>) { - match s { - Stmt::Let { init: Some(e), .. } - | Stmt::Expr(e) - | Stmt::Throw(e) - | Stmt::Return(Some(e)) => e_walk(e, out), - Stmt::If { - condition, - then_branch, - else_branch, - } => { - e_walk(condition, out); - then_branch.iter().for_each(|s| s_walk(s, out)); - if let Some(b) = else_branch { - b.iter().for_each(|s| s_walk(s, out)); - } - } - Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { - e_walk(condition, out); - body.iter().for_each(|s| s_walk(s, out)); - } - Stmt::For { - init, - condition, - update, - body, - } => { - if let Some(i) = init { - s_walk(i, out); + // (binding, static max or counter, store) + let mut uses: Vec<(u32, Option, bool)> = Vec::new(); + let mut walk = |e: &Expr| -> bool { + // `numeric`: `e` is an operand a Number consumer reads (arithmetic, + // a relational compare, a `Math.*` argument, a stored element). A + // counter-indexed read anywhere else (`const o = xs[i]`) is not what + // the dense facts serve: it does not make its array a candidate. + fn e_walk(e: &Expr, env: &Env, numeric: bool, out: &mut Vec<(u32, Option, bool)>) { + let access = match e { + Expr::IndexGet { object, index } => Some((object.as_ref(), index.as_ref(), false)), + _ => element_store(e).map(|(o, i, _)| (o, i, true)), + }; + if let Some((object, index, store)) = access { + if let (Some(Recv::Local(id)), Some(ix)) = (env.array(object), env.index(index)) { + if ix.is_some() || store || numeric { + out.push((id, ix, store)); + } } - condition.iter().for_each(|c| e_walk(c, out)); - update.iter().for_each(|u| e_walk(u, out)); - body.iter().for_each(|s| s_walk(s, out)); } - Stmt::Switch { - discriminant, - cases, - } => { - e_walk(discriminant, out); - for c in cases { - c.test.iter().for_each(|t| e_walk(t, out)); - c.body.iter().for_each(|s| s_walk(s, out)); + let consumes = match e { + Expr::Binary { .. } => true, + Expr::Unary { op, .. } => !matches!(op, UnaryOp::Not), + Expr::Compare { op, .. } => { + matches!( + op, + CompareOp::Lt | CompareOp::Le | CompareOp::Gt | CompareOp::Ge + ) } - } - _ => {} + _ => element_store(e).is_some() || super::plan::pure_math_args(e).is_some(), + }; + perry_hir::walker::walk_expr_children(e, &mut |c| e_walk(c, env, consumes, out)); } + e_walk(e, env, false, &mut uses); + false + }; + for s in body { + perry_hir::walker::stmt_any_expr(s, &mut walk); } - body.iter().for_each(|s| s_walk(s, &mut reads)); - if reads.is_empty() { + if uses.is_empty() { return out; } let mut extra: Vec<&Expr> = Vec::new(); @@ -168,28 +427,79 @@ pub(super) fn candidates( let written = assigned(body, &extra); let keyed: HashSet = accesses(body) .into_iter() - .map(|(r, _, _, _, _)| r) + .map(|(r, _, _, _, _)| match r { + Recv::Local(id) => Recv::Local(env.resolve(id)), + r => r, + }) .collect(); - for (id, c) in reads { + for (id, ix, store) in uses { let r = Recv::Local(id); if written.contains(&id) || keyed.contains(&r) || !receiver_eligible(ctx, r) { continue; } - let m = out.entry(r).or_insert(0); - *m = (*m).max(c); + let u = out.entry(r).or_default(); + match ix { + Some(c) => u.max_index = u.max_index.max(c), + None => u.counter = true, + } + u.store |= store; } out } /// The preheader / re-check guard of one array receiver; stores the base. pub(super) fn emit_guard(ctx: &mut FnCtx<'_>, a: &ArrayRecv) -> Result { + // The counter: an integer in `[0, i32::MAX]` here (it only grows), and + // its bound as an `f64` for `bound <= length`. + let mut counter_ok = "true".to_string(); + let mut bound = None; + if let Some(c) = a.counter { + let iv = lower_expr(ctx, &Expr::LocalGet(c.id))?; + let b = match c.bound { + Bound::Lit(k) => format!("{:?}", k as f64), + Bound::Local(id) => lower_expr(ctx, &Expr::LocalGet(id))?, + }; + let blk = ctx.block(); + let lo = blk.fcmp("oge", &iv, "0.0"); + let hi = blk.fcmp("ole", &iv, "2147483647.0"); + let in_range = blk.and(I1, &lo, &hi); + // No `fptosi` of an out-of-range value (poison): convert a stand-in. + let safe = blk.select(I1, &in_range, DOUBLE, &iv, "0.0"); + let as_int = blk.fptosi(DOUBLE, &safe, I32); + let back = blk.sitofp(I32, &as_int, DOUBLE); + let integral = blk.fcmp("oeq", &back, &iv); + counter_ok = blk.and(I1, &in_range, &integral); + bound = Some(b); + } let recv_box = lower_recv(ctx, a.recv)?; - Ok(crate::expr::emit_array_region_guard( - ctx, - &recv_box, - a.max_index, - &a.base_slot, - )) + let dense = a.dense.then_some(crate::expr::ArrayRegionDense { + store: a.store, + len_bound: bound.as_deref(), + }); + let mut pass = + crate::expr::emit_array_region_guard(ctx, &recv_box, a.max_index, dense, &a.base_slot); + if a.typed { + // Not a dense array: an owning Float64Array serves the same raw slots. + let ta = ctx.new_block("rloop.ta"); + let done = ctx.new_block("rloop.ta.done"); + let ta_l = ctx.block_label(ta); + let done_l = ctx.block_label(done); + let from = ctx.block().label.clone(); + ctx.block().cond_br(&pass, &done_l, &ta_l); + ctx.current_block = ta; + let pass_t = crate::expr::emit_typed_f64_region_guard( + ctx, + &recv_box, + a.max_index, + bound.as_deref(), + &a.base_slot, + ); + let ta_end = ctx.block().label.clone(); + ctx.block().br(&done_l); + ctx.current_block = done; + pass = ctx.block().phi(I1, &[("true", &from), (&pass_t, &ta_end)]); + } + Ok(ctx.block().and(I1, &pass, &counter_ok)) } /// The `IndexGet` hook: a planned-bare element read in F-body. @@ -203,14 +513,68 @@ pub(crate) fn try_lower_bare_index_get(ctx: &mut FnCtx<'_>, e: &Expr) -> Result< let Expr::IndexGet { object, index } = e else { return Ok(None); }; - let Some(r) = Recv::of(object) else { + let Some(ar) = a.arrays.iter().find(|x| x.is(object)).cloned() else { + return Ok(None); + }; + // The planner proved `index` in range (static, or the guarded counter) + // and JS-free. + let idx = lower_expr(ctx, index)?; + let b = ctx.current_block; + let i = ctx.func.blocks()[b].insts().len(); + if let Some(a) = ctx.region_loop_facts.last_mut() { + a.emitted_arr.push((b, i)); + } + let blk = ctx.block(); + let idx = blk.fptosi(DOUBLE, &idx, I64); + let base = blk.load(I64, &ar.base_slot); + let off = blk.shl(I64, &idx, "3"); + let addr = blk.add(I64, &base, &off); + let ptr = blk.inttoptr(I64, &addr); + let v = if ar.typed { + // A Float64Array slot may hold any NaN payload. + let raw = blk.load(DOUBLE, &ptr); + let ordered = blk.fcmp("ord", &raw, &raw); + blk.select(I1, &ordered, DOUBLE, &raw, "0x7FF8000000000000") + } else if ar.dense { + // Dense raw f64, in bounds: a canonical double, never a hole. + blk.load(DOUBLE, &ptr) + } else { + let raw = blk.load(I64, &ptr); + let hole = blk.icmp_eq(I64, &raw, crate::nanbox::TAG_HOLE_I64); + let v = blk.select(I1, &hole, I64, crate::nanbox::TAG_UNDEFINED_I64, &raw); + blk.bitcast_i64_to_double(&v) + }; + stat(2, 1); + Ok(Some(v)) +} + +/// The `IndexSet` hook: a planned-bare element store in F-body. The guard +/// proved a dense raw-f64 array with the index in `[0, length)` and the +/// integrity bits clear; a value [`expr_produces_canonical_raw_f64`] proves +/// a canonical double (asked again here, with the region's facts active) +/// then overwrites one raw slot: no length, layout or barrier work. A value +/// it cannot prove takes today's store (whose calls the verifier judges). +/// +/// [`expr_produces_canonical_raw_f64`]: crate::type_analysis::expr_produces_canonical_raw_f64 +pub(crate) fn try_lower_bare_index_set(ctx: &mut FnCtx<'_>, e: &Expr) -> Result> { + let Some(a) = ctx.region_loop_facts.last() else { + return Ok(None); + }; + if a.arrays.is_empty() || !a.bare.contains(&(e as *const Expr as usize)) { + return Ok(None); + } + let Some((object, index, value)) = element_store(e) else { return Ok(None); }; - let Some(ar) = a.arrays.iter().find(|x| x.recv == r).cloned() else { + let Some(ar) = a.arrays.iter().find(|x| x.is(object)).cloned() else { return Ok(None); }; - // The planner proved `index` in `[0, max_index]` and JS-free. + if !ar.dense || !ar.store || !crate::type_analysis::expr_produces_canonical_raw_f64(ctx, value) + { + return Ok(None); + } let idx = lower_expr(ctx, index)?; + let v = lower_expr(ctx, value)?; let b = ctx.current_block; let i = ctx.func.blocks()[b].insts().len(); if let Some(a) = ctx.region_loop_facts.last_mut() { @@ -222,14 +586,26 @@ pub(crate) fn try_lower_bare_index_get(ctx: &mut FnCtx<'_>, e: &Expr) -> Result< let off = blk.shl(I64, &idx, "3"); let addr = blk.add(I64, &base, &off); let ptr = blk.inttoptr(I64, &addr); - let raw = blk.load(I64, &ptr); - let hole = blk.icmp_eq(I64, &raw, crate::nanbox::TAG_HOLE_I64); - let v = blk.select(I1, &hole, I64, crate::nanbox::TAG_UNDEFINED_I64, &raw); - let v = blk.bitcast_i64_to_double(&v); - stat(2, 1); + // GC_STORE_AUDIT(POINTER_FREE): the region guard proved the array dense + // raw-f64 and `expr_produces_canonical_raw_f64` the value a canonical + // double — no GC pointer is written into the slot, so no write barrier. + blk.store(DOUBLE, &v, &ptr); + stat(3, 1); Ok(Some(v)) } +/// Is `e` a planned-bare element read of a DENSE region array (a canonical +/// double by the guard: the shared numeric-element predicate answers yes)? +pub(crate) fn is_f64_index_read(ctx: &FnCtx<'_>, e: &Expr) -> bool { + let Expr::IndexGet { object, .. } = e else { + return false; + }; + ctx.region_loop_facts.last().is_some_and(|a| { + a.bare.contains(&(e as *const Expr as usize)) + && a.arrays.iter().any(|x| x.dense && x.is(object)) + }) +} + /// Is `e` a planned-bare element read of the active region? (The number /// context's own tiers step aside for it.) pub(crate) fn is_bare_index_get(ctx: &FnCtx<'_>, e: &Expr) -> bool { @@ -258,7 +634,16 @@ pub(crate) fn emit_poll_refresh(ctx: &mut FnCtx<'_>) -> Result<()> { let recv_box = lower_recv(ctx, a.recv)?; let h = handle_of(ctx, &recv_box); let blk = ctx.block(); - let base = blk.array_elements_addr(&h); + let mut base = blk.array_elements_addr(&h); + if a.typed { + // A Float64Array (never moved) keeps its inline base. + let ty_addr = blk.sub(I64, &h, "8"); + let ty_ptr = blk.inttoptr(I64, &ty_addr); + let ty = blk.load(I8, &ty_ptr); + let is_ta = blk.icmp_eq(I8, &ty, "11"); // GC_TYPE_TYPED_ARRAY + let ta_base = blk.add(I64, &h, "16"); + base = blk.select(I1, &is_ta, I64, &ta_base, &base); + } blk.store(I64, &base, &a.base_slot); blk.br(&done_l); ctx.current_block = done; diff --git a/crates/perry-codegen/src/stmt/region_loop/bare.rs b/crates/perry-codegen/src/stmt/region_loop/bare.rs index d3bad48c01..ea4bd33c9f 100644 --- a/crates/perry-codegen/src/stmt/region_loop/bare.rs +++ b/crates/perry-codegen/src/stmt/region_loop/bare.rs @@ -428,6 +428,7 @@ pub(crate) fn try_lower_fact_add_tree(ctx: &mut FnCtx<'_>, e: &Expr) -> Result, + /// Statements after which F-body sets the dirty flag. + dirty_after: HashSet, /// Loop regions with array receivers: the body region split inside /// F-body (a per-iteration receiver read from the array). Its fact /// trees' generic arms set the loop's dirty flag (its `dirty_slot` is the @@ -307,6 +310,7 @@ pub(crate) struct Active { /// Array receivers (S3) and the emitted bare element reads. arrays: Vec, emitted_arr: Vec<(usize, usize)>, + dirty_after: HashSet, } thread_local! { @@ -417,7 +421,8 @@ fn begin_with( } // A loop region; failing that, a body region (per-iteration receiver). let cands = candidates_for_loop(ctx, cond, body, update); - let arrs = arrays::candidates(ctx, cond, body, update); + let env = arrays::loop_env(ctx, cond, body, update); + let arrs = arrays::candidates(ctx, cond, body, update, &env); // Array receivers (S3): the loop region, with the body region nested in // its F-body when there is one. Without a bare element read, today's // choice below. @@ -429,20 +434,21 @@ fn begin_with( body, cands.clone(), arrs, + &env, Some((cond, update)), inner.as_ref().map(|(k, ip)| (*k, &ip.bare, &ip.trees)), - ) - // A re-check every iteration re-derives the array's facts every - // iteration: that is the straight-line read's cost, plus a split. - .filter(|p| !p.arrays.is_empty() && p.recheck != Recheck::Always); + ); if std::env::var("PERRY_REGION_DIAG").as_deref() == Ok("4") { eprintln!( "[perry region] array plan in {}: {:?}", ctx.func.name, p.as_ref() - .map(|p| (p.recheck, p.arrays.len(), p.bare.len())) + .map(|p| (p.recheck, p.arrays.clone(), p.bare.len())) ); } + // A re-check every iteration re-derives the array's facts every + // iteration: that is the straight-line read's cost, plus a split. + let p = p.filter(|p| !p.arrays.is_empty() && p.recheck != Recheck::Always); if let Some(p) = p { let bare = p.bare.len() + inner.as_ref().map_or(0, |(_, ip)| ip.bare.len()); if pays(ctx, "loop", body_nodes(body), bare) { @@ -456,8 +462,16 @@ fn begin_with( let (first, inner) = match nested { Some((p, inner)) => (Some(p), inner), None => ( - plan(ctx, body, cands, HashMap::new(), Some((cond, update)), None) - .filter(|p| pays(ctx, "loop", body_nodes(body), p.bare.len())), + plan( + ctx, + body, + cands, + HashMap::new(), + &Env::default(), + Some((cond, update)), + None, + ) + .filter(|p| pays(ctx, "loop", body_nodes(body), p.bare.len())), None, ), }; @@ -500,10 +514,20 @@ fn begin_with( decode_slots(ctx, rv, &word); } let mut arrs: Vec = Vec::new(); - for (r, m) in &p.arrays { + for (r, u) in &p.arrays { let a = ArrayRecv { recv: *r, - max_index: *m, + max_index: u.max_index, + dense: u.dense(), + store: u.store, + typed: u.dense() && !arrays::declared_plain_array(ctx, *r), + counter: env.counter.filter(|_| u.counter), + aliases: env + .aliases + .iter() + .filter(|(_, src)| Recv::Local(**src) == *r) + .map(|(a, _)| *a) + .collect(), base_slot: ctx.func.alloca_entry(I64), }; let pass = arrays::emit_guard(ctx, &a)?; @@ -552,6 +576,7 @@ fn begin_with( token, spill_mode: false, arrays: arrs, + dirty_after: p.dirty_after, inner, parent_valid: None, retry: None, @@ -601,8 +626,16 @@ fn body_region_plan(ctx: &FnCtx<'_>, body: &[Stmt]) -> Option<(usize, Plan)> { } let mut cands = HashSet::new(); cands.insert(Recv::Local(*id)); - if let Some(p) = plan(ctx, tail, cands, HashMap::new(), None, None) - .filter(|p| pays(ctx, "body", body_nodes(tail), p.bare.len())) + if let Some(p) = plan( + ctx, + tail, + cands, + HashMap::new(), + &Env::default(), + None, + None, + ) + .filter(|p| pays(ctx, "body", body_nodes(tail), p.bare.len())) { return Some((i + 1, p)); } @@ -762,6 +795,7 @@ fn body_pending(p: Plan, body: &[Stmt], split_at: usize) -> Pending { token, spill_mode: false, arrays: Vec::new(), + dirty_after: HashSet::new(), inner: None, parent_valid: None, retry: None, @@ -878,6 +912,22 @@ fn any_flag(ctx: &mut FnCtx<'_>, flags: &[String]) -> String { acc } +/// `lower_stmts`' per-statement hook: in F-body, a statement after which the +/// facts may be stale sets the dirty flag (see `Plan::dirty_after`). +pub(crate) fn after_stmt(ctx: &mut FnCtx<'_>, s: &Stmt) { + let Some(slot) = ctx.region_loop_facts.last().and_then(|a| { + a.dirty_after + .contains(&(s as *const Stmt as usize)) + .then(|| a.dirty_slot.clone()) + .flatten() + }) else { + return; + }; + if !ctx.block().is_terminated() { + ctx.block().store(I1, "true", &slot); + } +} + pub(crate) fn end(ctx: &mut FnCtx<'_>, token: Option) { if let Some(t) = token { ctx.region_loops.retain(|p| p.token != t); @@ -948,6 +998,7 @@ pub(crate) fn lower_split( let trees = ctx.region_loops[idx].trees.clone(); let dirty_slot = ctx.region_loops[idx].dirty_slot.clone(); let arrs = ctx.region_loops[idx].arrays.clone(); + let dirty_after = ctx.region_loops[idx].dirty_after.clone(); // The layouts F-body must serve: a loop region's split copy was chosen by // its preheader (one layout); a body region chooses per iteration, so it // carries the all-inline copy and, unless every key it names is stored @@ -1130,6 +1181,7 @@ pub(crate) fn lower_split( spill: mode, arrays: arrs.clone(), emitted_arr: Vec::new(), + dirty_after: dirty_after.clone(), }); let r = match &inner { Some(ib) => { diff --git a/crates/perry-codegen/src/stmt/region_loop/numeric_expression.rs b/crates/perry-codegen/src/stmt/region_loop/numeric_expression.rs index dafa643a7b..96a4030d10 100644 --- a/crates/perry-codegen/src/stmt/region_loop/numeric_expression.rs +++ b/crates/perry-codegen/src/stmt/region_loop/numeric_expression.rs @@ -53,6 +53,7 @@ fn active(receivers: Vec, read: Option<&Expr>) -> Active { spill: false, arrays: Vec::new(), emitted_arr: Vec::new(), + dirty_after: HashSet::new(), } } diff --git a/crates/perry-codegen/src/stmt/region_loop/plan.rs b/crates/perry-codegen/src/stmt/region_loop/plan.rs index 29fb9ccef2..3e44b13787 100644 --- a/crates/perry-codegen/src/stmt/region_loop/plan.rs +++ b/crates/perry-codegen/src/stmt/region_loop/plan.rs @@ -159,8 +159,10 @@ pub(super) struct Planner<'p, 'a> { ctx: &'p FnCtx<'a>, cands: &'p HashSet, keys: &'p HashMap>, - /// Array receivers (S3) and their static index bound. - arrays: &'p HashMap, + /// Array receivers (S3) and what their accesses need. + arrays: &'p HashMap, + /// The loop counter and body-local copies an index proof may use. + env: &'p Env, bare: HashSet, /// Potential R keys, filtered against exact fresh bare reads at finish. number_reads: Vec<(usize, Recv, String)>, @@ -188,9 +190,31 @@ pub(super) struct Planner<'p, 'a> { /// A read is usable only after this pass has itself made it bare. proof_reads: &'p HashSet, proof_locals: &'p HashSet, + /// #10741: a statement after which the facts may be stale sets the + /// region's dirty flag (lowering stores it right after the statement), + /// so the facts are DIRTY, not lost, from there to the back edge: the + /// next iteration re-checks only when such a statement ran, instead of + /// the loop being refused or re-checked every iteration. + mark_dirty: bool, + dirty_after: HashSet, } impl Planner<'_, '_> { + /// `e` may run JS: every fact is stale. `PERRY_REGION_DIAG=5` names + /// the expression that staled them (the planner's refusal trace). + fn stale(&self, e: &Expr, st: &mut St) { + if st.as_ref().is_some_and(|m| !m.is_empty()) + && std::env::var("PERRY_REGION_DIAG").as_deref() == Ok("5") + { + let d = format!("{e:?}"); + eprintln!( + "[perry region] stale at {} in {}", + &d[..d.len().min(160)], + self.ctx.func.name + ); + } + kill(st); + } /// A primitive by construction, or a value the compiler proves a raw /// double (`expr_produces_canonical_raw_f64` is the predicate that already /// licenses an unguarded `fadd`). @@ -201,12 +225,78 @@ impl Planner<'_, '_> { self.bare.contains(&ptr) && self.proof_reads.contains(&ptr) } Expr::LocalGet(id) if self.proof_locals.contains(id) => true, + // A bare element read of a dense raw-f64 array: a double. + Expr::IndexGet { .. } => self.f64_element(e), Expr::Binary { left, right, .. } => self.prim(left) && self.prim(right), Expr::Unary { op, operand } if !matches!(op, UnaryOp::Not) => self.prim(operand), _ => prim(e) || crate::type_analysis::is_numeric_expr(self.ctx, e), } } + /// A planned-bare element read of a dense region array. + fn f64_element(&self, e: &Expr) -> bool { + let Expr::IndexGet { object, .. } = e else { + return false; + }; + self.bare.contains(&(e as *const Expr as usize)) + && self + .env + .array(object) + .and_then(|r| self.arrays.get(&r)) + .is_some_and(|u| u.dense()) + } + + /// A canonical double by construction, for a bare element STORE: the + /// mirror of `expr_produces_canonical_raw_f64` over this plan's bare + /// element reads (lowering asks that predicate again with the facts + /// active). Locals count only through the static predicate — never + /// through a region entry assumption. + fn num(&self, e: &Expr) -> bool { + match e { + Expr::Number(_) | Expr::Integer(_) => true, + Expr::IndexGet { .. } => self.f64_element(e), + // Every binary operator over two Numbers yields a Number. + Expr::Binary { left, right, .. } => self.num(left) && self.num(right), + Expr::Unary { op, operand } => { + matches!(op, UnaryOp::Neg | UnaryOp::Pos | UnaryOp::BitNot) && self.num(operand) + } + Expr::Conditional { + then_expr, + else_expr, + .. + } => self.num(then_expr) && self.num(else_expr), + _ if pure_math_args(e).is_some() => { + pure_math_args(e).is_some_and(|args| args.iter().all(|a| self.num(a))) + } + _ => crate::type_analysis::expr_produces_canonical_raw_f64(self.ctx, e), + } + } + + /// After its operands: is the element store `e` bare? Else the facts + /// are stale. + fn element_store(&mut self, e: &Expr, st: &mut St) { + let Some((object, index, value)) = arrays::element_store(e) else { + return self.stale(e, st); + }; + let r = self + .env + .array(object) + .filter(|r| self.arrays.get(r).is_some_and(|u| u.dense() && u.store)); + match r { + Some(r) + if self.env.index(index).is_some() + && self.num(value) + && st.as_ref().is_some_and(|m| m.get(&r) == Some(&FRESH)) => + { + if self.record { + self.bare.insert(e as *const Expr as usize); + self.bare_arrays.insert(r); + } + } + _ => self.stale(e, st), + } + } + fn covered(&self, r: Recv, key: &str) -> bool { self.keys .get(&r) @@ -237,7 +327,7 @@ impl Planner<'_, '_> { } // Today's tower: its miss path can reach a getter/setter or reshape // the receiver. - kill(st); + self.stale(e, st); } fn exprs(&mut self, es: &[Expr], mut st: St) -> St { @@ -271,7 +361,7 @@ impl Planner<'_, '_> { st = self.expr(object, st); match Recv::of(object) { Some(r) => self.access(e, r, property, false, false, &mut st), - None => kill(&mut st), + None => self.stale(e, &mut st), } st } @@ -297,7 +387,8 @@ impl Planner<'_, '_> { !crate::type_analysis::expr_produces_canonical_raw_f64(self.ctx, value); self.access(e, r, k, true, boxed, &mut st) } - _ => kill(&mut st), + _ if arrays::element_store(e).is_some() => self.element_store(e, &mut st), + _ => self.stale(e, &mut st), } st } @@ -308,7 +399,7 @@ impl Planner<'_, '_> { st = self.expr(callee, st); } st = self.exprs(args, st); - kill(&mut st); + self.stale(e, &mut st); st } Expr::LocalSet(_, v) => self.expr(v, st), @@ -318,10 +409,13 @@ impl Planner<'_, '_> { Expr::IndexGet { object, index } => { st = self.expr(object, st); st = self.expr(index, st); - let r = Recv::of(object).filter(|r| self.arrays.contains_key(r)); + let r = self + .env + .array(object) + .filter(|r| self.arrays.contains_key(r)); match r { Some(r) - if arrays::static_index_max(index).is_some() + if self.env.index(index).is_some() && st.as_ref().is_some_and(|m| m.get(&r) == Some(&FRESH)) => { if self.record { @@ -329,7 +423,34 @@ impl Planner<'_, '_> { self.bare_arrays.insert(r); } } - _ => kill(&mut st), + _ => self.stale(e, &mut st), + } + st + } + // #10741: an element store into a dense region array at a proven + // index, of a value proven a canonical double, runs no JS and + // changes no fact (not the length, not the layout). Anything + // else is today's store, which can. + Expr::IndexSet { + object, + index, + value, + } => { + st = self.expr(object, st); + st = self.expr(index, st); + st = self.expr(value, st); + self.element_store(e, &mut st); + st + } + // Pure `Math.*` over primitives runs no JS (a spread argument + // iterates, so it is not one of these). + _ if pure_math_args(e).is_some() => { + let args = pure_math_args(e).unwrap_or_default(); + for a in &args { + st = self.expr(a, st); + } + if !args.iter().all(|a| self.prim(a)) { + self.stale(e, &mut st); } st } @@ -371,7 +492,7 @@ impl Planner<'_, '_> { st = self.expr(left, st); st = self.expr(right, st); if !(self.prim(left) && self.prim(right)) { - kill(&mut st); + self.stale(e, &mut st); } st } @@ -382,7 +503,7 @@ impl Planner<'_, '_> { } st = self.expr(operand, st); if !matches!(op, UnaryOp::Not) && !self.prim(operand) { - kill(&mut st); + self.stale(e, &mut st); } st } @@ -403,7 +524,7 @@ impl Planner<'_, '_> { if !matches!(op, CompareOp::Eq | CompareOp::Ne) && !(self.prim(left) && self.prim(right)) { - kill(&mut st); + self.stale(e, &mut st); } st } @@ -438,7 +559,7 @@ impl Planner<'_, '_> { // A local ++/-- ToNumerics its operand (valueOf on an object). Expr::Update { id, .. } => { if !self.ctx.integer_locals.contains(id) { - kill(&mut st); + self.stale(e, &mut st); } st } @@ -451,7 +572,7 @@ impl Planner<'_, '_> { for k in kids { st = self.expr(k, st); } - kill(&mut st); + self.stale(e, &mut st); st } } @@ -459,7 +580,16 @@ impl Planner<'_, '_> { fn stmts(&mut self, ss: &[Stmt], mut st: St) -> St { for s in ss { + let before = if self.mark_dirty { st.clone() } else { None }; st = self.stmt(s, st); + if let (Some(b), Some(a)) = (&before, &st) { + if b.keys().any(|r| !a.contains_key(r)) { + if self.record { + self.dirty_after.insert(s as *const Stmt as usize); + } + st = Some(b.keys().map(|r| (*r, DIRTY)).collect()); + } + } } st } @@ -579,6 +709,55 @@ impl Planner<'_, '_> { } } +/// The arguments of a `Math.*` call that runs no JavaScript once its +/// arguments are primitives (`ToNumber` of a primitive cannot call out); +/// `None` for anything else, including the spread forms (they iterate). +pub(super) fn pure_math_args(e: &Expr) -> Option> { + match e { + Expr::MathFloor(..) + | Expr::MathCeil(..) + | Expr::MathRound(..) + | Expr::MathTrunc(..) + | Expr::MathSign(..) + | Expr::MathAbs(..) + | Expr::MathSqrt(..) + | Expr::MathLog(..) + | Expr::MathLog2(..) + | Expr::MathLog10(..) + | Expr::MathPow(..) + | Expr::MathMin(..) + | Expr::MathMax(..) + | Expr::MathImul(..) + | Expr::MathRandom + | Expr::MathSin(..) + | Expr::MathCos(..) + | Expr::MathTan(..) + | Expr::MathAsin(..) + | Expr::MathAcos(..) + | Expr::MathAtan(..) + | Expr::MathAtan2(..) + | Expr::MathCbrt(..) + | Expr::MathHypot(..) + | Expr::MathFround(..) + | Expr::MathF16round(..) + | Expr::MathClz32(..) + | Expr::MathExpm1(..) + | Expr::MathLog1p(..) + | Expr::MathSinh(..) + | Expr::MathCosh(..) + | Expr::MathTanh(..) + | Expr::MathAsinh(..) + | Expr::MathAcosh(..) + | Expr::MathAtanh(..) + | Expr::MathExp(..) => { + let mut args = Vec::new(); + perry_hir::walker::walk_expr_children(e, &mut |c| args.push(c)); + Some(args) + } + _ => None, + } +} + /// Anything that makes a body unsuitable for a split: a closure (a second /// lowering would emit it twice), `try` (handlers), labels, generators. pub(super) fn body_refused(ss: &[Stmt]) -> bool { @@ -911,8 +1090,10 @@ pub(super) struct Plan { pub(super) declared_locals: HashSet, pub(super) trees: HashSet, pub(super) recheck: Recheck, - /// Array receivers with a bare read, and their static index bound. - pub(super) arrays: Vec<(Recv, u32)>, + /// Array receivers with a bare access, and what their accesses need. + pub(super) arrays: Vec<(Recv, ArrayUse)>, + /// Statements after which F-body sets the dirty flag (`Planner::mark_dirty`). + pub(super) dirty_after: HashSet, } /// What the top of an iteration must do before F-body. @@ -940,7 +1121,8 @@ pub(super) fn plan( ctx: &FnCtx<'_>, tail: &[Stmt], cands: HashSet, - arrays: HashMap, + arrays: HashMap, + env: &Env, loop_ctl: Option<(Option<&Expr>, Option<&Expr>)>, inner: Option<(usize, &HashSet, &HashSet)>, ) -> Option { @@ -951,6 +1133,7 @@ pub(super) fn plan( tail, &cands, &arrays, + env, loop_ctl, inner, &empty_reads, @@ -988,6 +1171,7 @@ pub(super) fn plan( tail, &cands, &arrays, + env, loop_ctl, inner, &proof_reads, @@ -1033,7 +1217,8 @@ fn plan_once<'p, 'a>( ctx: &'p FnCtx<'a>, tail: &[Stmt], cands: &'p HashSet, - arrays: &'p HashMap, + arrays: &'p HashMap, + env: &'p Env, loop_ctl: Option<(Option<&Expr>, Option<&Expr>)>, inner: Option<(usize, &'p HashSet, &'p HashSet)>, proof_reads: &'p HashSet, @@ -1075,6 +1260,7 @@ fn plan_once<'p, 'a>( cands: &cands, keys: &keys, arrays: &arrays, + env, bare: HashSet::new(), number_reads: Vec::new(), number_local_uses: HashSet::new(), @@ -1090,6 +1276,9 @@ fn plan_once<'p, 'a>( record: true, proof_reads, proof_locals, + // Loop regions with array receivers and no nested body region. + mark_dirty: loop_ctl.is_some() && !arrays.is_empty() && inner.is_none(), + dirty_after: HashSet::new(), }; let end = match inner { // The nested body region's tail: F-tail keeps the loop's facts @@ -1151,9 +1340,10 @@ fn plan_once<'p, 'a>( crate::collectors::region_number_flow_reads(tail, &number_local_uses); number_local_uses.extend(flow_locals); let trees = std::mem::take(&mut p.trees); + let dirty_after = std::mem::take(&mut p.dirty_after); let bare_stores = std::mem::take(&mut p.bare_stores); let boxed_stores = std::mem::take(&mut p.boxed_stores); - let mut plan_arrays: Vec<(Recv, u32)> = std::mem::take(&mut p.bare_arrays) + let mut plan_arrays: Vec<(Recv, ArrayUse)> = std::mem::take(&mut p.bare_arrays) .into_iter() .map(|r| (r, arrays[&r])) .collect(); @@ -1282,5 +1472,6 @@ fn plan_once<'p, 'a>( trees, recheck, arrays: plan_arrays, + dirty_after, }) } diff --git a/crates/perry-codegen/src/stmt/stable_packed_loop.rs b/crates/perry-codegen/src/stmt/stable_packed_loop.rs index be5e95167d..cc73c3f3f5 100644 --- a/crates/perry-codegen/src/stmt/stable_packed_loop.rs +++ b/crates/perry-codegen/src/stmt/stable_packed_loop.rs @@ -1359,6 +1359,11 @@ fn finish_revalidated_read( use super::stable_packed_accumulator::collect_numeric_accumulators; pub(crate) fn has_numeric_index_fact(ctx: &FnCtx<'_>, expr: &Expr) -> bool { + // A loop region's bare read of a dense raw-f64 array (#10741): the + // region guard proved the slot a canonical double, with no fallback edge. + if crate::stmt::region_loop::is_f64_index_read(ctx, expr) { + return true; + } let Expr::IndexGet { object, index } = expr else { return false; }; diff --git a/test-files/test_gap_region_counter_loops.ts b/test-files/test_gap_region_counter_loops.ts new file mode 100644 index 0000000000..9e839c3ff2 --- /dev/null +++ b/test-files/test_gap_region_counter_loops.ts @@ -0,0 +1,293 @@ +// #10741: loop regions over arrays indexed by the loop COUNTER, with +// multi-statement bodies, element stores, pure Math calls and calls that can +// change the facts. The preheader guards each array once (dense raw-f64 +// layout, integrity bits, `bound <= length`, the counter a non-negative +// integer); F-body reads and writes raw slots. Every case below breaks one +// of those facts mid-loop, or starts without it, and must print what node +// prints. + +function mk(n: number, f: (i: number) => number): number[] { + const a: number[] = []; + for (let i = 0; i < n; i++) a.push(f(i)); + return a; +} + +function show(label: string, v: unknown): void { + console.log(label, JSON.stringify(v)); +} + +// The issue's particle step: five statements, two branches, compound +// assignment (its spilled base/key temps are copies of the binding/counter). +function step(x: number[], y: number[], vx: number[], vy: number[], n: number): number { + for (let k = 0; k < n; k++) + for (let i = 0; i < 64; i++) { + x[i] += vx[i]; + y[i] += vy[i]; + if (x[i] < 0 || x[i] > 100) vx[i] = -vx[i]; + if (y[i] < 0 || y[i] > 100) vy[i] = -vy[i]; + vy[i] += 0.01; + } + let s = 0; + for (let i = 0; i < 64; i++) s += x[i] + y[i]; + return Math.round(s * 1000); +} + +function stepTyped(x: Float64Array, y: Float64Array, vx: Float64Array, vy: Float64Array, n: number): number { + for (let k = 0; k < n; k++) + for (let i = 0; i < 64; i++) { + x[i] += vx[i]; + y[i] += vy[i]; + if (x[i] < 0 || x[i] > 100) vx[i] = -vx[i]; + if (y[i] < 0 || y[i] > 100) vy[i] = -vy[i]; + vy[i] += 0.01; + } + let s = 0; + for (let i = 0; i < 64; i++) s += x[i] + y[i]; + return Math.round(s * 1000); +} + +// Pure Math in the body (no JS can run): still bare. +function mathBody(a: number[], b: number[], n: number): number { + for (let i = 0; i < n; i++) { + const t = Math.sqrt(a[i] * a[i] + b[i] * b[i]); + a[i] = Math.max(t, 1); + b[i] = Math.floor(b[i] / 2) + Math.abs(a[i] - 3); + } + return a.reduce((p, c) => p + c, 0) + b.reduce((p, c) => p + c, 0); +} + +// A call mutates the receiver mid-loop: shrinks it. The rest of this +// iteration and the next ones must see the shorter array. +let shrinkAt = 5; +function shrink(a: number[], i: number): void { + if (i === shrinkAt) a.length = 8; +} +function callShrinks(a: number[], n: number): unknown[] { + const out: unknown[] = []; + for (let i = 0; i < n; i++) { + a[i] = a[i] + 1; + shrink(a, i); + out.push(a[i]); + a[i] = a[i] * 2; + } + return out; +} + +// A call writes a string into the array (its raw-f64 layout is cleared). +function poison(a: number[], i: number): void { + if (i === 3) (a as any)[i + 1] = "str"; +} +function callPoisons(a: number[], n: number): unknown[] { + const out: unknown[] = []; + for (let i = 0; i < n; i++) { + a[i] = a[i] + 1; + poison(a, i); + out.push(a[i] + 0); + } + return out; +} + +// A call freezes the array: later stores are silently ignored (sloppy). +function freezer(a: number[], i: number): void { + if (i === 2) Object.freeze(a); +} +function callFreezes(a: number[], n: number): number[] { + for (let i = 0; i < n; i++) { + a[i] = a[i] + 10; + freezer(a, i); + } + return a; +} + +// A call grows the array past its capacity (the elements move). +function grow(a: number[], i: number): void { + if (i === 4) for (let j = 0; j < 100; j++) a.push(j); +} +function callGrows(a: number[], n: number): number { + let s = 0; + for (let i = 0; i < n; i++) { + a[i] = a[i] + 1; + grow(a, i); + s += a[i]; + } + return s + a.length; +} + +// A call replaces an element's prototype path: a hole read after the call. +function holePunch(a: number[], i: number): void { + if (i === 1) delete (a as any)[6]; +} +function callHoles(a: number[], n: number): unknown[] { + const out: unknown[] = []; + for (let i = 0; i < n; i++) { + a[i] = a[i] + 1; + holePunch(a, i); + out.push(a[i]); + } + return out; +} + +// An exception thrown mid-loop: the stores before it are visible after. +function thrower(i: number): void { + if (i === 6) throw new Error("boom at " + i); +} +function throwsMidLoop(a: number[], n: number): string { + try { + for (let i = 0; i < n; i++) { + a[i] = a[i] * 3; + thrower(i); + a[i] = a[i] + 1; + } + } catch (e) { + return (e as Error).message + " " + JSON.stringify(a); + } + return "no throw"; +} + +// The counter written in the body: not a region index. +function counterInBody(a: number[], n: number): number[] { + for (let i = 0; i < n; i++) { + a[i] = a[i] + 1; + if (a[i] > 4) i++; + } + return a; +} + +// The bound written in the update clause and in the condition. +function boundInUpdate(a: number[], n: number): number[] { + for (let i = 0; i < n; i++, n--) a[i] = a[i] + 1; + return a; +} +function boundInCondition(a: number[], n: number): number[] { + let m = n; + for (let i = 0; i < (m = m - 1); i++) a[i] = a[i] + 1; + return a; +} + +// The array reassigned in the update clause: never a region receiver. +function arrayInUpdate(a: number[], b: number[], n: number): number[] { + let c = a; + for (let i = 0; i < n; i++, c = b) c[i] = c[i] + 100; + return a.concat(b); +} + +// Bound larger than the array: out-of-range reads are undefined, and a +// store past the end grows it (the guard fails; today's loop runs). +function pastEnd(a: number[], n: number): unknown[] { + const out: unknown[] = []; + for (let i = 0; i < n; i++) { + out.push(a[i]); + a[i] = i; + } + out.push(a.length); + return out; +} + +// A non-integer and a negative start: `a[0.5]`, `a[-1]` are properties. +function oddStarts(a: number[]): unknown[] { + const out: unknown[] = []; + for (let i = 0.5; i < 4; i++) out.push(a[i]); + for (let i = -2; i < 3; i++) { + out.push(a[i]); + a[i] = i * 2; + } + out.push((a as any)[-1], (a as any)[-2]); + return out; +} + +// Holes from `new Array(n)` and a mixed-element array (no raw-f64 layout). +function holesAndMixed(): unknown[] { + const h = new Array(6); + h[0] = 1; + h[3] = 4; + const m: any[] = [1, "2", 3, null, 5]; + const out: unknown[] = []; + for (let i = 0; i < 6; i++) { + out.push(h[i]); + h[i] = (h[i] ?? 0) + i; + } + for (let i = 0; i < 5; i++) { + out.push(m[i] + 1); + m[i] = m[i] + 1; + } + return out.concat(h, m); +} + +// NaN through a Float64Array (its slots may hold any NaN payload) and into +// a plain array. +function nanFlow(t: Float64Array, a: number[], n: number): unknown[] { + for (let i = 0; i < n; i++) { + a[i] = t[i] * 2; + t[i] = a[i] - 1; + } + return [Array.from(t).map(String), a.map(String)]; +} + +// A typed array of another kind, and a typed view over a shared buffer. +function otherKinds(n: number): unknown[] { + const i32 = new Int32Array(n); + const buf = new ArrayBuffer(8 * n); + const v1 = new Float64Array(buf); + const v2 = new Float64Array(buf); + for (let i = 0; i < n; i++) { + i32[i] = i * 3; + v1[i] = i + 0.5; + v2[i] = v1[i] * 2; + } + return [Array.from(i32), Array.from(v1), Array.from(v2)]; +} + +// Moving collections: the body allocates through a call every few +// iterations, so the young arrays move between iterations; the region must +// re-derive their element base. +let sink: unknown[] = []; +function churn(i: number): void { + if (i % 7 === 0) { + const junk: number[][] = []; + for (let j = 0; j < 400; j++) junk.push([j, j + 1, j + 2]); + sink = junk; + } +} +function movingGc(n: number): number { + let total = 0; + for (let round = 0; round < 30; round++) { + const a = mk(n, (i) => i + round); + const b = mk(n, (i) => i * 2); + for (let i = 0; i < n; i++) { + a[i] = a[i] + b[i]; + b[i] = a[i] - 1; + churn(i); + a[i] = a[i] + b[i]; + } + for (let i = 0; i < n; i++) total += a[i] + b[i]; + } + return total + sink.length; +} + +const X = mk(64, (i) => (i * 37) % 100); +const Y = mk(64, (i) => (i * 53) % 100); +const VX = mk(64, (i) => ((i * 7) % 11) - 5 + 0.25); +const VY = mk(64, (i) => ((i * 13) % 9) - 4 + 0.5); +show("step", step(X.slice(), Y.slice(), VX.slice(), VY.slice(), 300)); +show( + "stepTyped", + stepTyped(Float64Array.from(X), Float64Array.from(Y), Float64Array.from(VX), Float64Array.from(VY), 300), +); +show("math", mathBody(mk(16, (i) => i - 4), mk(16, (i) => 7 - i), 16)); +show("callShrinks", callShrinks(mk(12, (i) => i), 12)); +show("callPoisons", callPoisons(mk(8, (i) => i * 1.5), 8)); +show("callFreezes", callFreezes(mk(6, (i) => i), 6)); +show("callGrows", callGrows(mk(8, (i) => i), 8)); +show("callHoles", callHoles(mk(9, (i) => i), 9)); +show("throws", throwsMidLoop(mk(10, (i) => i), 10)); +show("counterInBody", counterInBody(mk(10, (i) => i), 10)); +show("boundInUpdate", boundInUpdate(mk(10, (i) => i), 10)); +show("boundInCondition", boundInCondition(mk(10, (i) => i), 10)); +show("arrayInUpdate", arrayInUpdate(mk(5, (i) => i), mk(5, (i) => 10 * i), 5)); +show("pastEnd", pastEnd(mk(4, (i) => i + 1), 7)); +show("oddStarts", oddStarts(mk(5, (i) => i * 10))); +show("holesAndMixed", holesAndMixed()); +const T = new Float64Array([1, NaN, -0, Infinity, 2.5]); +show("nanFlow", nanFlow(T, mk(5, () => 0), 5)); +show("otherKinds", otherKinds(5)); +show("movingGc", movingGc(50)); From b662351d3c4c070cfab7ff854104f067d157821d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 3 Oct 2026 12:06:46 +0200 Subject: [PATCH 2/2] changelog: key the loop-admission fragment to PR 11790 --- .../{PENDING-10741-loop-admission.md => 11790-loop-admission.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{PENDING-10741-loop-admission.md => 11790-loop-admission.md} (100%) diff --git a/changelog.d/PENDING-10741-loop-admission.md b/changelog.d/11790-loop-admission.md similarity index 100% rename from changelog.d/PENDING-10741-loop-admission.md rename to changelog.d/11790-loop-admission.md