diff --git a/changelog.d/6812-nonzero-object-write-start.md b/changelog.d/6812-nonzero-object-write-start.md new file mode 100644 index 0000000000..d2c8a6b843 --- /dev/null +++ b/changelog.d/6812-nonzero-object-write-start.md @@ -0,0 +1 @@ +perf(codegen, runtime): admit constant non-zero inner starts to the guarded object-array numeric write clone. A range preflight proves exactly the active receiver suffix after the semantic priming peel, while range analysis, absolute array indexes, and the generated inner-loop phi preserve the source start value; unsupported receivers and runtime-length bounds continue through the untouched semantic fallback. diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index cf1d6faf7e..e1f7d3b73e 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -490,6 +490,11 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { I64, &[DOUBLE, DOUBLE, DOUBLE, DOUBLE, DOUBLE, I32, I32], ); + module.declare_function( + "js_object_array_numeric_write_range_guard", + I64, + &[DOUBLE, DOUBLE, DOUBLE, DOUBLE, DOUBLE, I32, I32, I32], + ); module.declare_function( "js_super_put_value_set", DOUBLE, diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 7a9ccba8b8..4a49f4be19 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -2136,6 +2136,9 @@ struct ObjectArrayWriteLoop { outer_start: i32, outer_bound: i32, inner_counter_id: u32, + /// The fast loop may start inside the dense array. The range guard proves + /// exactly `[inner_start, inner_bound)` before the raw clone runs. + inner_start: i32, /// 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). @@ -2224,6 +2227,7 @@ fn object_array_write_number_finite_range( expr: &ObjectArrayWriteNumber, outer_start: i32, outer_bound: i32, + inner_start: i32, inner_bound: i32, ) -> Option<(f64, f64)> { let finite_range = @@ -2232,19 +2236,23 @@ fn object_array_write_number_finite_range( ObjectArrayWriteNumber::OuterCounter => { finite_range(outer_start as f64, (outer_bound - 1) as f64) } - ObjectArrayWriteNumber::InnerCounter => finite_range(0.0, (inner_bound - 1) as f64), + ObjectArrayWriteNumber::InnerCounter => { + finite_range(inner_start as f64, (inner_bound - 1) as f64) + } ObjectArrayWriteNumber::Constant(value) => finite_range(*value, *value), ObjectArrayWriteNumber::Add(left, right) => { let (left_lo, left_hi) = object_array_write_number_finite_range( left, outer_start, outer_bound, + inner_start, inner_bound, )?; let (right_lo, right_hi) = object_array_write_number_finite_range( right, outer_start, outer_bound, + inner_start, inner_bound, )?; finite_range(left_lo + right_lo, left_hi + right_hi) @@ -2254,12 +2262,14 @@ fn object_array_write_number_finite_range( left, outer_start, outer_bound, + inner_start, inner_bound, )?; let (right_lo, right_hi) = object_array_write_number_finite_range( right, outer_start, outer_bound, + inner_start, inner_bound, )?; let products = [ @@ -2277,12 +2287,14 @@ fn object_array_write_number_finite_range( left, outer_start, outer_bound, + inner_start, inner_bound, )?; let (right_lo, right_hi) = object_array_write_number_finite_range( right, outer_start, outer_bound, + inner_start, inner_bound, )?; finite_range(left_lo - right_hi, left_hi - right_lo) @@ -2296,12 +2308,14 @@ fn object_array_write_number_finite_range( left, outer_start, outer_bound, + inner_start, inner_bound, )?; let (right_lo, right_hi) = object_array_write_number_finite_range( right, outer_start, outer_bound, + inner_start, inner_bound, )?; if left_lo < 0.0 || right_lo != right_hi || right_lo < 1.0 || right_lo.fract() != 0.0 { @@ -2490,10 +2504,17 @@ fn match_object_array_write_loop( } _ => 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 + // A constant non-zero start can use the range preflight while retaining + // absolute array indexes in the raw clone. + if inner_start < 0 + || inner_start >= inner_bound || inner_bound > 16_000_000 + // A runtime length can be below the source start. In that case the + // original loop leaves its counter at `start`, while the existing + // fast completion path publishes `length`; keep that separate loop + // dimension on the semantic path until completion uses max(start, + // length). + || (inner_start != 0 && dyn_len_source.is_some()) || outer_counter_id == inner_counter_id || ctx.boxed_vars.contains(&outer_counter_id) || ctx.boxed_vars.contains(&inner_counter_id) @@ -2675,6 +2696,7 @@ fn match_object_array_write_loop( &idx, outer_start, outer_bound, + inner_start, inner_bound, ) { @@ -2694,6 +2716,7 @@ fn match_object_array_write_loop( &value, outer_start, outer_bound, + inner_start, inner_bound, )?; key_table = Some(KeyTableLane { @@ -2737,7 +2760,13 @@ fn match_object_array_write_loop( 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)?; + object_array_write_number_finite_range( + &value, + outer_start, + outer_bound, + inner_start, + inner_bound, + )?; properties.push(property); values.push(value); if properties.len() > MAX_OBJECT_ARRAY_WRITE_FIELDS { @@ -2768,8 +2797,13 @@ fn match_object_array_write_loop( } } - // v1: a key-table lane is exclusive — single group, sole store. - if key_table.is_some() && (groups.len() != 1 || !groups[0].properties.is_empty()) { + // v1: a key-table lane is exclusive — single group, sole store. Its + // wrapper currently exposes only the zero-based prefix guard; keep a + // non-zero range on the ordinary dynamic-key path rather than scanning + // receivers the generated loop never uses. + if key_table.is_some() + && (inner_start != 0 || groups.len() != 1 || !groups[0].properties.is_empty()) + { return None; } let first = groups.remove(0); @@ -2778,6 +2812,7 @@ fn match_object_array_write_loop( outer_start, outer_bound, inner_counter_id, + inner_start, inner_bound, inner_bound_from_length: dyn_len_source.is_some(), array_id: first.array_id, @@ -2898,7 +2933,8 @@ fn lower_object_array_write_versioned_for( // 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. + // versioning path. Non-zero starts use the range preflight below, so the + // peel and the proof cover the same active receiver suffix. let mut peeled_init_stmt: Option = None; if matched.outer_start < matched.outer_bound { let Some(Stmt::Let { @@ -2993,19 +3029,37 @@ fn lower_object_array_write_versioned_for( ctx.block().zext(I32, &ret, I64) } else { let blk = ctx.block(); - blk.call( - I64, - "js_object_array_numeric_write_guard", - &[ - (DOUBLE, &array_box), - (DOUBLE, &key_boxes[0]), - (DOUBLE, &key_boxes[1]), - (DOUBLE, &key_boxes[2]), - (DOUBLE, &key_boxes[3]), - (I32, &field_count), - (I32, &inner_bound), - ], - ) + if matched.inner_start == 0 { + blk.call( + I64, + "js_object_array_numeric_write_guard", + &[ + (DOUBLE, &array_box), + (DOUBLE, &key_boxes[0]), + (DOUBLE, &key_boxes[1]), + (DOUBLE, &key_boxes[2]), + (DOUBLE, &key_boxes[3]), + (I32, &field_count), + (I32, &inner_bound), + ], + ) + } else { + let inner_start = matched.inner_start.to_string(); + blk.call( + I64, + "js_object_array_numeric_write_range_guard", + &[ + (DOUBLE, &array_box), + (DOUBLE, &key_boxes[0]), + (DOUBLE, &key_boxes[1]), + (DOUBLE, &key_boxes[2]), + (DOUBLE, &key_boxes[3]), + (I32, &field_count), + (I32, &inner_start), + (I32, &inner_bound), + ], + ) + } }; // #6812 (w9): one preflight guard call per extra group — each group is // monomorphic on its own array, so the single-shape guard applies @@ -3027,19 +3081,37 @@ fn lower_object_array_write_versioned_for( let g_field_count = group.properties.len().to_string(); let g_packed = { let blk = ctx.block(); - blk.call( - I64, - "js_object_array_numeric_write_guard", - &[ - (DOUBLE, &g_box), - (DOUBLE, &g_keys[0]), - (DOUBLE, &g_keys[1]), - (DOUBLE, &g_keys[2]), - (DOUBLE, &g_keys[3]), - (I32, &g_field_count), - (I32, &inner_bound), - ], - ) + if matched.inner_start == 0 { + blk.call( + I64, + "js_object_array_numeric_write_guard", + &[ + (DOUBLE, &g_box), + (DOUBLE, &g_keys[0]), + (DOUBLE, &g_keys[1]), + (DOUBLE, &g_keys[2]), + (DOUBLE, &g_keys[3]), + (I32, &g_field_count), + (I32, &inner_bound), + ], + ) + } else { + let inner_start = matched.inner_start.to_string(); + blk.call( + I64, + "js_object_array_numeric_write_range_guard", + &[ + (DOUBLE, &g_box), + (DOUBLE, &g_keys[0]), + (DOUBLE, &g_keys[1]), + (DOUBLE, &g_keys[2]), + (DOUBLE, &g_keys[3]), + (I32, &g_field_count), + (I32, &inner_start), + (I32, &inner_bound), + ], + ) + } }; extra_guards.push((g_packed, g_box)); } @@ -3180,7 +3252,7 @@ fn lower_object_array_write_versioned_for( let inner = ctx.block().phi( I32, &[ - ("0", &fast_inner_pre_label), + (&matched.inner_start.to_string(), &fast_inner_pre_label), (&inner_next, &fast_inner_latch_label), ], ); diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index e6807f3e4e..0af14a7343 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -14727,6 +14727,96 @@ fn nested_same_shape_object_writes_version_one_through_four_fields() { } } + let mut nonzero_start_body = loop_body(2); + let Stmt::For { + body: outer_body, .. + } = &mut nonzero_start_body[1] + else { + panic!("expected outer loop"); + }; + let Stmt::For { init, .. } = &mut outer_body[0] else { + panic!("expected inner loop"); + }; + let Some(inner_init) = init else { + panic!("expected inner loop initializer"); + }; + let Stmt::Let { + init: Some(start), .. + } = inner_init.as_mut() + else { + panic!("expected inner counter binding"); + }; + *start = Expr::Integer(5); + + let nonzero_start = compile_ir("nested_object_write_nonzero_start_loop", nonzero_start_body); + assert_eq!( + nonzero_start + .matches("call i64 @js_object_array_numeric_write_range_guard") + .count(), + 1, + "a non-zero start must perform one proof over exactly its active receiver range:\n{nonzero_start}" + ); + assert!( + nonzero_start.contains("[ 5, %object_array_write.loop.fast.inner.preheader"), + "the fast inner-counter phi must preserve the source start value:\n{nonzero_start}" + ); + assert!( + nonzero_start.contains("for.object_array_write_peel"), + "the semantic peel must prime typed-layout state for the same suffix the range guard proves:\n{nonzero_start}" + ); + assert!( + nonzero_start.contains("object_array_write.loop.slow.preheader") + && nonzero_start + .matches("call double @js_put_value_set_ic_miss") + .count() + >= 2, + "guard failure must retain both original semantic PutValue sites:\n{nonzero_start}" + ); + + let mut nonzero_dynamic_bound_body = loop_body(1); + let Stmt::For { + body: outer_body, .. + } = &mut nonzero_dynamic_bound_body[1] + else { + panic!("expected outer loop"); + }; + let Stmt::For { + init, condition, .. + } = &mut outer_body[0] + else { + panic!("expected inner loop"); + }; + let Some(inner_init) = init else { + panic!("expected inner loop initializer"); + }; + let Stmt::Let { + init: Some(start), .. + } = inner_init.as_mut() + else { + panic!("expected inner counter binding"); + }; + *start = Expr::Integer(5); + let Some(Expr::Compare { right, .. }) = condition else { + panic!("expected inner loop condition"); + }; + *right = Box::new(length(objects)); + + let nonzero_dynamic_bound = compile_ir( + "nested_object_write_nonzero_dynamic_bound_loop", + nonzero_dynamic_bound_body, + ); + assert!( + !nonzero_dynamic_bound.contains("call i64 @js_object_array_numeric_write_guard") + && !nonzero_dynamic_bound + .contains("call i64 @js_object_array_numeric_write_range_guard") + && !nonzero_dynamic_bound.contains("object_array_write.loop.fast"), + "a runtime bound below the start has distinct final-counter semantics and must remain on the semantic loop:\n{nonzero_dynamic_bound}" + ); + assert!( + nonzero_dynamic_bound.contains("call double @js_put_value_set_ic_miss"), + "the rejected dynamic-bound form must retain its original PutValue site:\n{nonzero_dynamic_bound}" + ); + let rejected = compile_ir("nested_object_write5_loop", loop_body(5)); assert!( !rejected.contains("call i64 @js_object_array_numeric_write_guard") diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index 84c07235cb..59eca98119 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -2601,6 +2601,27 @@ mod tests { ) } + fn object_array_numeric_write_range_guard( + array: f64, + keys: &[f64], + start: u32, + end: u32, + ) -> u64 { + assert!((1..=4).contains(&keys.len())); + let mut padded = [0.0; 4]; + padded[..keys.len()].copy_from_slice(keys); + put_value::js_object_array_numeric_write_range_guard( + array, + padded[0], + padded[1], + padded[2], + padded[3], + keys.len() as u32, + start, + end, + ) + } + /// #6809/#6812: the whole-loop preflight may only publish raw slot indexes /// when every array element has the same writable data layout. The /// generated clone performs no checks after this result, so heterogeneous @@ -2754,6 +2775,35 @@ mod tests { "content-equal but distinct shape keys arrays must not share raw slots" ); + let ranged_values = [ + boxed_object(other), + boxed_object(first), + boxed_object(second), + ]; + let ranged = + crate::array::js_array_from_f64(ranged_values.as_ptr(), ranged_values.len() as u32); + let ranged_box = boxed_object(ranged.cast()); + assert_eq!( + object_array_numeric_write_range_guard(ranged_box, &[c, d], 1, 3), + (4u64 << 16) | 3, + "a non-zero range must ignore an ineligible receiver before its source start" + ); + assert_eq!( + object_array_numeric_write_guard(ranged_box, &[c, d], 3), + 0, + "the legacy prefix ABI must continue proving from element zero" + ); + assert_eq!( + object_array_numeric_write_range_guard(ranged_box, &[c, d], 3, 3), + 0, + "an empty receiver range must reject" + ); + assert_eq!( + object_array_numeric_write_range_guard(ranged_box, &[c, d], 1, 4), + 0, + "a receiver range may not outrun the array" + ); + unsafe { let original = (*first).class_id; let first_header = diff --git a/crates/perry-runtime/src/proxy/put_value.rs b/crates/perry-runtime/src/proxy/put_value.rs index 71c0f49632..b65ab03047 100644 --- a/crates/perry-runtime/src/proxy/put_value.rs +++ b/crates/perry-runtime/src/proxy/put_value.rs @@ -749,16 +749,17 @@ fn trace_object_array_numeric_write_stage(value: Option, reason: &'static /// receiver, key, or layout state that could require ordinary `[[Set]]` /// semantics. The fixed array is stack-only; no descriptor allocation is /// introduced on the preflight path. -fn object_array_numeric_write_slots(array: f64, keys: &[f64], count: u32) -> Option<[u16; 4]> { +fn object_array_numeric_write_slots( + array: f64, + keys: &[f64], + receiver_start: u32, + receiver_end: u32, +) -> Option<[u16; 4]> { // Reuse the process gate js_gc_init disables for typed-feedback tracing, // typed-layout verification, and the explicit inline-field escape hatch. // This loop bypasses the same observations/checks as the class-field // inline clone and therefore must honor the identical gate. - if count == 0 - || keys.is_empty() - || keys.len() > 4 - || !crate::object::class_field_inline_guard_enabled() - { + if keys.is_empty() || keys.len() > 4 || !crate::object::class_field_inline_guard_enabled() { trace_object_array_numeric_write_rejection("disabled gate or invalid field/count bound"); return None; } @@ -793,12 +794,16 @@ fn object_array_numeric_write_slots(array: f64, keys: &[f64], count: u32) -> Opt // 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"); + let receiver_end = if receiver_end == u32::MAX { + length + } else { + receiver_end + }; + if receiver_start >= receiver_end { + trace_object_array_numeric_write_rejection("empty or reversed receiver range"); return None; } - if length > 16_000_000 || capacity > 16_000_000 || length > capacity || count > length { + if length > 16_000_000 || capacity > 16_000_000 || length > capacity || receiver_end > length { trace_object_array_numeric_write_rejection("array length/capacity/prefix bound"); return None; } @@ -903,7 +908,7 @@ fn object_array_numeric_write_slots(array: f64, keys: &[f64], count: u32) -> Opt let elements = unsafe { (arr as *const u8).add(std::mem::size_of::()) as *const f64 }; - let first_bits = unsafe { (*elements).to_bits() }; + let first_bits = unsafe { (*elements.add(receiver_start as usize)).to_bits() }; if first_bits == crate::value::TAG_HOLE { trace_object_array_numeric_write_rejection("first receiver is a hole"); return None; @@ -996,7 +1001,7 @@ fn object_array_numeric_write_slots(array: f64, keys: &[f64], count: u32) -> Opt } } - for i in 1..count as usize { + for i in (receiver_start as usize + 1)..receiver_end as usize { let bits = unsafe { (*elements.add(i)).to_bits() }; if bits == crate::value::TAG_HOLE { trace_object_array_numeric_write_rejection("receiver prefix contains a hole"); @@ -1117,7 +1122,7 @@ pub extern "C" fn js_object_array_keytable_write_guard( } let keys: Vec = key_handles.iter().map(|h| h.get_nanbox_f64()).collect(); let Some(slots) = - object_array_numeric_write_slots(array_handle.get_nanbox_f64(), &keys, receiver_count) + object_array_numeric_write_slots(array_handle.get_nanbox_f64(), &keys, 0, receiver_count) else { return 0; }; @@ -1156,7 +1161,7 @@ pub extern "C" fn js_object_array_numeric_write_guard( } let keys = [key_1, key_2, key_3, key_4]; let Some(slots) = - object_array_numeric_write_slots(array, &keys[..field_count as usize], receiver_count) + object_array_numeric_write_slots(array, &keys[..field_count as usize], 0, receiver_count) else { return 0; }; @@ -1168,6 +1173,41 @@ pub extern "C" fn js_object_array_numeric_write_guard( }) } +/// Range variant for a constant non-zero inner loop start. The result has the +/// same packed-lane ABI as [`js_object_array_numeric_write_guard`], but proves +/// exactly `receiver_start..receiver_end`; generated code retains the array +/// base pointer and uses the original absolute element index. +#[no_mangle] +pub extern "C" fn js_object_array_numeric_write_range_guard( + array: f64, + key_1: f64, + key_2: f64, + key_3: f64, + key_4: f64, + field_count: u32, + receiver_start: u32, + receiver_end: u32, +) -> u64 { + if !(1..=4).contains(&field_count) { + return 0; + } + let keys = [key_1, key_2, key_3, key_4]; + let Some(slots) = object_array_numeric_write_slots( + array, + &keys[..field_count as usize], + receiver_start, + receiver_end, + ) else { + return 0; + }; + slots[..field_count as usize] + .iter() + .enumerate() + .fold(0, |packed, (index, slot)| { + packed | ((u64::from(*slot) + 1) << (index * 16)) + }) +} + /// Preserve the #6811 internal ABI for cached generated objects. New codegen /// uses [`js_object_array_numeric_write_guard`], but an object cache entry /// produced before the runtime rebuild may still reference this symbol. @@ -1178,7 +1218,7 @@ pub extern "C" fn js_object_array_numeric_write2_guard( key_2: f64, receiver_count: u32, ) -> u64 { - let Some(slots) = object_array_numeric_write_slots(array, &[key_1, key_2], receiver_count) + let Some(slots) = object_array_numeric_write_slots(array, &[key_1, key_2], 0, receiver_count) else { return 0; }; diff --git a/test-files/test_gap_6812_object_write_loop_generalization.ts b/test-files/test_gap_6812_object_write_loop_generalization.ts index 108419673c..34adfd2a24 100644 --- a/test-files/test_gap_6812_object_write_loop_generalization.ts +++ b/test-files/test_gap_6812_object_write_loop_generalization.ts @@ -46,6 +46,46 @@ for (let r = 0; r < 7; r++) { } console.log("four", fieldSum(four, ["a", "b", "c", "d"])); +// A non-zero inner start narrows the dense prefix proved by the guard. The +// skipped receivers must remain untouched, and the counter value used in the +// numeric RHS must still be the source index rather than a rebased offset. +const nonzero: any[] = [ + { x: 100, y: 100 }, + { x: 100, y: 100 }, + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 0 }, +]; +for (let r = 0; r < 7; r++) { + for (let i = 2; i < 5; i++) { + const object: any = nonzero[i]; + object.x = r + i; + object.y = r - i; + } +} +console.log( + "nonzero", + nonzero[0].x, + nonzero[1].y, + fieldSum(nonzero, ["x", "y"]), +); + +// The paired semantic fallback: mixed active receiver shapes must reject the +// once-only proof before any raw store, then execute the original loop. +const nonzeroMixed: any[] = [ + { x: 50, a: 0 }, + { x: 50, a: 0 }, + { x: 0, b: 0 }, + { x: 0, c: 0 }, +]; +for (let r = 0; r < 3; r++) { + for (let i = 2; i < 4; i++) { + const object: any = nonzeroMixed[i]; + object.x = r + i; + } +} +console.log("nonzero-mixed", fieldSum(nonzeroMixed, ["x"])); + // Duplicate target slots are valid, but source order remains observable. const duplicate: any[] = [{ x: 0, y: 0 }, { x: 0, y: 0 }]; for (let r = 0; r < 5; r++) {