From efa2050349849c116e7b36591a28340853c0abfc Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Sat, 19 Sep 2026 13:22:54 +0000 Subject: [PATCH 1/3] perf(codegen,hir): stop re-proving a loop-invariant array receiver on every element access (#10718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An indexed read on an ordinary `Array` cost 87 instructions per element, against 6 for the identical arithmetic on a `Float64Array` and 16 for node. None of it was a runtime call: callgrind attributes 88.3 instructions per element to straight-line code in `main`, of which **56 are loop-invariant receiver revalidation** re-executed every iteration — the NaN-box tag and handle-band test, the forwarding-flag follow, and a six-load live-head guard that re-reads gc_type, gc_flags, obj_flags, a volatile invalidation flag, length and capacity. perry already has tiers that hoist exactly this proof into the loop preheader and version the loop. They were declining at a single gate in `stmt/loops.rs` — `array_static_type_excluded` — a **declared static type** test sitting in front of a tier that is otherwise fully runtime-guarded. `const a: number[] = new Array(400)` reached it and measured 13.4; plain `new Array(400)` infers `Array` and did not, so ordinary JavaScript never got the tier it already had. Separately, and larger: `a[i] += 1` cost 948 instructions per element, 3.7x the identical `a[i] = a[i] + 1`, and a type annotation did not help. `lower/expr_assign.rs` minted the compound-assignment spill temporaries as `Type::Any`, erasing the receiver's array-ness and the index's integer-ness before codegen could see the statement — which is why no annotation could recover it. The temporaries now carry the operand types. Array read 87.0 -> 13.5 (node 16.3 — perry now wins 1.21x) a[i] += 1 948 -> 273 a[i] += b[i] 1025 -> 347 bare loop 4.0 -> 4.0 unchanged to the instruction Float64Array r/w 6.0/8.0 -> 6.0/8.0 unchanged to the instruction A particle simulation over four numeric arrays spends 60.9% fewer instructions (41.21 G -> 16.12 G) and 59% less peak RSS. The widened tier initially regressed a numeric window inside an otherwise non-numeric array by 33%, because the array-wide layout walk runs per loop entry. A window-scoped layout proof is used as a second chance after that walk declines; both shapes are now 12-13% wins, and arrays that are non-numeric from slot 0 are unchanged to the instruction. Claude-Session: https://claude.ai/code/session_01YaNfLEjMRdhCLtk3SB5MdJ --- crates/perry-codegen/src/stmt/loops.rs | 87 +++++++++- .../lower/compound_assign_temp_type_tests.rs | 152 ++++++++++++++++++ crates/perry-hir/src/lower/expr_assign.rs | 26 ++- crates/perry-hir/src/lower/mod.rs | 2 + crates/perry-runtime/src/array/header.rs | 66 ++++++++ crates/perry-runtime/src/array/mod.rs | 7 +- crates/perry-runtime/src/typed_feedback.rs | 9 +- scripts/local_binding_type_allowlist.json | 8 + 8 files changed, 344 insertions(+), 13 deletions(-) create mode 100644 crates/perry-hir/src/lower/compound_assign_temp_type_tests.rs diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index b471d5f88b..6e811d50b9 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -1889,15 +1889,19 @@ fn match_packed_f64_range_loop( return range_loop_reject("store_not_fact_eligible"); } } else if !local_is_number_array(ctx, arr_id) - && !(dense && local_is_untyped_candidate(ctx, arr_id)) + && !local_is_guardable_untyped_array(ctx, arr_id) { - // #6750 follow-up: read-only DENSE accesses also admit bindings - // with no usable static type (`any` function parameters — the - // bcryptjs S-box shape). The entry guards/probes re-validate the - // ACTUAL runtime value, so a wrong hint costs one failed guard → - // slow loop, never correctness. Known non-array static types stay - // excluded so ordinary object/string index loops don't grow dead - // guard chains. + // #6750 follow-up: read-only accesses also admit bindings with no + // usable static ELEMENT type — an `any` parameter (the bcryptjs + // S-box shape) and, since #10718, the ordinary untyped-JavaScript + // `const a = new Array(n)` / `const a = []` binding, whose element + // type erases to `any`. The entry guards/probes re-validate the + // ACTUAL runtime value — plain-array shape, descriptors, prototype + // pollution, frozen/sealed, the whole index window, and raw-f64 + // (hole-tolerant) packedness — so a wrong hint costs one failed + // guard → slow loop, never correctness. Known non-array and + // known-non-numeric static types stay excluded so ordinary + // object/string index loops don't grow dead guard chains. return range_loop_reject("array_static_type_excluded"); } } @@ -5968,6 +5972,73 @@ pub(super) fn local_is_untyped_candidate(ctx: &FnCtx<'_>, local_id: u32) -> bool ) } +/// #10718: a READ-only range-loop receiver whose static type carries no usable +/// element proof, but which is not known to be a non-array or a non-numeric +/// array. +/// +/// Two shapes qualify, and the difference matters: +/// +/// * [`local_is_untyped_candidate`] — no stable type proof at all, or +/// `any`/`unknown`. This is #6750's population (an `any` parameter). +/// * an ARRAY binding whose element type erases to `any`/`unknown`. This is +/// what `const a = new Array(n)`, `const a = []` and every untyped +/// JavaScript array infer, and it was the gate that kept ordinary JS off +/// every hoisted element tier: annotating the identical program +/// `const a: number[] = new Array(n)` cost 13.4 instructions per element +/// against 87 for the same source without the annotation (#10718). +/// +/// Admitting these is a hint, never a claim: `packed_f64_array_loop_range_guard` +/// re-proves plain-array shape, forwarding, index descriptors, `Array.prototype` +/// / `Object.prototype` index pollution, a recorded custom array prototype, +/// frozen / sealed / non-extensible flags, the capacity/length sanity bounds, +/// the whole index window against the LIVE length, and raw-f64-or-holes +/// packedness of every slot, at every loop entry — and the matched body admits +/// no call, closure or await, so nothing can invalidate that between the guard +/// and the last iteration. A receiver that is not what the hint suggested fails +/// the guard and runs the unchanged slow loop. +/// +/// A declared non-numeric array (`string[]`, `Foo[]`) and every known non-array +/// type stay excluded: their guard would be dead weight on every loop entry. +fn local_is_guardable_untyped_array(ctx: &FnCtx<'_>, local_id: u32) -> bool { + local_is_untyped_candidate(ctx, local_id) + || local_array_binding_element_type_is_erased(ctx, local_id) +} + +/// True when the binding's type says "an Array" but says nothing usable about +/// its ELEMENT type. +/// +/// `const a = new Array(n)` records `Generic { base: "Array", type_args: [] }` +/// — an `Array` with no type argument, i.e. `Array`. `[]`, `any[]`, +/// `unknown[]`, `Array` and `Array` land here too. These are the +/// ordinary untyped-JavaScript array bindings; before #10718 every one of them +/// missed the hoisted element tiers, which is why `const a = new Array(400)` +/// cost 87 instructions per element and `const a: number[] = new Array(400)` +/// cost 13.4 on the identical program. +/// +/// This reads `local_type_hint` rather than `stable_local_type_proof` on +/// purpose, and the read is a DISPATCH HINT ONLY: it selects which loops are +/// offered to the range tier, and every offered loop is admitted by +/// `js_typed_feedback_packed_f64_range_loop_guard`, which re-proves the live +/// receiver at loop entry (see [`local_is_guardable_untyped_array`]). A stale +/// or reassigned binding therefore fails the guard and runs the unchanged slow +/// loop; it can never produce a wrong element value. +fn element_type_is_erased(ty: &perry_hir::types::Type) -> bool { + matches!( + ty, + perry_hir::types::Type::Any | perry_hir::types::Type::Unknown + ) +} + +fn local_array_binding_element_type_is_erased(ctx: &FnCtx<'_>, local_id: u32) -> bool { + match ctx.local_type_hint(&local_id) { + Some(perry_hir::types::Type::Array(elem)) => element_type_is_erased(elem.as_ref()), + Some(perry_hir::types::Type::Generic { base, type_args }) if base == "Array" => { + type_args.is_empty() || (type_args.len() == 1 && element_type_is_erased(&type_args[0])) + } + _ => false, + } +} + fn local_allows_packed_f64_loop_store(ctx: &FnCtx<'_>, local_id: u32) -> bool { matches!( local_array_element_type(ctx, local_id), diff --git a/crates/perry-hir/src/lower/compound_assign_temp_type_tests.rs b/crates/perry-hir/src/lower/compound_assign_temp_type_tests.rs new file mode 100644 index 0000000000..934a6734e7 --- /dev/null +++ b/crates/perry-hir/src/lower/compound_assign_temp_type_tests.rs @@ -0,0 +1,152 @@ +//! #10718: the compound-assignment spill must carry the SOURCE binding's type. +//! +//! `a[i] += 1` is desugared by `hoist_compound_member_assign` into two +//! immutable temps so base and key are each evaluated exactly once. Those temps +//! used to be minted with `ty: Type::Any`, which erased BOTH the receiver's +//! array-ness and the index's integer-ness before codegen ever saw the +//! statement — so every element tier declined and the read and the write fell +//! to `js_object_get_index_polymorphic` / `js_object_set_index_polymorphic`, +//! the generic OBJECT property path. Measured on `for (i) a[i] += 1` over an +//! ordinary 400-element array: 948 instructions per element, against 16 for +//! `a[i] = k + i` on the same array, and a `number[]` annotation did not help +//! because the erasure happens here. +//! +//! This is a VERDICT test: the desugar is semantically correct either way and +//! prints the same numbers, so only the recorded type distinguishes +//! "optimizable" from "structurally excluded". Behaviour is covered +//! differentially against node. + +#![cfg(test)] + +use crate::types::Type; +use crate::{Module, Stmt}; +use perry_diagnostics::SourceCache; + +fn lower(src: &str) -> Module { + let src = src.to_string(); + std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(move || { + let mut cache = SourceCache::new(); + let parsed = perry_parser::parse_typescript_with_cache( + &src, + "compound_assign_temp_type.ts", + &mut cache, + ) + .expect("parse should succeed"); + crate::lower_module(&parsed.module, "test", "compound_assign_temp_type.ts") + .expect("lower should succeed") + }) + .expect("spawn lower thread") + .join() + .expect("lower thread panicked") +} + +/// `(name, ty)` of every `Stmt::Let` whose name starts with `__cmpd_`. +fn cmpd_temps(stmts: &[Stmt]) -> Vec<(String, Type)> { + let mut out = Vec::new(); + fn walk(stmts: &[Stmt], out: &mut Vec<(String, Type)>) { + for stmt in stmts { + match stmt { + Stmt::Let { name, ty, .. } if name.starts_with("__cmpd_") => { + out.push((name.clone(), ty.clone())); + } + Stmt::For { init, body, .. } => { + if let Some(init) = init { + walk(std::slice::from_ref(init.as_ref()), out); + } + walk(body, out); + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => walk(body, out), + Stmt::If { + then_branch, + else_branch, + .. + } => { + walk(then_branch, out); + if let Some(else_branch) = else_branch { + walk(else_branch, out); + } + } + _ => {} + } + } + } + walk(stmts, &mut out); + out +} + +fn temp(temps: &[(String, Type)], tag: &str) -> Type { + temps + .iter() + .find(|(name, _)| name.starts_with(&format!("__cmpd_{tag}_"))) + .unwrap_or_else(|| panic!("no __cmpd_{tag}_* temp among {temps:?}")) + .1 + .clone() +} + +#[test] +fn declared_number_array_compound_assign_keeps_both_types() { + let module = lower( + "const a: number[] = new Array(4);\n\ + for (let i = 0; i < 4; i++) a[i] += 1;\n", + ); + let temps = cmpd_temps(&module.init); + assert_eq!( + temp(&temps, "base"), + Type::Array(Box::new(Type::Number)), + "the base temp must carry the receiver's declared array type" + ); + assert_eq!( + temp(&temps, "key"), + Type::Number, + "the key temp must carry the counter's Number type, or every \ + integer-index proof is erased before codegen" + ); +} + +#[test] +fn untyped_new_array_compound_assign_keeps_the_erased_array_type() { + // `new Array(n)` records `Generic { base: "Array", type_args: [] }`. That + // is a WEAKER claim than `number[]` but still says "an Array", which is + // what the range-loop tier's admission keys on. `Any` said nothing. + let module = lower( + "const a = new Array(4);\n\ + for (let i = 0; i < 4; i++) a[i] += 1;\n", + ); + let temps = cmpd_temps(&module.init); + assert!( + matches!(temp(&temps, "base"), Type::Generic { ref base, .. } if base == "Array"), + "the base temp must stay an Array type, got {:?}", + temp(&temps, "base") + ); + assert_eq!(temp(&temps, "key"), Type::Number); +} + +#[test] +fn a_non_local_base_still_spills_as_any() { + // The copy is restricted to a bare `LocalGet` source, where the temp is an + // immutable snapshot of exactly one binding and its type is the source's + // type by construction. A call result has no binding to copy from, so the + // temp keeps `Any` — if this ever becomes a type, the copy has been + // widened past its proof. + let module = lower( + "function f() { return [1, 2, 3]; }\n\ + f()[1] += 1;\n", + ); + let temps = cmpd_temps(&module.init); + assert_eq!(temp(&temps, "base"), Type::Any); +} + +#[test] +fn a_string_array_compound_assign_does_not_gain_a_numeric_type() { + // The copy is exact: a `string[]` receiver must report `string[]`, never a + // numeric array. This is the twin that fails if the copy is ever replaced + // by a guess. + let module = lower( + "const a: string[] = [\"x\"];\n\ + a[0] += \"y\";\n", + ); + let temps = cmpd_temps(&module.init); + assert_eq!(temp(&temps, "base"), Type::Array(Box::new(Type::String))); +} diff --git a/crates/perry-hir/src/lower/expr_assign.rs b/crates/perry-hir/src/lower/expr_assign.rs index 83bda62ad3..1b08d31c8e 100644 --- a/crates/perry-hir/src/lower/expr_assign.rs +++ b/crates/perry-hir/src/lower/expr_assign.rs @@ -1402,11 +1402,35 @@ pub(crate) fn hoist_compound_member_assign( let mut stmts: Vec = Vec::new(); let spill = |ctx: &mut LoweringContext, stmts: &mut Vec, tag: &str, init: Expr| -> LocalId { + // #10718: carry the SOURCE binding's type onto the temp instead of + // erasing it to `Any`. + // + // The temp is an immutable snapshot of exactly one binding's value + // (`init` is a bare `LocalGet`), so its type is the source's type + // by construction — which is why the copy is restricted to that + // one shape. Erasing it cost the whole element-access tier stack: + // `a[i] += 1` on an ordinary array spilled `a` and `i` into two + // `Any` temps, which erased BOTH the receiver's array-ness and the + // index's integer-ness, so the read and the write fell all the way + // to `js_object_get_index_polymorphic` / + // `js_object_set_index_polymorphic` — the generic OBJECT property + // path, with per-element index stringification and shape/descriptor + // table work. Measured: 948 instructions per element for + // `a[i] += 1` against 16 for `a[i] = k + i` on the same array, and + // a `number[]` annotation did not help because this erasure + // happens before codegen ever sees it. + let ty = match &init { + Expr::LocalGet(src) => ctx + .lookup_local_type_by_id(*src) + .cloned() + .unwrap_or(Type::Any), + _ => Type::Any, + }; let id = ctx.fresh_local(); stmts.push(Stmt::Let { id, name: format!("__cmpd_{}_{}", tag, id), - ty: Type::Any, + ty, mutable: false, init: Some(init), }); diff --git a/crates/perry-hir/src/lower/mod.rs b/crates/perry-hir/src/lower/mod.rs index 1831d4b68f..5ed0800561 100644 --- a/crates/perry-hir/src/lower/mod.rs +++ b/crates/perry-hir/src/lower/mod.rs @@ -137,6 +137,8 @@ mod bun_sql_tests; #[cfg(test)] mod collection_view_tests; #[cfg(test)] +mod compound_assign_temp_type_tests; +#[cfg(test)] mod for_multi_decl_tests; #[cfg(test)] mod for_of_counter_tests; diff --git a/crates/perry-runtime/src/array/header.rs b/crates/perry-runtime/src/array/header.rs index 4b0187379e..17c3fa7baa 100644 --- a/crates/perry-runtime/src/array/header.rs +++ b/crates/perry-runtime/src/array/header.rs @@ -1211,6 +1211,72 @@ pub(crate) unsafe fn rebuild_array_numeric_raw_f64_allow_holes(arr: *mut ArrayHe true } +/// #10718: WINDOW-scoped variant of +/// [`rebuild_array_numeric_raw_f64_allow_holes`], for the classic +/// (hole-tolerant, side-exiting) packed-f64 range loop. +/// +/// The array-wide rebuild proves an invariant over `[0, length)` and, on +/// success, records it in the header so later loop entries are O(1). That is +/// exactly right when it succeeds — and needlessly fatal when it does not: a +/// single non-numeric slot ANYWHERE disqualifies a loop that only ever reads +/// `[min_idx, max_idx_exclusive)`, and because the failure clears the layout +/// flags, every re-entry walks the array again. Measured: an untyped +/// 400-element array holding one string at index 399, read by +/// `for (i = 0; i < 399; i++) s += a[i]` inside an outer loop, paid a full +/// 400-slot walk per outer iteration. +/// +/// This checks only the slots the guarded clone can touch. It canonicalizes +/// numeric slots in the window exactly as the array-wide walk does (an +/// INT32-boxed integer becomes raw f64 bits), tolerates `TAG_HOLE` — the +/// classic tier's loads hole-check and side-exit — and fails on the first slot +/// that is neither. +/// +/// It deliberately records NOTHING in the header and does NOT call +/// `layout_init_pointer_free`: slots outside the window are unexamined and may +/// still hold heap pointers, which the collector must keep tracing. The cost +/// is that a window-only admission re-walks its window on each loop entry; +/// that is bounded by the window, and the array-wide fast path above still +/// serves every array that really is numeric throughout. +/// +/// # Safety +/// +/// `arr` must be a live, non-forwarded `GC_TYPE_ARRAY` head whose +/// `length <= capacity`, and `[min_idx, max_idx_exclusive)` must lie within +/// `[0, length)` — `packed_f64_array_loop_range_guard` proves all of that +/// before calling. +pub(crate) unsafe fn array_window_is_numeric_raw_f64_allow_holes( + arr: *mut ArrayHeader, + min_idx: i32, + max_idx_exclusive: i32, +) -> bool { + if arr.is_null() || min_idx < 0 { + return false; + } + let len = i64::from((*arr).length); + let min = i64::from(min_idx); + let max = i64::from(max_idx_exclusive).min(len); + if min >= max { + // An empty window: the clone runs zero iterations, so there is nothing + // to prove. (The caller has already rejected `max_idx_exclusive > len`.) + return true; + } + let elements = array_elements_ptr(arr); + for i in min..max { + let slot_bits = array_slot_bits(arr, i as usize); + if slot_bits == crate::value::TAG_HOLE { + continue; + } + let Some(number) = value_bits_to_number(slot_bits) else { + return false; + }; + if number.to_bits() != slot_bits { + // GC_STORE_AUDIT(POINTER_FREE): raw-f64 rewrite stores numeric payloads only. + std::ptr::write(elements.add(i as usize) as *mut f64, number); + } + } + true +} + /// Dense-window variant of [`rebuild_array_numeric_raw_f64_allow_holes`] for /// the read-only masked-index range loop: after the hole-tolerant rebuild, /// additionally require that `[min_idx, max_idx_exclusive)` contains NO holes. diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index f085ceffce..c44149cf53 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -142,9 +142,10 @@ pub(crate) use self::generic_object::{ object_splice, }; pub(crate) use self::header::{ - array_has_arguments_object_flag, js_array_is_numeric_f64_layout_resolved, - mark_array_as_arguments_object, rebuild_array_numeric_raw_f64_allow_holes, - rebuild_array_numeric_raw_f64_dense_window, rebuild_array_numeric_raw_f64_dense_window_i32, + array_has_arguments_object_flag, array_window_is_numeric_raw_f64_allow_holes, + js_array_is_numeric_f64_layout_resolved, mark_array_as_arguments_object, + rebuild_array_numeric_raw_f64_allow_holes, rebuild_array_numeric_raw_f64_dense_window, + rebuild_array_numeric_raw_f64_dense_window_i32, }; pub use self::header::{ js_array_clear_numeric_layout, js_array_declare_all_pointer_elements, diff --git a/crates/perry-runtime/src/typed_feedback.rs b/crates/perry-runtime/src/typed_feedback.rs index ffb9b526d6..9e2352cfa2 100644 --- a/crates/perry-runtime/src/typed_feedback.rs +++ b/crates/perry-runtime/src/typed_feedback.rs @@ -1480,7 +1480,14 @@ fn packed_f64_array_loop_range_guard( if min_idx < 0 || i64::from(max_idx_exclusive) > i64::from(len) { return false; } - crate::array::rebuild_array_numeric_raw_f64_allow_holes(arr) + if crate::array::rebuild_array_numeric_raw_f64_allow_holes(arr) { + return true; + } + // #10718: the array-wide invariant can fail on a slot this loop never + // touches. The clone reads only `[min_idx, max_idx_exclusive)`, so a + // window-scoped proof is the whole requirement; it records nothing, so + // the array-wide claim above stays the authority for everyone else. + crate::array::array_window_is_numeric_raw_f64_allow_holes(arr, min_idx, max_idx_exclusive) } } diff --git a/scripts/local_binding_type_allowlist.json b/scripts/local_binding_type_allowlist.json index b2abe964e4..1f2672e0f7 100644 --- a/scripts/local_binding_type_allowlist.json +++ b/scripts/local_binding_type_allowlist.json @@ -401,6 +401,14 @@ "classification": "runtime-validated", "reason": "A falsy-local fold consumes only the private method clone's proof: its public wrapper bit-compares the live argument with TAG_UNDEFINED, candidate discovery rejects user writes and closure capture, and the proof API rejects every remaining reassigned binding." }, + { + "path": "crates/perry-codegen/src/stmt/loops.rs", + "function": "local_array_binding_element_type_is_erased", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "#10718: the declared/inferred type only selects which read-only range loops are OFFERED to the packed-f64 range tier; it licenses no load. Every offered loop enters through js_typed_feedback_packed_f64_range_loop_guard, which re-proves the live receiver at loop entry - plain-array GC type, no forwarding, no index descriptors, no Array.prototype/Object.prototype index pollution, no recorded custom array prototype, not frozen/sealed/non-extensible, capacity and length sanity, the whole index window inside the LIVE length, and raw-f64-or-holes packedness of every slot - and the matched body admits no call, closure or await, so nothing can invalidate that between the guard and the last iteration. A stale or reassigned binding fails the guard and runs the unchanged slow loop." + }, { "path": "crates/perry-codegen/src/stmt/loops.rs", "function": "local_array_element_type", From 1246fd9df48844fbbea7388b9c43ebd5b896b194 Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Sat, 19 Sep 2026 13:23:18 +0000 Subject: [PATCH 2/3] changelog: fragment for #10718 Claude-Session: https://claude.ai/code/session_01YaNfLEjMRdhCLtk3SB5MdJ --- changelog.d/10718-array-index-hoist.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 changelog.d/10718-array-index-hoist.md diff --git a/changelog.d/10718-array-index-hoist.md b/changelog.d/10718-array-index-hoist.md new file mode 100644 index 0000000000..cfcf2b480f --- /dev/null +++ b/changelog.d/10718-array-index-hoist.md @@ -0,0 +1,9 @@ +**Indexed reads on an ordinary `Array` no longer re-prove a loop-invariant receiver on every element.** + +An indexed read cost **87 instructions per element** — against 6 for the same arithmetic on a `Float64Array` and 16 for node — and none of it was a runtime call. 56 of the 87 were loop-invariant receiver revalidation re-executed every iteration: the NaN-box tag and handle-band test, the forwarding-flag follow, and a six-load live-head guard. + +perry already had tiers that hoist that proof into the loop preheader. They were declining at one gate, `array_static_type_excluded` — a *declared static type* test in front of a tier that is otherwise fully runtime-guarded — so `const a: number[]` got it and plain `new Array(400)`, which infers `Array`, did not. Ordinary JavaScript never reached the tier it already had. + +Separately, `a[i] += 1` cost **948** instructions per element, 3.7× the identical `a[i] = a[i] + 1`, and no annotation helped: the compound-assignment spill temporaries were minted as `Type::Any`, erasing the receiver's array-ness and the index's integer-ness before codegen saw the statement. + +Array read **87 → 13.5** (node 16.3), `a[i] += 1` **948 → 273**, `a[i] += b[i]` **1025 → 347**. A particle simulation over four numeric arrays spends **60.9% fewer instructions** and **59% less peak RSS**. The bare loop and both `Float64Array` paths are unchanged to the instruction. From 2b2b8906390c42a3e4e0305214aa573893753f23 Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Sat, 19 Sep 2026 16:36:25 +0000 Subject: [PATCH 3/3] perf(codegen): hoist the loop-invariant receiver proof for array element stores (#10718) An indexed write cost 105 instructions per element with zero runtime calls; 51 of them were loop-invariant receiver revalidation and 42 was a write-barrier decision provable away from the value type. This widens the store admission the way #10731 widened reads. a[i] = k + i 105 -> 17.4, a[i] = a[i] + 1 256 -> 24.5, a[i] = a[i] + b[i] 333 -> 35.9. The bare loop, both Float64Array paths and the indexed read are unchanged to the instruction. The barrier-stem census probe for idxset.recv_global gains a second statement: the widened tier made its loop qualify, which would have left that stem with no live witness. A future multi-statement store tier must re-shape the probe, not delete it. Claude-Session: https://claude.ai/code/session_01YaNfLEjMRdhCLtk3SB5MdJ --- changelog.d/10718-array-store-hoist.md | 9 +++ .../src/expr/barrier_stem_census_tests.rs | 41 ++++++++++-- crates/perry-codegen/src/stmt/loops.rs | 67 ++++++++++++++++++- 3 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 changelog.d/10718-array-store-hoist.md diff --git a/changelog.d/10718-array-store-hoist.md b/changelog.d/10718-array-store-hoist.md new file mode 100644 index 0000000000..648ff78110 --- /dev/null +++ b/changelog.d/10718-array-store-hoist.md @@ -0,0 +1,9 @@ +**Stores to an ordinary `Array` element no longer re-prove a loop-invariant receiver on every element.** + +An indexed write cost **105 instructions per element** — against 8 for the same store to a `Float64Array` and 12 for node — with zero runtime calls. **51 of the 105 were loop-invariant** receiver revalidation, and a further 42 was a write-barrier decision provable away from the value's type. + +This widens the store admission the way #10731 widened reads. The gate was `has_materialization_hazard`, which a trailing `console.log` is enough to set. + +`a[i] = k + i` **105 → 17.4**, `a[i] = a[i] + 1` **256 → 24.5** (node 18.7), `a[i] = a[i] + b[i]` **333 → 35.9**. The bare loop, both `Float64Array` paths and the indexed read are unchanged to the instruction. + +Note this moves none of the five real programs in #10695 — their loop bodies are multi-statement or contain calls, which no current tier admits (#10741) — and `a[i] += 1` is unaffected because its lowering is two statements (#10743). diff --git a/crates/perry-codegen/src/expr/barrier_stem_census_tests.rs b/crates/perry-codegen/src/expr/barrier_stem_census_tests.rs index 09b5817ffa..e4f3c17141 100644 --- a/crates/perry-codegen/src/expr/barrier_stem_census_tests.rs +++ b/crates/perry-codegen/src/expr/barrier_stem_census_tests.rs @@ -507,11 +507,42 @@ fn idxset_recv_global_ir() -> String { op: UpdateOp::Increment, prefix: false, }), - body: vec![Stmt::Expr(Expr::IndexSet { - object: Box::new(Expr::LocalGet(G_ID)), - index: Box::new(Expr::LocalGet(IDX_ID)), - value: Box::new(Expr::LocalGet(VAL_ID)), - })], + // #10718 store side: the body carries a SECOND statement, and + // that is load-bearing for this probe rather than incidental. + // + // Widening the packed-f64 range loop's STORE admission to + // element-type-erased array bindings (`Array` — which is + // exactly `g`'s type here) made this loop qualify for the + // versioned tier. The tier is correct on it — the fast copy + // stores only values its per-store check proved are genuine + // doubles, and everything else side-exits into a slow copy that + // keeps the full barriered store (`idxset.inbounds.barrier` -> + // `js_write_barrier_slot_validated_parent`, plus + // `js_write_barrier_slot` on both extend paths and the numeric + // note) — but the slow copy reaches the store through the + // `idxset.inbounds` receiver arm, not through `recv_global`. + // The stem would then have had NO live witness anywhere, which + // is the one thing this census exists to prevent. + // + // `packed_f64_range_loop_body_collect` admits exactly ONE + // statement, so a second one keeps this probe on the + // un-versioned receiver ladder it is here to cover, without + // touching what it asserts. If a future tier learns to admit + // multi-statement store bodies, this probe goes red again — + // deliberately — and must be re-shaped, not deleted. + body: vec![ + Stmt::Expr(Expr::IndexSet { + object: Box::new(Expr::LocalGet(G_ID)), + index: Box::new(Expr::LocalGet(IDX_ID)), + value: Box::new(Expr::LocalGet(VAL_ID)), + }), + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::LocalGet(VAL_ID)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }), + ], }, Stmt::Return(Some(Expr::LocalGet(G_ID))), ], diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 6e811d50b9..f9f6514a9b 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -1823,7 +1823,9 @@ fn match_packed_f64_range_loop( { return range_loop_reject("dense_written_not_addressable"); } - } else if !packed_loop_array_binding_is_eligible(ctx, arr_id) { + } else if !packed_loop_array_binding_is_eligible(ctx, arr_id) + && !written_untyped_binding_is_guardable(ctx, arr_id) + { return range_loop_reject("written_binding_not_eligible"); } } else if !packed_loop_array_binding_storage_is_addressable(ctx, arr_id) @@ -1878,13 +1880,21 @@ fn match_packed_f64_range_loop( // every loop entry, so those static facts are not load-bearing // here; a wrong hint is one failed guard -> slow loop. Classic // (side-exiting, hole-tolerant) written arrays keep the full set. - if !local_allows_packed_f64_loop_store(ctx, arr_id) { + // #10718 store side: the two remaining tests below are DECLARED + // STATIC TYPE / static fact-graph tests standing in front of a + // tier whose every correctness obligation is discharged at + // runtime. `written_untyped_binding_is_guardable` admits the + // ordinary untyped-JavaScript array binding alongside them — see + // that function for why the guard, not the hint, is what holds. + let untyped_guardable = written_untyped_binding_is_guardable(ctx, arr_id); + if !local_allows_packed_f64_loop_store(ctx, arr_id) && !untyped_guardable { return range_loop_reject("store_local_not_allowed"); } if !dense && !ctx .native_facts .packed_f64_eligible_for_guarded_store(arr_id) + && !untyped_guardable { return range_loop_reject("store_not_fact_eligible"); } @@ -6039,6 +6049,59 @@ fn local_array_binding_element_type_is_erased(ctx: &FnCtx<'_>, local_id: u32) -> } } +/// #10718 store side: may a WRITTEN range-loop receiver be admitted on the +/// strength of the loop-entry guard alone, with no declared element type and +/// no static fact-graph claim? +/// +/// #10731 widened the READ admission to ordinary untyped JavaScript arrays and +/// took `Array` element reads from 87 instructions to 13.5. It deliberately +/// left the STORE side alone, because a raw slot store on an unproven element +/// type pulls in frozen/sealed, the write barrier and the pointer-free layout +/// note. Every one of those is discharged, and none of them by a static hint: +/// +/// * **frozen / sealed / non-extensible** — `packed_f64_array_loop_range_guard` +/// reads `OBJ_FLAG_FROZEN | OBJ_FLAG_SEALED | OBJ_FLAG_NO_EXTEND` off the GC +/// header at every loop entry and declines. A store that must be ignored in +/// sloppy mode or throw in strict mode therefore never reaches the fast copy +/// at all; it runs the unchanged generic store in the slow loop. +/// * **index accessors / `defineProperty` descriptors** — the same guard +/// declines on `OBJ_FLAG_ARRAY_DESCRIPTORS`. +/// * **a setter on `Array.prototype` / `Object.prototype`, or a recorded custom +/// array prototype** — declined by the three prototype-pollution flags in +/// `plain_array_index_guard`. This is what makes a store INTO A HOLE safe: +/// with no inherited index property, defining the element on an in-bounds +/// index is exactly what `[[Set]]` does. +/// * **the write barrier** — the fast store writes a value the per-store check +/// proved is a genuine double. A double carries no heap edge, so there is no +/// edge for the barrier to record. A NaN-boxed non-double side-exits to the +/// slow loop BEFORE the store, where the generic path runs barrier and all. +/// * **the pointer-free layout note** — the guard proved every slot in the +/// window is raw f64 or `TAG_HOLE`, and the fast store only ever writes a +/// double, so the array's numeric layout is an invariant of the fast copy +/// rather than something a note must maintain. +/// * **growing through the store, and out-of-bounds** — the guard proves the +/// loop's whole static index window against the LIVE `length`, so no admitted +/// index is `>= length` and the fast copy can never need to extend. +/// * **a proxied / subclassed / `arguments`-like receiver** — `GC_TYPE_ARRAY` +/// plus the forwarding-flag test in `plain_array_index_guard`; a Proxy is not +/// a `GC_TYPE_ARRAY` head. +/// * **anything changing mid-loop** — the matched body admits no call, closure +/// or await, so no user code can run between the guard and the last +/// iteration to freeze, seal, `defineProperty` or pollute a prototype. +/// +/// So the binding test that remains is a DISPATCH HINT: which loops are worth +/// offering the guard. Being wrong costs one failed guard and the unchanged +/// slow loop, never a wrong store. What it must still enforce is the two +/// STORAGE facts the guard cannot see — the binding is read by a plain load +/// (not a closure cell or box) and has not been scalar-replaced — because +/// those decide whether the emitted code is looking at the array the guard +/// validated. +fn written_untyped_binding_is_guardable(ctx: &FnCtx<'_>, local_id: u32) -> bool { + local_is_guardable_untyped_array(ctx, local_id) + && packed_loop_array_binding_storage_is_addressable(ctx, local_id) + && !ctx.scalar_replaced_arrays.contains_key(&local_id) +} + fn local_allows_packed_f64_loop_store(ctx: &FnCtx<'_>, local_id: u32) -> bool { matches!( local_array_element_type(ctx, local_id),