Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/6812-w8-temps-w13-intkeys.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions crates/perry-codegen/src/expr/proxy_reflect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,13 @@ fn static_write_key(ctx: &FnCtx<'_>, key: &Expr) -> Option<String> {
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,
}
}
Expand Down
161 changes: 156 additions & 5 deletions crates/perry-codegen/src/stmt/loops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1894,20 +1926,30 @@ fn match_object_array_write_number(
expr: &perry_hir::Expr,
outer_counter_id: u32,
inner_counter_id: u32,
temps: &std::collections::HashMap<u32, ObjectArrayWriteNumber>,
) -> Option<ObjectArrayWriteNumber> {
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))
}
Expr::Number(n) if n.is_finite() => Some(ObjectArrayWriteNumber::Constant(*n)),
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) {
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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;
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
let match_store = |effect: &Expr| -> Option<(String, ObjectArrayWriteNumber)> {
let Expr::PutValueSet {
target,
Expand All @@ -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))
};
Expand Down Expand Up @@ -2322,10 +2421,62 @@ fn lower_object_array_write_versioned_for(
update: Option<&perry_hir::Expr>,
body: &[Stmt],
) -> Result<bool> {
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<Stmt> = 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);
Expand Down
23 changes: 13 additions & 10 deletions docs/object-write-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading