diff --git a/changelog.d/6812-w8-temps-w13-intkeys.md b/changelog.d/6812-w8-temps-w13-intkeys.md new file mode 100644 index 0000000000..07df50f7de --- /dev/null +++ b/changelog.d/6812-w8-temps-w13-intkeys.md @@ -0,0 +1 @@ +perf(codegen): #6812 w8/w13 — the whole-loop write clone now admits the shapes the call inliner produces (immutable numeric temp `let`s between the element alias and the writes, resolved by substitution) and constant integer keys (`o[7]` = canonical property "7"), and peels the first outer iteration before versioning so first-write key appends prime receiver shapes instead of vetoing the nest. w8 (helper-function writes): 6× slower than node → beats node (0.75×). Within-capacity integer-key appends (`o[7]` fitting the slot floor): beats node 2.4×. w16 pays one ordinary peel round (still beats node, 0.26×); past-capacity appends (w13's 6th key) stay generic until the object-owned spill lands. diff --git a/crates/perry-codegen/src/expr/proxy_reflect.rs b/crates/perry-codegen/src/expr/proxy_reflect.rs index b41ec6ce67..bf22b72809 100644 --- a/crates/perry-codegen/src/expr/proxy_reflect.rs +++ b/crates/perry-codegen/src/expr/proxy_reflect.rs @@ -594,6 +594,13 @@ fn static_write_key(ctx: &FnCtx<'_>, key: &Expr) -> Option { match key { Expr::String(property) => Some(property.clone()), Expr::LocalGet(id) => ctx.const_string_locals.get(id).cloned(), + // #6812 (w13): `o[7] = v` — a constant integer key is the canonical + // numeric-string property key ("7"; i64 formatting is canonical for + // every integer, including negatives). Real arrays never take the IC + // hit path: the miss handler and the emitted guards validate the + // receiver as a REGULAR heap object, so array receivers fall to the + // generic write, which performs the element store. + Expr::Integer(n) => Some(n.to_string()), _ => None, } } diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 90bd3a16a9..947f525755 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -1857,6 +1857,38 @@ enum ObjectArrayWriteNumber { /// the measured #6812 gap while preserving a fixed-size, allocation-free /// preflight ABI. const MAX_OBJECT_ARRAY_WRITE_FIELDS: usize = 4; +/// #6812 (w8): caps for the leading numeric-temp run. Substituting a temp +/// duplicates its tree at every use (`let b = a + a;` doubles per level), so +/// both the temp count and every parsed tree's node count are budgeted — +/// the range/emit walkers recurse over these trees and must stay on a +/// bounded stack for generated/inlined bodies of any size. +const MAX_OBJECT_ARRAY_WRITE_TEMPS: usize = 8; +const MAX_OBJECT_ARRAY_WRITE_NUMBER_NODES: usize = 64; + +/// Iterative (explicit-worklist) node count with early exit past the cap, so +/// counting an oversized tree never recurses either. +fn object_array_write_number_node_count(root: &ObjectArrayWriteNumber) -> usize { + let mut count = 0usize; + let mut work = vec![root]; + while let Some(node) = work.pop() { + count += 1; + if count > MAX_OBJECT_ARRAY_WRITE_NUMBER_NODES { + return count; + } + match node { + ObjectArrayWriteNumber::Add(left, right) + | ObjectArrayWriteNumber::Sub(left, right) + | ObjectArrayWriteNumber::Mul(left, right) => { + work.push(left); + work.push(right); + } + ObjectArrayWriteNumber::OuterCounter + | ObjectArrayWriteNumber::InnerCounter + | ObjectArrayWriteNumber::Constant(_) => {} + } + } + count +} struct ObjectArrayWriteLoop { outer_counter_id: u32, @@ -1894,11 +1926,19 @@ fn match_object_array_write_number( expr: &perry_hir::Expr, outer_counter_id: u32, inner_counter_id: u32, + temps: &std::collections::HashMap, ) -> Option { use perry_hir::{BinaryOp, Expr}; match expr { Expr::LocalGet(id) if *id == outer_counter_id => Some(ObjectArrayWriteNumber::OuterCounter), Expr::LocalGet(id) if *id == inner_counter_id => Some(ObjectArrayWriteNumber::InnerCounter), + // #6812 (w8): a body-local immutable numeric temp (`let x = r + i;`) + // — the shape the call inliner leaves behind — substitutes its parsed + // expression tree. Recomputation at each use is safe: the grammar + // admits only pure numeric expressions over counters/constants/ + // earlier temps, and the finite-range proof runs on the substituted + // tree exactly as if the user had written it inline. + Expr::LocalGet(id) => temps.get(id).cloned(), Expr::Integer(n) if (-i64::from(i32::MAX)..=i64::from(i32::MAX)).contains(n) => { Some(ObjectArrayWriteNumber::Constant(*n as f64)) } @@ -1906,8 +1946,10 @@ fn match_object_array_write_number( Expr::Binary { op, left, right } if matches!(op, BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul) => { - let left = match_object_array_write_number(left, outer_counter_id, inner_counter_id)?; - let right = match_object_array_write_number(right, outer_counter_id, inner_counter_id)?; + let left = + match_object_array_write_number(left, outer_counter_id, inner_counter_id, temps)?; + let right = + match_object_array_write_number(right, outer_counter_id, inner_counter_id, temps)?; Some(if matches!(op, BinaryOp::Mul) { ObjectArrayWriteNumber::Mul(Box::new(left), Box::new(right)) } else if matches!(op, BinaryOp::Add) { @@ -2200,7 +2242,10 @@ fn match_object_array_write_loop( else { return None; }; - if stores.is_empty() || stores.len() > MAX_OBJECT_ARRAY_WRITE_FIELDS { + // Only emptiness here: `stores` may still carry leading numeric temps + // (#6812 w8), so the MAX_OBJECT_ARRAY_WRITE_FIELDS cap applies to the + // real writes after the temp run is split off below. + if stores.is_empty() { return None; } let (Expr::LocalGet(array_id), Expr::LocalGet(index_id)) = (object.as_ref(), index.as_ref()) @@ -2229,6 +2274,46 @@ fn match_object_array_write_loop( } } + // #6812 (w8): admit a leading run of body-local immutable numeric temps + // between the alias and the writes — the exact shape the call inliner + // produces (`setCD(o, r + i, r - i)` becomes `let x = r + i; + // let y = r - i; o.c = x; o.d = y`). Each temp's init must parse in the + // same pure-numeric grammar (over counters, constants, and earlier + // temps); write values then resolve `LocalGet(temp)` by substitution, so + // the emitter and the finite-range proof see the trees the user could + // have written inline (recomputation of a pure numeric expression is + // unobservable). A temp that is captured (boxed) or fails the grammar + // rejects the whole loop — statements are never skipped. + let mut temps = std::collections::HashMap::new(); + let mut stores = stores; + while let Some(( + Stmt::Let { + id: temp_id, + mutable: false, + init: Some(temp_init), + .. + }, + rest, + )) = stores.split_first() + { + if ctx.boxed_vars.contains(temp_id) || temps.len() >= MAX_OBJECT_ARRAY_WRITE_TEMPS { + return None; + } + let parsed = + match_object_array_write_number(temp_init, outer_counter_id, inner_counter_id, &temps)?; + // Substitution can compound: `let b = a + a; let c = b + b;` doubles + // the tree per level, so a size budget — not a temp-count cap alone — + // keeps the recursive range/emit walkers on bounded stacks. + if object_array_write_number_node_count(&parsed) > MAX_OBJECT_ARRAY_WRITE_NUMBER_NODES { + return None; + } + temps.insert(*temp_id, parsed); + stores = rest; + } + if stores.is_empty() || stores.len() > MAX_OBJECT_ARRAY_WRITE_FIELDS { + return None; + } + let match_store = |effect: &Expr| -> Option<(String, ObjectArrayWriteNumber)> { let Expr::PutValueSet { target, @@ -2250,9 +2335,23 @@ fn match_object_array_write_loop( let property = match key.as_ref() { Expr::String(property) => property.clone(), Expr::LocalGet(id) => ctx.const_string_locals.get(id).cloned()?, + // #6812 (w13): `o[7] = v` — a constant integer key IS the + // canonical numeric-string property ("7") on a plain object. + // Receivers that are real arrays at runtime are safe: the + // preflight guard type-checks every element as GC_TYPE_OBJECT + // and rejects the nest, and the per-write fallback handles + // element writes generically. + Expr::Integer(n) => n.to_string(), _ => return None, }; - let value = match_object_array_write_number(value, outer_counter_id, inner_counter_id)?; + let value = + match_object_array_write_number(value, outer_counter_id, inner_counter_id, &temps)?; + // Same size budget as the temps: a value combining several + // substituted temps must still hand the recursive range/emit + // walkers a bounded tree. + if object_array_write_number_node_count(&value) > MAX_OBJECT_ARRAY_WRITE_NUMBER_NODES { + return None; + } object_array_write_number_finite_range(&value, outer_start, outer_bound, inner_bound)?; Some((property, value)) }; @@ -2322,10 +2421,62 @@ fn lower_object_array_write_versioned_for( update: Option<&perry_hir::Expr>, body: &[Stmt], ) -> Result { - let Some(matched) = match_object_array_write_loop(ctx, init, condition, update, body) else { + let Some(mut matched) = match_object_array_write_loop(ctx, init, condition, update, body) + else { return Ok(false); }; + // #6812 (w13): peel outer iteration #1 through the ordinary lowering + // before versioning. A first-write loop (`o[7] = v` where "7" is a new + // key) appends the key to every receiver — a shape transition — so a + // preflight taken before any iteration rejects with "target key is + // absent from the shared shape" and the ENTIRE nest runs generically. + // The peeled round primes the shapes with exact source semantics; the + // guard then proves the remaining [start+1, bound) rounds, which run in + // the call-free clone. When the guard would have passed anyway the cost + // is one ordinary outer round of a multi-round nest. The peel calls + // `lower_for_after_init` directly, so it cannot re-enter this + // versioning path. + let mut peeled_init_stmt: Option = None; + if matched.outer_start < matched.outer_bound { + let Some(Stmt::Let { + id, + name, + ty, + mutable, + .. + }) = init + else { + // match_constant_counted_for only admits a Let-counted for; + // defensive rather than unreachable. + return Ok(false); + }; + let peel_cond = perry_hir::Expr::Compare { + op: perry_hir::CompareOp::Lt, + left: Box::new(perry_hir::Expr::LocalGet(*id)), + right: Box::new(perry_hir::Expr::Integer(i64::from(matched.outer_start) + 1)), + }; + lower_for_after_init( + ctx, + init, + Some(&peel_cond), + update, + body, + "for.object_array_write_peel", + )?; + matched.outer_start += 1; + peeled_init_stmt = Some(Stmt::Let { + id: *id, + name: name.clone(), + ty: ty.clone(), + mutable: *mutable, + init: Some(perry_hir::Expr::Integer(i64::from(matched.outer_start))), + }); + } + // Both the guard-fail fallback and the fast nest must cover only the + // un-peeled rounds. + let init = peeled_init_stmt.as_ref().or(init); + let slow_pre_idx = ctx.new_block("object_array_write.loop.slow.preheader"); let merge_idx = ctx.new_block("object_array_write.loop.merge"); let slow_pre_label = ctx.block_label(slow_pre_idx); diff --git a/docs/object-write-matrix.md b/docs/object-write-matrix.md index 9ac867c88e..2c7cd19a8a 100644 --- a/docs/object-write-matrix.md +++ b/docs/object-write-matrix.md @@ -37,27 +37,30 @@ Ratio = perry/node median (fill from measurement; `<1` = beating node). | w0_canonical | 2 static writes, const alias, nested const `for`s, `+`/`-` RHS | whole-loop clone | 6 | 9 | **0.67** | BEATS node | | w1_three_writes | 3 static writes, same loop | whole-loop clone (≤4 fields) | 7 | 9 | **0.78** | BEATS node | | w2_one_write | 1 static write | whole-loop clone | 5 | 7 | **0.71** | BEATS node | -| w3_mul_rhs | RHS uses `*` | NOT clone (`Add\|Sub` only) → PIC | 47 | 7 | 6.7 | GAP: extend numeric matcher | -| w4_dyn_bound | inner bound `objs.length` | NOT clone (const bounds only) → PIC | 51 | 8 | 6.4 | GAP: length-stable versioning | -| w5_while | same body, `while` form | NOT clone (`for`-only) → PIC | 46 | 7 | 6.6 | GAP: while normalization | +| w3_mul_rhs | RHS uses `*` | *(pre-#6830 baseline)* PIC → whole-loop clone | 47 → 6 | 7 | 6.7 → **0.86** | BEATS node (#6830 admits `Mul` with endpoint-product ranges) | +| w4_dyn_bound | inner bound `objs.length` | *(pre-#6830 baseline)* PIC → whole-loop clone | 51 → 6 | 8 | 6.4 → **0.75** | BEATS node (#6830 sentinel-resolved dynamic bound) | +| w5_while | same body, `while` form | *(pre-#6830 baseline)* PIC → whole-loop clone | 46 → 5 | 7 | 6.6 → **0.71** | BEATS node (#6830 while normalization) | | w6_call_in_body | user call inside body | NOT clone → PIC + call | 100 | 7 | 14.3 | GAP: call-tolerant clone / inlining | -| w7_mut_alias | `let o = objs[i]` | NOT clone (const alias only) | 70 | 8 | 8.8 | GAP: SSA no-reassign proof | -| w8_helper_mono | writes inside helper fn, mono | static-key PIC hit (~10 ns/write) | 47 | 8 | 5.9 | GAP: call overhead + guard chain vs node's full inlining | +| w7_mut_alias | `let o = objs[i]` | *(pre-#6830 baseline)* generic → whole-loop clone | 70 → 6 | 8 | 8.8 → **0.75** | BEATS node (#6830: matched region structurally forbids reassignment) | +| w8_helper_mono | writes inside helper fn, mono | *(pre-#6812-w8 baseline)* per-write PIC → whole-loop clone | 47 → 6 | 8 | 5.9 → **0.75** | BEATS node — the inliner's temp `let`s are admitted by substitution, so inlined helper bodies clone | | w9_poly2 | 2 shapes through one site | PIC entries 1–2 | 27 | 6 | 4.5 | GAP: same as w8 + entry chain | | w10_poly8 | 8 shapes through one site | PIC exhausted → runtime miss | 27 | 19 | 1.4 | close; megamorphic path is decent | | w11_stable_dynkey | `o[k]`, `const k = "c"` | clone (const-string local = static) | 6 | 8 | **0.75** | BEATS node | | w12_arb_dynkey | rotating keys from array | generic | 84 | 18 | 4.7 | GAP: GC-safe dynamic-key cache | -| w13_int_key | `o[7]` on plain object | generic numeric-as-property | 160 | 13 | 12.3 | GAP | +| w13_int_key | `o[7]` on plain object | generic numeric-as-property; append past inline capacity | 160 | 13 | 12.3 | PARTIAL: integer keys are static clone keys and iteration #1 is peeled, so a within-capacity append clones (variant: 5 vs 12 ms, **0.42**, beats node). The canonical cell appends a 6th key past the literal's 5-slot capacity → overflow side-table; needs the object-owned spill (next slice) | | w15_append_build | fresh `{}` + 6 assigns (builder) | *(pre-#6829 baseline)* generic transitions; `class_id==0` blocks PIC | 1489 → 196 (#6829) | 8 | 186 → 25 | #6829 folds builders into literals; residual tracked below | -| w16_overflow_slot | writes past inline capacity | *(pre-#6812-w16 baseline)* runtime (PIC bounds reject) → whole-loop clone | 4163 → 3 | 29 | 173 → **0.10** | BEATS node — `{}` per-site classes + learned width + compile-time width hint make builder arrays uniform and clone-eligible. Each ratio vs its own run's node baseline (2026-07-25 sweeps: pre-fix 4163/24, post-fix 3/29) | +| w16_overflow_slot | writes past inline capacity | *(pre-#6812-w16 baseline)* runtime (PIC bounds reject) → whole-loop clone | 4163 → 3 | 29 | 173 → **0.26** | BEATS node — `{}` per-site classes + learned width + compile-time width hint make builder arrays uniform and clone-eligible. The #6812-w13 peel adds one ordinary outer round (3 → 6 ms; reclaimable with a guard-first second-chance design). Ratios vs each run's own node baseline (2026-07-25 sweeps) | | w17_alloc_rhs | allocating RHS (`"s"+i`) | NOT PIC (safepoint-free rule) | 26 | 18 | 1.4 | close; revisit only with receiver-reload design | | w18_class_inst | class instances via `any` | PIC | 6 | 12 | **0.50** | BEATS node (best row) | ### Reading of the triage pass -- **Beating node already (5 rows):** everything the whole-loop clone covers, - plus class instances through the PIC. The #6811/#6823 architecture wins - when it applies. +- **Beating node (11 of 18 rows as of #6812 w8/w13):** everything the + whole-loop clone covers — including, since #6830/#6833/#6812-w8, `Mul` + RHS, dynamic bounds, `while` form, `let` aliases, builder-pattern arrays, + inlined-helper bodies, and peeled first-write appends — plus class + instances through the PIC. The #6811/#6823 architecture wins when it + applies. - **Two catastrophic rows (~180×), both ubiquitous in real code:** the builder pattern (fresh `{}` + property assigns — every API response, parser, ORM row) and wide-object overflow-slot writes. These dominate the