From 5dd56daf06941ce2654487b3be71c7ebea6d171c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 25 Jul 2026 09:31:14 +0200 Subject: [PATCH 1/4] =?UTF-8?q?perf(codegen):=20#6812=20=E2=80=94=20widen?= =?UTF-8?q?=20whole-loop=20clone=20eligibility:=20Mul=20RHS,=20let-alias,?= =?UTF-8?q?=20inner=20while=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/perry-codegen/src/stmt/loops.rs | 110 +++++++++++++++++++++---- 1 file changed, 92 insertions(+), 18 deletions(-) diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 91d3d14a4..032e45003 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -908,7 +908,9 @@ fn packed_f64_range_loop_index_offset(index: &perry_hir::Expr, counter_id: u32) use perry_hir::{BinaryOp, Expr}; let offset = match index { Expr::LocalGet(id) if *id == counter_id => Some(0i64), - Expr::Binary { op, left, right } if matches!(op, BinaryOp::Add | BinaryOp::Sub) => { + Expr::Binary { op, left, right } + if matches!(op, BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul) => + { match (left.as_ref(), right.as_ref()) { (Expr::LocalGet(id), Expr::Integer(c)) if *id == counter_id => { if matches!(op, BinaryOp::Sub) { @@ -1849,6 +1851,7 @@ enum ObjectArrayWriteNumber { Constant(f64), Add(Box, Box), Sub(Box, Box), + Mul(Box, Box), } /// Keep the transactional clone small enough that unrolling does not turn a @@ -1896,7 +1899,9 @@ fn match_object_array_write_number( Expr::Binary { op, left, right } if matches!(op, BinaryOp::Add | BinaryOp::Sub) => { 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)?; - Some(if matches!(op, BinaryOp::Add) { + Some(if matches!(op, BinaryOp::Mul) { + ObjectArrayWriteNumber::Mul(Box::new(left), Box::new(right)) + } else if matches!(op, BinaryOp::Add) { ObjectArrayWriteNumber::Add(Box::new(left), Box::new(right)) } else { ObjectArrayWriteNumber::Sub(Box::new(left), Box::new(right)) @@ -1941,6 +1946,21 @@ fn object_array_write_number_finite_range( )?; finite_range(left_lo + right_lo, left_hi + right_hi) } + ObjectArrayWriteNumber::Mul(left, right) => { + let (left_lo, left_hi) = + object_array_write_number_finite_range(left, outer_start, outer_bound, inner_bound)?; + let (right_lo, right_hi) = + object_array_write_number_finite_range(right, outer_start, outer_bound, inner_bound)?; + let products = [ + left_lo * right_lo, + left_lo * right_hi, + left_hi * right_lo, + left_hi * right_hi, + ]; + let lo = products.iter().copied().fold(f64::INFINITY, f64::min); + let hi = products.iter().copied().fold(f64::NEG_INFINITY, f64::max); + finite_range(lo, hi) + } ObjectArrayWriteNumber::Sub(left, right) => { let (left_lo, left_hi) = object_array_write_number_finite_range( left, @@ -2027,21 +2047,67 @@ fn match_object_array_write_loop( } let (outer_counter_id, outer_start, outer_bound) = match_constant_counted_for(ctx, init, condition, update)?; - let [Stmt::For { - init: inner_init, - condition: inner_condition, - update: inner_update, - body: inner_body, - }] = body - else { - return None; - }; - let (inner_counter_id, inner_start, inner_bound) = match_constant_counted_for( - ctx, - inner_init.as_deref(), - inner_condition.as_ref(), - inner_update.as_ref(), - )?; + let (inner_counter_id, inner_start, inner_bound, inner_body): (u32, i32, i32, &[Stmt]) = + match body { + [Stmt::For { + init: inner_init, + condition: inner_condition, + update: inner_update, + body: inner_body, + }] => { + let (id, start, bound) = match_constant_counted_for( + ctx, + inner_init.as_deref(), + inner_condition.as_ref(), + inner_update.as_ref(), + )?; + (id, start, bound, inner_body.as_slice()) + } + // `let i = 0; while (i < N) { …; i++ }` is the same counted loop + // spelled differently. The store-shape constraints below admit + // ONLY PutValueSet statements between the alias binding and the + // trailing increment — no `continue` (which would skip a + // while-loop's trailing increment but not a for-update) or other + // control flow can be present in a matched body. The emitter + // already finalizes both counter slots after the fast nest, so + // the function-scoped `i` observes its post-loop value. + [Stmt::Let { + id: while_counter, + init: Some(counter_init), + .. + }, Stmt::While { + condition: while_cond, + body: while_body, + }] => { + use perry_hir::{CompareOp, UpdateOp}; + let start = match_nonnegative_constant_i32_with_ctx(ctx, counter_init)?; + let bound = match while_cond { + Expr::Compare { + op: CompareOp::Lt, + left, + right, + } if matches!(left.as_ref(), Expr::LocalGet(id) if id == while_counter) => { + match_nonnegative_constant_i32_with_ctx(ctx, right)? + } + _ => return None, + }; + let Some((last, head)) = while_body.split_last() else { + return None; + }; + if !matches!( + last, + Stmt::Expr(Expr::Update { + id, + op: UpdateOp::Increment, + .. + }) if id == while_counter + ) { + return None; + } + (*while_counter, start, bound, head) + } + _ => return None, + }; // Starting at zero lets the runtime preflight prove one contiguous dense // prefix and keeps the raw element address calculation minimal. if inner_start != 0 @@ -2056,7 +2122,10 @@ fn match_object_array_write_loop( let Some(( Stmt::Let { id: alias_id, - mutable: false, + // `let` aliases qualify too: every statement in the matched + // region must be a PutValueSet on the alias (anything else + // rejects the loop), so reassignment is structurally + // impossible; captures are excluded via `boxed_vars` below. init: Some(Expr::IndexGet { object, index }), .. }, @@ -2156,6 +2225,11 @@ fn emit_object_array_write_number( let right = emit_object_array_write_number(ctx, right, outer, inner); ctx.block().fsub(&left, &right) } + ObjectArrayWriteNumber::Mul(left, right) => { + let left = emit_object_array_write_number(ctx, left, outer, inner); + let right = emit_object_array_write_number(ctx, right, outer, inner); + ctx.block().fmul(&left, &right) + } } } From 8f69f91887509192308ee7cf2dec5645b011b4b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 25 Jul 2026 09:38:18 +0200 Subject: [PATCH 2/4] =?UTF-8?q?fix(codegen):=20#6812=20=E2=80=94=20Mul=20w?= =?UTF-8?q?idening=20landed=20on=20the=20wrong=20matcher;=20index-offset?= =?UTF-8?q?=20matcher=20must=20stay=20Add|Sub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blind text replace widened packed_f64_range_loop_index_offset's guard instead of match_object_array_write_number's — which would have treated an `i * c` index as offset `c` (a wrong-index miscompile) in the packed range-loop family. Reverted there; Mul now admitted where intended, with the endpoint-product range analysis and fmul emission it was built for. --- crates/perry-codegen/src/stmt/loops.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 032e45003..6eb846fa6 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -908,9 +908,7 @@ fn packed_f64_range_loop_index_offset(index: &perry_hir::Expr, counter_id: u32) use perry_hir::{BinaryOp, Expr}; let offset = match index { Expr::LocalGet(id) if *id == counter_id => Some(0i64), - Expr::Binary { op, left, right } - if matches!(op, BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul) => - { + Expr::Binary { op, left, right } if matches!(op, BinaryOp::Add | BinaryOp::Sub) => { match (left.as_ref(), right.as_ref()) { (Expr::LocalGet(id), Expr::Integer(c)) if *id == counter_id => { if matches!(op, BinaryOp::Sub) { @@ -1896,7 +1894,9 @@ fn match_object_array_write_number( 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) => { + 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)?; Some(if matches!(op, BinaryOp::Mul) { @@ -1947,10 +1947,18 @@ fn object_array_write_number_finite_range( finite_range(left_lo + right_lo, left_hi + right_hi) } ObjectArrayWriteNumber::Mul(left, right) => { - let (left_lo, left_hi) = - object_array_write_number_finite_range(left, outer_start, outer_bound, inner_bound)?; - let (right_lo, right_hi) = - object_array_write_number_finite_range(right, outer_start, outer_bound, inner_bound)?; + let (left_lo, left_hi) = object_array_write_number_finite_range( + left, + outer_start, + outer_bound, + inner_bound, + )?; + let (right_lo, right_hi) = object_array_write_number_finite_range( + right, + outer_start, + outer_bound, + inner_bound, + )?; let products = [ left_lo * right_lo, left_lo * right_hi, From 12f107755a490f27126879bbe8f3eb33fdaed1b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 25 Jul 2026 11:24:53 +0200 Subject: [PATCH 3/4] docs(changelog): changeset for clone-eligibility widening --- changelog.d/6812-clone-eligibility.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/6812-clone-eligibility.md diff --git a/changelog.d/6812-clone-eligibility.md b/changelog.d/6812-clone-eligibility.md new file mode 100644 index 000000000..041c6b29c --- /dev/null +++ b/changelog.d/6812-clone-eligibility.md @@ -0,0 +1 @@ +perf(codegen): #6812 — widen whole-loop object-write clone eligibility: multiplication in numeric RHS (endpoint-product interval bounds), `let`-declared receiver aliases (reassignment is structurally impossible in the matched region), and the `let i = 0; while (i < N)` spelling of the counted inner loop. Three matrix rows move from 6–9× node to 0.62–0.75× (beating node); the emitter needed no changes — it already finalizes counter slots on the fast edge. Also restores the packed-f64 index-offset matcher's exact Add|Sub guard after a mis-scoped widening was caught pre-merge (multiplied-index sanity added to the validation set). From 138cf0a2e7bf3f30994586ba5d76b7d15a43d951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 25 Jul 2026 11:30:41 +0200 Subject: [PATCH 4/4] =?UTF-8?q?perf(codegen,runtime):=20#6812=20=E2=80=94?= =?UTF-8?q?=20dynamic=20array-length=20inner=20bounds=20for=20the=20versio?= =?UTF-8?q?ned=20write=20clone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `for (let i = 0; i < arr.length; i++)` over the same array the loop writes qualifies: the guard receives a u32::MAX sentinel and resolves the scan length from the validated array's header (rejecting >16M), and the emitter loads the length register in a new guard-ok fast.entry block — the fast nest can never outrun the proven prefix. Length is loop-invariant by construction (the matched body admits only element-field stores). The 16M cap serves as the finite-range ceiling. --- crates/perry-codegen/src/stmt/loops.rs | 124 +++++++++++++++++--- crates/perry-runtime/src/proxy/put_value.rs | 10 ++ 2 files changed, 119 insertions(+), 15 deletions(-) diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 6eb846fa6..90bd3a16a 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -1863,7 +1863,16 @@ struct ObjectArrayWriteLoop { outer_start: i32, outer_bound: i32, inner_counter_id: u32, + /// Constant inner bound, or — when `inner_bound_from_length` — the 16M + /// ceiling used only by the finite-range proof (the runtime bound is the + /// matched array's own length, resolved by the preflight guard). inner_bound: i32, + /// #6812: `for (let i = 0; i < arr.length; i++)` over the SAME array the + /// loop writes into. The guard receives a `u32::MAX` sentinel, validates + /// the array first, resolves the scan length from the header (rejecting + /// > 16M so the fast nest can never outrun the proven prefix), and the + /// emitter loads the length register after guard-ok. + inner_bound_from_length: bool, array_id: u32, properties: Vec, values: Vec, @@ -2055,6 +2064,7 @@ fn match_object_array_write_loop( } let (outer_counter_id, outer_start, outer_bound) = match_constant_counted_for(ctx, init, condition, update)?; + let mut dyn_len_source: Option = None; let (inner_counter_id, inner_start, inner_bound, inner_body): (u32, i32, i32, &[Stmt]) = match body { [Stmt::For { @@ -2063,13 +2073,61 @@ fn match_object_array_write_loop( update: inner_update, body: inner_body, }] => { - let (id, start, bound) = match_constant_counted_for( + if let Some((id, start, bound)) = match_constant_counted_for( ctx, inner_init.as_deref(), inner_condition.as_ref(), inner_update.as_ref(), - )?; - (id, start, bound, inner_body.as_slice()) + ) { + (id, start, bound, inner_body.as_slice()) + } else { + // `for (let i = 0; i < xs.length; i++)` — the bound is the + // length of some local; required below to be the SAME + // array this loop writes into (which the matched body + // cannot mutate structurally: only field stores on the + // element alias are admitted). + use perry_hir::UpdateOp; + let (id, start) = match inner_init.as_deref()? { + Stmt::Let { + id, + init: Some(start), + .. + } => (*id, match_nonnegative_constant_i32_with_ctx(ctx, start)?), + _ => return None, + }; + let len_source = match inner_condition.as_ref()? { + Expr::Compare { + op: perry_hir::CompareOp::Lt, + left, + right, + } if matches!(left.as_ref(), Expr::LocalGet(l) if *l == id) => { + match right.as_ref() { + Expr::PropertyGet { + object, property, .. + } if property == "length" => match object.as_ref() { + Expr::LocalGet(src) => *src, + _ => return None, + }, + _ => return None, + } + } + _ => return None, + }; + if !matches!( + inner_update.as_ref()?, + Expr::Update { + id: uid, + op: UpdateOp::Increment, + .. + } if *uid == id + ) { + return None; + } + dyn_len_source = Some(len_source); + // 16M is the guard's hard cap in sentinel mode, so it is + // a sound ceiling for the finite-range proof. + (id, start, 16_000_000, inner_body.as_slice()) + } } // `let i = 0; while (i < N) { …; i++ }` is the same counted loop // spelled differently. The store-shape constraints below admit @@ -2162,6 +2220,14 @@ fn match_object_array_write_loop( { return None; } + // Dynamic bound: `i < xs.length` must read the SAME array being written + // (its length is then loop-invariant — the matched body admits only + // element-field stores, never structural array mutation). + if let Some(len_source) = dyn_len_source { + if len_source != *array_id { + return None; + } + } let match_store = |effect: &Expr| -> Option<(String, ObjectArrayWriteNumber)> { let Expr::PutValueSet { @@ -2207,6 +2273,7 @@ fn match_object_array_write_loop( outer_bound, inner_counter_id, inner_bound, + inner_bound_from_length: dyn_len_source.is_some(), array_id: *array_id, properties, values, @@ -2276,7 +2343,14 @@ fn lower_object_array_write_versioned_for( key_boxes.push(zero.clone()); } let field_count = matched.properties.len().to_string(); - let inner_bound = matched.inner_bound.to_string(); + // Dynamic-bound loops pass the u32::MAX sentinel: the guard validates the + // array FIRST, then resolves the scan length from its header (rejecting + // > 16M), so the fast nest can never outrun the proven prefix. + let inner_bound = if matched.inner_bound_from_length { + u32::MAX.to_string() + } else { + matched.inner_bound.to_string() + }; let packed_slots = { let blk = ctx.block(); blk.call( @@ -2312,12 +2386,14 @@ fn lower_object_array_write_versioned_for( ctx.block().br(&merge_label); } + let fast_entry_idx = ctx.new_block("object_array_write.loop.fast.entry"); let fast_outer_cond_idx = ctx.new_block("object_array_write.loop.fast.outer.cond"); let fast_inner_pre_idx = ctx.new_block("object_array_write.loop.fast.inner.preheader"); let fast_inner_cond_idx = ctx.new_block("object_array_write.loop.fast.inner.cond"); let fast_inner_body_idx = ctx.new_block("object_array_write.loop.fast.inner.body"); let fast_inner_exit_idx = ctx.new_block("object_array_write.loop.fast.inner.exit"); let fast_done_idx = ctx.new_block("object_array_write.loop.fast.done"); + let fast_entry_label = ctx.block_label(fast_entry_idx); let fast_outer_cond_label = ctx.block_label(fast_outer_cond_idx); let fast_inner_pre_label = ctx.block_label(fast_inner_pre_idx); let fast_inner_cond_label = ctx.block_label(fast_inner_cond_idx); @@ -2346,7 +2422,7 @@ fn lower_object_array_write_versioned_for( (slots, array_ptr) }; - let fast_scan_start = fast_outer_cond_idx; + let fast_scan_start = fast_entry_idx; let (outer_next, inner_next) = { let blk = ctx .func @@ -2354,11 +2430,24 @@ fn lower_object_array_write_versioned_for( .expect("object-array preheader block must exist"); (blk.fresh_reg(), blk.fresh_reg()) }; + // Guard-ok entry: the array is proven live/dense here, so a length load + // is safe. Constant-bound loops use the compile-time bound unchanged. + ctx.current_block = fast_entry_idx; + let inner_bound_operand = if matched.inner_bound_from_length { + let bits = ctx.block().bitcast_double_to_i64(&array_box); + let handle = ctx.block().and(I64, &bits, crate::nanbox::POINTER_MASK_I64); + let len_ptr = ctx.block().inttoptr(I64, &handle); + ctx.block().load(I32, &len_ptr) + } else { + matched.inner_bound.to_string() + }; + ctx.block().br(&fast_outer_cond_label); + ctx.current_block = fast_outer_cond_idx; let outer = ctx.block().phi( I32, &[ - (&matched.outer_start.to_string(), &preheader_label), + (&matched.outer_start.to_string(), &fast_entry_label), (&outer_next, &fast_inner_exit_label), ], ); @@ -2380,9 +2469,7 @@ fn lower_object_array_write_versioned_for( (&inner_next, &fast_inner_body_label), ], ); - let inner_more = ctx - .block() - .icmp_slt(I32, &inner, &matched.inner_bound.to_string()); + let inner_more = ctx.block().icmp_slt(I32, &inner, &inner_bound_operand); ctx.block() .cond_br(&inner_more, &fast_inner_body_label, &fast_inner_exit_label); @@ -2424,16 +2511,23 @@ fn lower_object_array_write_versioned_for( // are normally block-scoped, but this also preserves transformed `var` // cases and future HIR consumers without adding work inside either loop. ctx.current_block = fast_done_idx; - for (id, final_value) in [ - (matched.outer_counter_id, matched.outer_bound), - (matched.inner_counter_id, matched.inner_bound), + let inner_final: String = if matched.inner_bound_from_length { + // Dynamic bound: the post-loop counter value is the length register + // (fast_done is dominated by fast_entry, so it is in scope). + inner_bound_operand.clone() + } else { + matched.inner_bound.to_string() + }; + for (id, final_i32) in [ + (matched.outer_counter_id, matched.outer_bound.to_string()), + (matched.inner_counter_id, inner_final), ] { if let Some(slot) = ctx.locals.get(&id).cloned() { - let value = crate::nanbox::double_literal(final_value as f64); + let value = ctx.block().sitofp(I32, &final_i32, DOUBLE); ctx.block().store(DOUBLE, &value, &slot); } if let Some(slot) = ctx.i32_counter_slots.get(&id).cloned() { - ctx.block().store(I32, &final_value.to_string(), &slot); + ctx.block().store(I32, &final_i32, &slot); } } ctx.block().br(&merge_label); @@ -2444,7 +2538,7 @@ fn lower_object_array_write_versioned_for( let guard_ok = ctx.block().icmp_ne(I64, &packed_slots, "0"); if fast_call_free { ctx.block() - .cond_br(&guard_ok, &fast_outer_cond_label, &slow_pre_label); + .cond_br(&guard_ok, &fast_entry_label, &slow_pre_label); } else { ctx.block().br(&slow_pre_label); } diff --git a/crates/perry-runtime/src/proxy/put_value.rs b/crates/perry-runtime/src/proxy/put_value.rs index 5e8b83a18..774aaec40 100644 --- a/crates/perry-runtime/src/proxy/put_value.rs +++ b/crates/perry-runtime/src/proxy/put_value.rs @@ -442,6 +442,16 @@ fn object_array_numeric_write_slots(array: f64, keys: &[f64], count: u32) -> Opt let arr = array_addr as *const crate::array::ArrayHeader; let (length, capacity) = unsafe { ((*arr).length, (*arr).capacity) }; + // #6812 dynamic-bound loops (`i < arr.length`): the u32::MAX sentinel + // resolves to the VALIDATED array's own length — after the array-kind + // checks above, before the 16M cap below, so the emitter's fast nest + // (which loads the same header length after guard-ok) can never outrun + // the proven prefix. An empty array is not worth the fast path. + let count = if count == u32::MAX { length } else { count }; + if count == 0 { + trace_object_array_numeric_write_rejection("empty dynamic-length prefix"); + return None; + } if length > 16_000_000 || capacity > 16_000_000 || length > capacity || count > length { trace_object_array_numeric_write_rejection("array length/capacity/prefix bound"); return None;