diff --git a/benchmarks/object-write-6812/matrix.ts b/benchmarks/object-write-6812/matrix.ts index 3ca442dbf4..371599bc29 100644 --- a/benchmarks/object-write-6812/matrix.ts +++ b/benchmarks/object-write-6812/matrix.ts @@ -219,6 +219,30 @@ function shapeFour(): CellResult { return { elapsed, writes: 60000000, sink: checksum(objects, ["x"]) }; } +function shapeEight(): CellResult { + const objects: any[] = []; + for (let i = 0; i < 2400; i++) { + const kind = i & 7; + if (kind === 0) objects.push({ x: i, a: 0 }); + else if (kind === 1) objects.push({ x: i, b: 0 }); + else if (kind === 2) objects.push({ x: i, c: 0 }); + else if (kind === 3) objects.push({ x: i, d: 0 }); + else if (kind === 4) objects.push({ x: i, e: 0 }); + else if (kind === 5) objects.push({ x: i, f: 0 }); + else if (kind === 6) objects.push({ x: i, g: 0 }); + else objects.push({ x: i, h: 0 }); + } + const t0 = Date.now(); + for (let r = 0; r < 25000; r++) { + for (let i = 0; i < 2400; i++) { + const object: any = objects[i]; + object.x = r + i; + } + } + const elapsed = Date.now() - t0; + return { elapsed, writes: 60000000, sink: checksum(objects, ["x"]) }; +} + function shapeTransitionBeforeLoop(): CellResult { const objects: any[] = []; for (let i = 0; i < 2400; i++) { @@ -503,6 +527,7 @@ else if (name === "rhs_call") result = rhsCall(); else if (name === "shape_monomorphic") result = shapeMonomorphic(); else if (name === "shape_two") result = shapeTwo(); else if (name === "shape_four") result = shapeFour(); +else if (name === "shape_eight") result = shapeEight(); else if (name === "shape_transition_before_loop") { result = shapeTransitionBeforeLoop(); } else if (name === "fields_one") result = fieldsOne(); diff --git a/benchmarks/object-write-6812/results.md b/benchmarks/object-write-6812/results.md index 96de0b57c6..81fe4ab87c 100644 --- a/benchmarks/object-write-6812/results.md +++ b/benchmarks/object-write-6812/results.md @@ -143,3 +143,36 @@ Function-local inspection of the definitive uncached LLVM module found: - the new `test_gap_6812_object_write_loop_generalization` passed in that full release-driver run; its final expanded inherited-setter/Proxy corpus also passed a focused release-driver rerun + +## Eight-shape static-PIC follow-up + +The original four-way static write PIC sent every shape beyond its first three +stable entries through the fourth miss slot. At an eight-shape site that slot +was overwritten continuously, so half of all writes repeated full `[[Set]]` +semantics. The follow-up keeps the four generated ways unchanged and adds four +non-evicting ways in one outlined helper. + +The benchmark corpus now includes `shape_eight`, using the same 2,400-object, +60-million-write scale as `shape_four`. Both binaries were compiled from clean +release builds with the default auto-optimizing pipeline; the final object was +regenerated with both Perry caches disabled. Measurements were 15 alternating +Node/Perry pairs on macOS arm64, with identical `62876400` checksums throughout. + +| Implementation | Node median | Perry median | Perry/Node | +|---|---:|---:|---:| +| Four-way baseline | 234 ms | 2,359 ms | 10.08× | +| Four inline + four outlined | 226 ms | **768 ms** | 3.40× | + +The Perry median improves by **67.4% (3.07×)**. The established paths remain +stable: `shape_monomorphic` was 119 → 119 ms and `shape_four` was 400 → 408 ms. +Both linked matrix executables were 5,849,320 bytes. + +Raw alternating samples: + +| Implementation | Node ms | Perry ms | +|---|---|---| +| Four-way baseline | `[228, 250, 231, 234, 240, 242, 236, 238, 236, 233, 239, 230, 228, 230, 227]` | `[2355, 2422, 2310, 3778, 2396, 2376, 2359, 2361, 2371, 2395, 2354, 2294, 2291, 2292, 2331]` | +| Four inline + four outlined | `[226, 226, 226, 229, 227, 230, 226, 227, 227, 226, 226, 228, 225, 224, 226]` | `[768, 768, 764, 766, 763, 765, 766, 762, 767, 770, 774, 775, 774, 771, 771]` | + +The executable parity corpus adds an eight-shape settled-cache case, a frozen +receiver in an outlined way, and a ninth-shape bounded-fallback case. diff --git a/benchmarks/object-write-6812/run_matrix.py b/benchmarks/object-write-6812/run_matrix.py index 24237498fc..10e64b7d9e 100644 --- a/benchmarks/object-write-6812/run_matrix.py +++ b/benchmarks/object-write-6812/run_matrix.py @@ -24,6 +24,7 @@ ("Receiver shapes", "monomorphic", "shape_monomorphic"), ("Receiver shapes", "2-shape", "shape_two"), ("Receiver shapes", "4-shape", "shape_four"), + ("Receiver shapes", "8-shape", "shape_eight"), ("Receiver shapes", "transition before loop", "shape_transition_before_loop"), ("Fields/iteration", "1", "fields_one"), ("Fields/iteration", "2", "fields_two"), diff --git a/changelog.d/8026-static-write-poly-tail.md b/changelog.d/8026-static-write-poly-tail.md new file mode 100644 index 0000000000..ca2d870120 --- /dev/null +++ b/changelog.d/8026-static-write-poly-tail.md @@ -0,0 +1,12 @@ +### perf(objects): settle eight-shape static write caches (#6812) + +Static existing-field writes now retain four inline receiver-shape entries and +use a compact outlined helper for four more. Once all eight entries are full, +the cache stays settled instead of continuously evicting its fourth shape; +ninth and later shapes continue through full `[[Set]]` semantics without +unbounded code growth. + +On the new 60-million-write eight-shape matrix cell, 15 alternating runs reduce +Perry's median from 2,359 ms to 768 ms (67.4%, 3.07×) with identical Node/Perry +checksums. Monomorphic timing is unchanged, four-shape timing stays within 2%, +and both linked matrix executables have the same file size. diff --git a/crates/perry-codegen/src/expr/proxy_reflect.rs b/crates/perry-codegen/src/expr/proxy_reflect.rs index 14ffe76ce4..ed8371bd77 100644 --- a/crates/perry-codegen/src/expr/proxy_reflect.rs +++ b/crates/perry-codegen/src/expr/proxy_reflect.rs @@ -398,8 +398,8 @@ fn put_value_static_property_fast_path( } } -/// Monomorphic inline cache for a static-name `PutValue` whose target and -/// receiver are the same expression. +/// Bounded polymorphic inline cache for a static-name `PutValue` whose target +/// and receiver are the same expression. /// /// Sloppy script writes cannot reuse `PropertySet` because its fallback throws /// on rejected writes. This diamond keeps the strict-aware runtime on every @@ -455,6 +455,12 @@ fn lower_put_value_static_write_ic( .push((format!("__ic_decl_{}", site_id), DOUBLE, vec![])); ctx.ic_globals.push(cache_name.clone()); let cache_ref = format!("@{}", cache_name); + // Keep the first four ways inline. Shapes 5–8 use a separate cache in a + // compact outlined helper, avoiding four more copies of the generated + // receiver guards while preventing the fourth inline way from thrashing. + let tail_cache_name = format!("perry_ic_{}_poly_tail", site_id); + ctx.ic_globals.push(tail_cache_name.clone()); + let tail_cache_ref = format!("@{}", tail_cache_name); // Branch before the first header load so primitives, forged non-pointer // bit patterns, and native handle ids can never be dereferenced by the @@ -470,11 +476,13 @@ fn lower_put_value_static_write_ic( let fallback_idx = ctx.new_block("put.pic.fallback"); let dispatch3_idx = ctx.new_block("put.pic.dispatch3"); let dispatch4_idx = ctx.new_block("put.pic.dispatch4"); + let dispatch5_idx = ctx.new_block("put.pic.dispatch5"); let hit_idx = ctx.new_block("put.pic.hit"); let miss_idx = ctx.new_block("put.pic.miss"); let miss2_idx = ctx.new_block("put.pic.miss2"); let miss3_idx = ctx.new_block("put.pic.miss3"); let miss4_idx = ctx.new_block("put.pic.miss4"); + let tail_idx = ctx.new_block("put.pic.tail"); let merge_idx = ctx.new_block("put.pic.merge"); let guard_label = ctx.block_label(guard_idx); let guard2_label = ctx.block_label(guard2_idx); @@ -483,11 +491,13 @@ fn lower_put_value_static_write_ic( let fallback_label = ctx.block_label(fallback_idx); let dispatch3_label = ctx.block_label(dispatch3_idx); let dispatch4_label = ctx.block_label(dispatch4_idx); + let dispatch5_label = ctx.block_label(dispatch5_idx); let hit_label = ctx.block_label(hit_idx); let miss_label = ctx.block_label(miss_idx); let miss2_label = ctx.block_label(miss2_idx); let miss3_label = ctx.block_label(miss3_idx); let miss4_label = ctx.block_label(miss4_idx); + let tail_label = ctx.block_label(tail_idx); let merge_label = ctx.block_label(merge_idx); ctx.block() .cond_br(&heap_candidate, &guard_label, &miss_label); @@ -647,7 +657,12 @@ fn lower_put_value_static_write_ic( hit4 = ctx.block().and(I1, &hit4, &token4_match); hit4 = ctx.block().and(I1, &hit4, &token4_nonzero); hit4 = ctx.block().and(I1, &hit4, &slot4_in_bounds); - ctx.block().cond_br(&hit4, &hit_label, &miss4_label); + ctx.block().cond_br(&hit4, &hit_label, &dispatch5_label); + + ctx.current_block = dispatch5_idx; + let fourth_empty = ctx.block().icmp_eq(I64, &cached4_token, "0"); + ctx.block() + .cond_br(&fourth_empty, &miss4_label, &tail_label); ctx.current_block = hit_idx; let selected_slot = ctx.block().phi( @@ -756,6 +771,21 @@ fn lower_put_value_static_write_ic( let miss4_end_label = ctx.block().label.clone(); ctx.block().br(&merge_label); + ctx.current_block = tail_idx; + let tail_value = ctx.block().call( + DOUBLE, + "js_put_value_set_ic_poly_tail", + &[ + (PTR, &tail_cache_ref), + (DOUBLE, &target_value), + (I64, &key_handle), + (DOUBLE, &stored_value), + (I32, strict_i32), + ], + ); + let tail_end_label = ctx.block().label.clone(); + ctx.block().br(&merge_label); + ctx.current_block = merge_idx; let result = ctx.block().phi( DOUBLE, @@ -765,6 +795,7 @@ fn lower_put_value_static_write_ic( (&miss2_value, &miss2_end_label), (&miss3_value, &miss3_end_label), (&miss4_value, &miss4_end_label), + (&tail_value, &tail_end_label), ], ); Ok(Some(result)) diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 434d57b84e..82959122fe 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -481,6 +481,11 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { DOUBLE, &[DOUBLE, I64, DOUBLE, I32, PTR], ); + module.declare_function( + "js_put_value_set_ic_poly_tail", + DOUBLE, + &[PTR, DOUBLE, I64, DOUBLE, I32], + ); module.declare_function( "js_object_array_numeric_write_guard", I64, diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index f13b9a32e6..45d85c0242 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -13707,8 +13707,14 @@ fn static_put_value_uses_write_pic_for_call_free_rhs() { ir.contains("put.pic.guard2") && ir.contains("put.pic.guard3") && ir.contains("put.pic.guard4") - && ir.contains("put.pic.miss4"), - "the write PIC should retain four bounded shape entries" + && ir.contains("put.pic.miss4") + && ir.contains("put.pic.tail") + && ir.contains("call double @js_put_value_set_ic_poly_tail"), + "the write PIC should retain four inline entries plus a bounded outlined tail" + ); + assert!( + ir.contains("@perry_ic_0_poly_tail = private global"), + "the outlined ways must use a distinct zero-initialized cache:\n{ir}" ); } diff --git a/crates/perry-runtime/src/proxy/put_value.rs b/crates/perry-runtime/src/proxy/put_value.rs index a07e9cadeb..f6b126018a 100644 --- a/crates/perry-runtime/src/proxy/put_value.rs +++ b/crates/perry-runtime/src/proxy/put_value.rs @@ -264,7 +264,7 @@ pub extern "C" fn js_put_value_set( value_handle.get_nanbox_f64() } -/// Miss path for the codegen-emitted monomorphic PutValue store cache. +/// Miss path for one way of the codegen-emitted polymorphic PutValue cache. /// /// The full strict/sloppy `[[Set]]` semantics run first. Only a successful /// ordinary class-instance own-data overwrite may prime `[shape_token, slot]`; @@ -410,6 +410,46 @@ pub extern "C" fn js_put_value_set_ic_miss( result } +const STATIC_PIC_TAIL_WAYS: usize = 4; + +/// Outlined ways 5–8 for the static-key write PIC. +/// +/// The generated function keeps its first four shape guards inline. Once +/// those are full, this helper validates up to four additional cached +/// `(shape_token, slot)` pairs before falling back to full `[[Set]]` +/// semantics. Empty ways are filled in order and a full cache is never +/// overwritten, so a stable eight-shape site settles instead of continuously +/// replacing its fourth entry. +#[no_mangle] +pub extern "C" fn js_put_value_set_ic_poly_tail( + cache: *mut [i64; 8], + target: f64, + key: *const crate::StringHeader, + value: f64, + strict: i32, +) -> f64 { + if !cache.is_null() { + unsafe { + let c = &mut *cache; + for way in 0..STATIC_PIC_TAIL_WAYS { + let word = way * 2; + let token = c[word] as u64; + if token == 0 { + let entry = c.as_mut_ptr().add(word) as *mut [i64; 2]; + return js_put_value_set_ic_miss(target, key, value, strict, entry); + } + if let Some(result) = dyn_ic_try_store(target, token, c[word + 1] as u32, value) { + return result; + } + } + } + } + + // More than eight stable shapes remain bounded and semantically correct: + // execute the ordinary write without evicting a useful settled entry. + js_put_value_set_ic_miss(target, key, value, strict, std::ptr::null_mut()) +} + // --------------------------------------------------------------------------- // #6812 (w12): 3-way dynamic-key write IC. // diff --git a/docs/object-write-matrix.md b/docs/object-write-matrix.md index f6878803b8..a7e43410c6 100644 --- a/docs/object-write-matrix.md +++ b/docs/object-write-matrix.md @@ -18,7 +18,7 @@ lack of benefit. no calls/allocations/labels; preflight proves dense same-shape prefix, writable own slots, layout. Strongest path: call-free, barrier-free body. 2. **Static-key write PIC** (`expr/proxy_reflect.rs::lower_put_value_static_write_ic`, - 4-entry polymorphic since #6823; miss/priming + four inline entries plus four outlined entries; miss/priming `proxy/put_value.rs::js_put_value_set_ic_miss`): static (interned/const) key, target ≡ receiver expression, safepoint-free RHS, heap object, non-forwarded, blocking flags clear (frozen/sealed/no-extend/descriptors/ @@ -44,7 +44,7 @@ Ratio = perry/node median (fill from measurement; `<1` = beating node). | 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 | *(pre-multi-group baseline)* PIC → per-group whole-loop clone | 27 → 5-8 | 6 | 4.5 → **~1.0** | Ties/beats node — the inliner's two-array body matches as two monomorphic groups, one guard call each (idle 15-run pending for the exact ratio) | -| w10_poly8 | 8 shapes through one site | PIC exhausted → runtime miss | 27 | 19 | 1.4 | close; megamorphic path is decent | +| w10_poly8 | 8 shapes through one site | *(pre-tail baseline)* PIC exhausted → 4 inline + 4 outlined ways | 27 (pre-tail) | 19 | 1.4 (pre-tail) | bounded tail prevents fourth-way thrashing; the scaled 60M-write follow-up drops Perry 2,359 → 768 ms (67%) | | 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 | *(pre-IC baseline)* generic → dyn-key IC → key-table clone lane | 84 → 3 | 18 | 4.7 → **0.17** | BEATS node 6× — the 3-way dynamic-key IC covers scattered writes; a loop-invariant key array upgrades to the clone via the resolved slot table (integer-domain index, no fmod) | | w13_int_key | `o[7]` on plain object | *(pre-spill-lanes baseline)* generic → peel + whole-loop clone with spill lanes | 160 → 7 | 13 | 12.3 → **0.54** | BEATS node — integer static keys (#6841), first-iteration peel (#6841), object-owned spill (#6849), and guard/emitter spill lanes make the append-past-capacity array clone-eligible | @@ -68,8 +68,9 @@ Ratio = perry/node median (fill from measurement; `<1` = beating node). - **A coherent 5–9× family** (w3–w8): each is one narrow eligibility rule of the clone matcher; per-write PIC at ~10 ns/write is the shared floor. The fix is widening clone eligibility, not touching the PIC. -- **Megamorphic (w10) and allocating-RHS (w17) are near-node already** — - deprioritize. +- **Eight-shape polymorphism (w10) now settles without duplicating four more + generated guard sequences.** Shape 9+ remains on the bounded semantic + fallback. Allocating-RHS (w17) remains near-node and is deprioritized. Not modeled as micros (justified fallbacks unless measurement says otherwise): symbol keys (separate table semantics), frozen/sealed/descriptor receivers 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 9ec82eaa9d..108419673c 100644 --- a/test-files/test_gap_6812_object_write_loop_generalization.ts +++ b/test-files/test_gap_6812_object_write_loop_generalization.ts @@ -85,6 +85,58 @@ for (let r = 0; r < 5; r++) { } console.log("mixed", fieldSum(mixed, ["x"])); +// The static write PIC settles four inline and four outlined shape entries. +const mixedEight: any[] = [ + { x: 0, a: 1 }, + { x: 0, b: 2 }, + { x: 0, c: 3 }, + { x: 0, d: 4 }, + { x: 0, e: 5 }, + { x: 0, f: 6 }, + { x: 0, g: 7 }, + { x: 0, h: 8 }, +]; +for (let r = 0; r < 5; r++) { + for (let i = 0; i < mixedEight.length; i++) { + const object: any = mixedEight[i]; + object.x = r + i; + } +} +console.log("mixed-eight", fieldSum(mixedEight, ["x"])); + +let tailFrozenThrew = false; +Object.freeze(mixedEight[7]); +try { + for (let i = 0; i < mixedEight.length; i++) { + const object: any = mixedEight[i]; + object.x = 100 + i; + } +} catch (error) { + tailFrozenThrew = error instanceof TypeError; +} +console.log("tail-frozen", tailFrozenThrew, mixedEight[7].x); + +// A ninth shape takes the bounded semantic fallback without evicting the +// settled eight ways. +const mixedNine: any[] = [ + { x: 0, a: 1 }, + { x: 0, b: 2 }, + { x: 0, c: 3 }, + { x: 0, d: 4 }, + { x: 0, e: 5 }, + { x: 0, f: 6 }, + { x: 0, g: 7 }, + { x: 0, h: 8 }, + { x: 0, i: 9 }, +]; +for (let r = 0; r < 5; r++) { + for (let i = 0; i < mixedNine.length; i++) { + const object: any = mixedNine[i]; + object.x = r + i; + } +} +console.log("mixed-nine", fieldSum(mixedNine, ["x"])); + let holeThrew = false; const hole: any[] = [{ x: 0 }, , { x: 0 }]; try {