From 7d99d14c33a3b8a9069da3db5983728bb76d2178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 13 Aug 2026 06:25:43 +0200 Subject: [PATCH 1/3] fix(runtime): birth-stamp the class allocators #8009 left lazy, and gate the split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #8009 (C3 rung 2) stamps a class instance's ShapeId at birth on the COMPILED path — `js_object_alloc_class_inline_keys_stamped`, called from the inline `new C(…)` lowering. Three other class-instance allocators were left on rung 1's lazy self-heal, which its own doc comment states: js_object_alloc_class_with_keys js_object_alloc_class_dynamic_parent js_object_alloc_class_inline_keys (the compatibility entry point) For any class that lands on one of those, the shape's population is still SPLIT — and a split population is not a slow start, it is a permanent 0% PIC hit rate. The emitted read PIC derives its entire cache token from the header shape word: is_stamp = (parent_class_id - 0x8000_0000) u< 0x4000_0000 token = is_stamp ? (parent_class_id | 1<<62) : keys_array so instance #1 misses, is stamped, primes the id token; instance #2 is newborn, computes the keys pointer, misses; the handler re-primes the same id; instance #3 misses. Forever. That is exactly #7983's defect, which this bisected: on instructions retired, isolated against its own parent, `cycles` +54.3%, `deeplist` +45.2%, `interp` +28.3%, `pipeline` +23.9%, `iso_miss` +22.9%, while the object-literal benchmarks (`churn` +1.2%, `retain` +0.2%) and `fib40` (+0.04%) did not move — literals have been birth-stamped since #6804. All three now stamp at birth. The two shape-cached allocators read the id out of the `ShapeCacheEntry::runtime_shape_id` their existing probe already returns (`shape_cache_get` -> `shape_cache_get_with_id`): one extra u32 from a loaded cache line, a compare and a store. The compatibility entry point mints from its canonical keys array — one shape-table probe, and it is not the compiled hot path. THE GATE. `a_fresh_class_instance_computes_the_token_the_miss_handler_primed` asserts the token the miss handler PRIMES equals the token a freshly-allocated sibling COMPUTES, using `emitted_pic_token` — the emitted IR's formula transcribed into the test. It FAILS on `main` as of #8009 and passes here, which is the whole point: #8009's own test asserts that a newborn CARRIES a stamp, and that is a presence check. Both-stamped and both-unstamped each satisfy it; only the MIXTURE is the bug, and only a test that compares the two sides can see it. This one also passes in either uniform state, so it does not need rewriting if the policy ever flips back. Two premise assertions in `delete_rest::shape_transition_tests_6759` said a fresh instance is unstamped. They are updated, not deleted, and the parent-chain test is strictly stronger for it: the word is now clobbered from birth, so there is no window in which it was ever valid inheritance data. --- crates/perry-runtime/src/object/alloc.rs | 50 +++++++-- .../perry-runtime/src/object/delete_rest.rs | 41 ++++--- .../src/object/field_get_set/ic_miss.rs | 100 ++++++++++++++++++ crates/perry-runtime/src/object/shapes.rs | 45 ++++++++ 4 files changed, 208 insertions(+), 28 deletions(-) diff --git a/crates/perry-runtime/src/object/alloc.rs b/crates/perry-runtime/src/object/alloc.rs index b153d20cb5..519767a43e 100644 --- a/crates/perry-runtime/src/object/alloc.rs +++ b/crates/perry-runtime/src/object/alloc.rs @@ -296,8 +296,15 @@ fn object_alloc_class_inline_keys_impl( } /// Compatibility entry point for runtime callers that do not have a -/// module-init ShapeId. Their first by-name resolve retains rung 1's lazy -/// self-heal; compiled allocations use the stamped entry point below. +/// module-init ShapeId. +/// +/// It mints the id from the canonical keys array instead of receiving it, so +/// the instance is still stamped AT BIRTH. Leaving it to rung 1's lazy +/// self-heal would split this class's population between stamped and newborn +/// receivers, which the emitted PIC cannot tolerate — see +/// `shapes::birth_stamp_object_shape`. The mint is one shape-table probe and +/// this is not the compiled hot path (compiled `new C(…)` sites call +/// `js_object_alloc_class_inline_keys_stamped` with a module-init id). #[no_mangle] pub extern "C" fn js_object_alloc_class_inline_keys( class_id: u32, @@ -305,7 +312,18 @@ pub extern "C" fn js_object_alloc_class_inline_keys( field_count: u32, keys_array: *mut ArrayHeader, ) -> *mut ObjectHeader { - object_alloc_class_inline_keys_impl(class_id, parent_class_id, field_count, keys_array) + let ptr = + object_alloc_class_inline_keys_impl(class_id, parent_class_id, field_count, keys_array); + if !keys_array.is_null() { + unsafe { + let id = crate::object::shapes::shape_id_for_keys_ensure( + keys_array as *const ArrayHeader, + (*keys_array).length, + ); + crate::object::shapes::birth_stamp_object_shape(ptr, id); + } + } + ptr } /// The compiled-class allocation entry point after #6759 C3 rung 2. @@ -462,9 +480,9 @@ pub extern "C" fn js_object_alloc_class_with_keys( .wrapping_mul(10007) .wrapping_add(field_count.wrapping_mul(100003)) .wrapping_add(1000000); - let cached = shape_cache_get(shape_id); - let keys_arr = if !cached.is_null() { - cached + let (cached, cached_runtime_id) = shape_cache_get_with_id(shape_id); + let (keys_arr, runtime_shape_id) = if !cached.is_null() { + (cached, cached_runtime_id) } else { let keys_bytes = unsafe { std::slice::from_raw_parts(packed_keys, packed_keys_len as usize) }; @@ -492,11 +510,18 @@ pub extern "C" fn js_object_alloc_class_with_keys( } } shape_cache_insert(shape_id, arr); - arr + (arr, shape_cache_get_with_id(shape_id).1) }; unsafe { set_object_keys_array(ptr, keys_arr); + // #6759 C3 rung 2, completed: birth-stamp here too. #8009 stamped the + // COMPILED entry point (`js_object_alloc_class_inline_keys_stamped`) + // and left this one lazily self-healing, which is a SPLIT population + // for every class that lands here — and a split population is a + // permanent PIC miss, not a slow start. See + // `shapes::birth_stamp_object_shape`. + crate::object::shapes::birth_stamp_object_shape(ptr, runtime_shape_id); } remember_class_keys_array(class_id, field_count, keys_arr); ptr @@ -559,9 +584,9 @@ pub extern "C" fn js_object_alloc_class_dynamic_parent( // from the own-only shape (`+ 2_000_000`) so it can't collide with the // `js_build_class_keys_array` / `js_object_alloc_class_with_keys` shapes. let shape_id = class_id.wrapping_mul(10007).wrapping_add(2_000_000); - let cached = shape_cache_get(shape_id); - let (merged_arr, field_count) = if !cached.is_null() { - (cached, unsafe { (*cached).length }) + let (cached, cached_runtime_id) = shape_cache_get_with_id(shape_id); + let (merged_arr, field_count, runtime_shape_id) = if !cached.is_null() { + (cached, unsafe { (*cached).length }, cached_runtime_id) } else { let own_keys: Vec<&[u8]> = if own_packed_keys.is_null() || own_packed_keys_len == 0 { Vec::new() @@ -599,7 +624,7 @@ pub extern "C" fn js_object_alloc_class_dynamic_parent( } } shape_cache_insert(shape_id, arr); - (arr, merged_len as u32) + (arr, merged_len as u32, shape_cache_get_with_id(shape_id).1) }; let header_size = std::mem::size_of::(); @@ -621,6 +646,9 @@ pub extern "C" fn js_object_alloc_class_dynamic_parent( } set_object_keys_array(ptr, merged_arr); crate::gc::layout_init_pointer_free(ptr as *mut u8); + // The dynamically-parented subclass shape needs the same birth stamp + // as every other class instance, or its sites split the same way. + crate::object::shapes::birth_stamp_object_shape(ptr, runtime_shape_id); } remember_class_keys_array(class_id, field_count, merged_arr); ptr diff --git a/crates/perry-runtime/src/object/delete_rest.rs b/crates/perry-runtime/src/object/delete_rest.rs index b6669daef4..d0328606bc 100644 --- a/crates/perry-runtime/src/object/delete_rest.rs +++ b/crates/perry-runtime/src/object/delete_rest.rs @@ -724,20 +724,23 @@ mod shape_transition_tests_6759 { } let keys_before = (*obj).keys_array; assert_eq!((*obj).class_id, CID, "test premise: a class instance"); - assert!( - !is_shape_id((*obj).parent_class_id), - "test premise: a FRESH instance carries its allocation-time \ - parent_class_id — rung 1's stamp is lazy, not a birth stamp" - ); - - // Lazy stamp: the first by-name resolve installs a ShapeId over the - // (now readerless — rung 0/#7981) inheritance word. - let _ = crate::object::js_object_get_field_by_name(obj, key("del6759_y")); let before = (*obj).parent_class_id; assert!( is_shape_id(before), - "a class instance was NOT stamped by its first by-name resolve \ - (got {before:#x}) — rung 1's whole subject is inert" + "test premise: a class instance is stamped AT BIRTH (got \ + {before:#x}). Rung 2 (#8009 for the compiled path, and the \ + runtime allocators alongside it) exists because a LAZY stamp \ + splits the shape's population — see \ + `shapes::birth_stamp_object_shape`" + ); + // A by-name resolve must not change it: the birth stamp is already + // the id every later resolve would have minted. + let _ = crate::object::js_object_get_field_by_name(obj, key("del6759_y")); + assert_eq!( + (*obj).parent_class_id, + before, + "a resolve re-stamped an already-stamped instance with a \ + DIFFERENT id — every site holding the birth token would miss" ); assert_eq!(js_object_delete_field(obj, key("del6759_x")), 1); @@ -857,10 +860,14 @@ mod shape_transition_tests_6759 { // prelude instead); MID→BASE has no instance here, so register it // the way that prelude does. crate::object::register_class(MID, BASE); - assert_eq!( - (*leaf).parent_class_id, - MID, - "test premise: the header word starts as inheritance data" + // Rung 2: the word is a ShapeId from BIRTH, so the parent edge is + // already only in the registry before anything below runs. That + // makes this test stronger than when the word still started as + // inheritance data — there is no window in which it was correct. + assert!( + is_shape_id((*leaf).parent_class_id), + "test premise: the newborn's header word was not clobbered by a \ + birth stamp, so the chain is not actually being stressed" ); let boxed = crate::value::js_nanbox_pointer(leaf as i64); @@ -869,11 +876,11 @@ mod shape_transition_tests_6759 { assert!(truthy(crate::object::js_instanceof(boxed, MID))); assert!(truthy(crate::object::js_instanceof(boxed, BASE))); - // Clobber the word with a stamp. + // …and it stays clobbered across a resolve. let _ = crate::object::js_object_get_field_by_name(leaf, key("chain6759_p")); assert!( is_shape_id((*leaf).parent_class_id), - "test premise: the resolve did not stamp, so nothing is being tested" + "test premise: the word stopped being a stamp, so nothing is being tested" ); assert!( diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index aaae89bc83..23328e1fbc 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -1575,6 +1575,106 @@ mod c3c_pic_tests { assert_eq!(c_compacted[1], 1, "compacted `c` slot"); } } + + /// The PIC cache token the EMITTED code computes for `obj`, transcribed + /// from `perry-codegen/src/expr/property_get/generic_dispatch.rs`: + /// + /// ```text + /// is_stamp = (parent_class_id - 0x8000_0000) u< 0x4000_0000 + /// token = is_stamp ? (parent_class_id | 1<<62) : keys_array + /// ``` + /// + /// The runtime never calls this; it exists so a test can compare what the + /// miss handler PRIMES against what the hit path will COMPUTE, which is + /// the only pair whose agreement decides whether a site can ever hit. + unsafe fn emitted_pic_token(obj: *const super::ObjectHeader) -> u64 { + let word = (*obj).parent_class_id; + if crate::object::shapes::is_shape_id(word) { + word as u64 | crate::object::shapes::PIC_ID_TOKEN_BIT + } else { + (*obj).keys_array as u64 + } + } + + /// ★ The invariant #6759 C3 rung 1 broke, asserted where it broke. + /// + /// A shape's population must be UNIFORMLY stamped: the token the miss + /// handler primes from one instance is only useful if a DIFFERENT, + /// freshly-allocated instance of the same class computes the same token. + /// Rung 1 (#7983) stamped class instances lazily while their allocator + /// still wrote the real `parent_class_id`, so instance #1 primed an id + /// token and every newborn sibling computed its keys pointer instead — + /// `token_eq` failed at every site reading a field of a fresh instance, + /// forever. Measured cost before the birth stamp: `cycles` +54%, + /// `deeplist` +45%, `interp` +28% in instructions retired. + /// + /// This is deliberately NOT "the newborn carries a stamp" — that is a + /// presence check two different states satisfy (both-stamped and + /// both-unstamped are each fine; the mixture is the bug). Comparing the + /// primed token against a fresh sibling's COMPUTED token is what fails + /// under either half of the split. + #[test] + fn a_fresh_class_instance_computes_the_token_the_miss_handler_primed() { + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + let packed = b"picbirth_x\0picbirth_y"; + let mk = || { + crate::object::js_object_alloc_class_with_keys( + 0x6082, + 0, + 2, + packed.as_ptr(), + packed.len() as u32, + ) + }; + let key = crate::string::js_string_from_bytes(b"picbirth_x".as_ptr(), 10); + + let primed_from = mk(); + crate::object::js_object_set_field( + primed_from, + 0, + crate::JSValue::from_bits(5.0f64.to_bits()), + ); + assert_eq!( + (*primed_from).class_id, + 0x6082, + "test premise: the receiver is a class instance, not a literal" + ); + + let mut cache = [0i64; super::PIC_CACHE_WORDS]; + assert_eq!( + super::js_object_get_field_ic_miss(primed_from, key, &mut cache), + 5.0, + "test premise: the miss handler resolved the field" + ); + assert_ne!( + cache[0], 0, + "test premise: the miss handler primed SOMETHING — a zero token \ + never hits, so the comparison below would be vacuous" + ); + + // The next `new C(...)`. Nothing has resolved a field on it. + let fresh = mk(); + assert_eq!( + emitted_pic_token(fresh), + cache[0] as u64, + "a freshly allocated instance of the SAME class computes a \ + different PIC token than the one primed from its sibling, so \ + every read of a newborn instance's field misses the cache and \ + takes the full miss handler — #7983's split population" + ); + + // And the same must hold once the fresh one has itself resolved: + // priming from either instance is interchangeable. + let mut cache2 = [0i64; super::PIC_CACHE_WORDS]; + super::js_object_get_field_ic_miss(fresh, key, &mut cache2); + assert_eq!( + cache2[0], cache[0], + "two instances of one class primed two different tokens — the \ + site thrashes between them" + ); + } + } } #[cfg(test)] diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index ff20e1895e..6a7a368b2f 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -215,6 +215,51 @@ pub(crate) unsafe fn stamp_object_shape( id } +/// Birth-stamp a NEWBORN receiver with an already-minted ShapeId. A zero id — +/// no shape-cache record yet, or the id range exhausted — leaves the word +/// alone, which preserves the pre-stamp fallback rather than inventing one. +/// +/// ★ **A shape's population must be UNIFORMLY stamped or uniformly not.** The +/// emitted read PIC derives its ENTIRE cache token from this word +/// (`perry-codegen/src/expr/property_get/generic_dispatch.rs`): +/// +/// ```text +/// is_stamp = (parent_class_id - 0x8000_0000) u< 0x4000_0000 +/// token = is_stamp ? (parent_class_id | 1<<62) : keys_array +/// ``` +/// +/// so a stamped receiver and an unstamped one OF THE SAME SHAPE compute two +/// DIFFERENT tokens, and a site that sees both can never hold a hit. It is not +/// a slow start — it is a permanent 0% hit rate: instance #1 misses, is +/// stamped, primes the id token; instance #2 is newborn, computes the +/// keys-pointer token, misses; the handler re-primes the same id; instance #3 +/// misses. Forever. +/// +/// #6759 C3 rung 1 (#7983) stamped class instances only LAZILY, at the first +/// by-name resolve, and that is exactly what it cost — measured in +/// instructions retired, isolated against its own parent: `cycles` +54.3%, +/// `deeplist` +45.2%, `interp` +28.3%, `pipeline` +23.9%, `iso_miss` +22.9%, +/// while the object-literal benchmarks (`churn` +1.2%, `retain` +0.2%) and +/// `fib40` (+0.04%) did not move — literals have been birth-stamped since +/// #6804, so their population was always uniform. +/// +/// Rung 2 (#8009) closed the compiled path. **Every OTHER allocator that +/// installs a shape-cached keys array on a fresh `ObjectHeader` must call this +/// too**, or its classes keep the split. +/// +/// No `shape_word_is_writable` check: the callers have just written +/// `object_type`/`class_id` into a header they allocated, so the receiver is a +/// genuine `ObjectHeader` and never the `RegExpHeader` alias. +#[inline] +pub(crate) unsafe fn birth_stamp_object_shape( + obj: *mut crate::object::ObjectHeader, + runtime_shape_id: u32, +) { + if is_shape_id(runtime_shape_id) { + (*obj).parent_class_id = runtime_shape_id; + } +} + /// Drop the stamp iff the word currently holds one, leaving a real /// `parent_class_id` untouched. Returns true when a stamp was cleared. /// From d61661399dd1bb32953b7c364de4d553e830a941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 13 Aug 2026 06:37:24 +0200 Subject: [PATCH 2/3] docs(changelog): fragment for #8010 (class-shape uniformity gate) --- .../8010-class-shape-uniformity-gate.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 changelog.d/8010-class-shape-uniformity-gate.md diff --git a/changelog.d/8010-class-shape-uniformity-gate.md b/changelog.d/8010-class-shape-uniformity-gate.md new file mode 100644 index 0000000000..67a03d6bbd --- /dev/null +++ b/changelog.d/8010-class-shape-uniformity-gate.md @@ -0,0 +1,38 @@ +### Birth-stamp the class allocators #8009 left lazy, and gate the split population (#7983) + +#8009 (C3 rung 2) stamps a class instance's ShapeId at birth on the **compiled** +path. Three other class-instance allocators were left on rung 1's lazy +self-heal — `js_object_alloc_class_with_keys`, +`js_object_alloc_class_dynamic_parent`, and the `js_object_alloc_class_inline_keys` +compatibility entry point — so for any class reaching one of them the shape's +population is still split between stamped and newborn receivers. + +That is not a slow start. The emitted read PIC derives its entire cache token +from the header shape word, so a stamped receiver and a newborn one of the same +shape compute two different tokens and the site's hit rate is **0% forever**: +instance #1 misses, is stamped, primes the id token; instance #2 is newborn, +computes the keys pointer, misses; the handler re-primes the same id; instance +#3 misses. + +This is the defect bisected to `4784d5da7` (#7983). On instructions retired, +isolated against its own parent: `cycles` +54.3%, `deeplist` +45.2%, `interp` ++28.3%, `pipeline` +23.9%, `iso_miss` +22.9% — while the object-literal +benchmarks (`churn` +1.2%, `retain` +0.2%) and `fib40` (+0.04%) did not move, +literals having been birth-stamped since #6804. Isolated further on one program +and one build: a read pass over 3,000,000 newborn class instances costs 43.6 +instructions per read, a second pass over the same now-stamped instances 15.5. + +All three allocators now stamp at birth. The two shape-cached ones read the id +out of the `ShapeCacheEntry::runtime_shape_id` their existing probe already +returns; the compatibility entry point mints from its canonical keys array, off +the compiled hot path. + +The gate, `a_fresh_class_instance_computes_the_token_the_miss_handler_primed`, +asserts the token the miss handler PRIMES equals the token a freshly-allocated +sibling COMPUTES, comparing against the emitted IR's formula transcribed into +the test. It fails on `main` as of #8009 and passes here. "A newborn carries a +stamp" is a presence check that both-stamped and both-unstamped each satisfy — +only the mixture is the bug, and only a test comparing the two sides can see it. +This one passes in either uniform state, so it survives a future policy flip. + +Working notes, including the full bisect and the two dead ends: `gc-handoff/BISECT-NOTES.md`. From a36cddd78330d09c72ff917033ffbd25f4b0b608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 13 Aug 2026 06:37:46 +0200 Subject: [PATCH 3/3] docs(gc-handoff): bisect notes for the #7983 shape-population regression --- gc-handoff/BISECT-NOTES.md | 200 +++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 gc-handoff/BISECT-NOTES.md diff --git a/gc-handoff/BISECT-NOTES.md b/gc-handoff/BISECT-NOTES.md new file mode 100644 index 0000000000..f2190b6c2f --- /dev/null +++ b/gc-handoff/BISECT-NOTES.md @@ -0,0 +1,200 @@ +# Bisect: the 2026-08-13 broad regression `0a21611fe` → `843ef621f` + +**Verdict: `4784d5da7` — #7983, "#6759 C3 rung 1 — make the shape word uniform".** +Single commit, isolated against its own parent. Fixed in PR #8010. + +Not #7997 (the aarch64 SVE prologue decoder) and not #7994 (per-thread +prototype addresses) — both landed *after* the regression was already fully +present and cost nothing measurable on this corpus. + +## Instrument + +Wall clock on the dev box cannot resolve this (load 30–200 all session), so +everything here is **instructions retired** (`/usr/bin/time -l`, best-of-3, +exit-checked, output `cmp`-ed against `m0810/expected/`). Every hop rebuilt +`-p perry -p perry-runtime-static -p perry-stdlib-static` into ONE target dir +with the `.a` mtimes checked. Corpus binaries went to `$HOME/bisect-bins//` +with the SAME basenames in DIFFERENT directories, per the protocol's `cmp` trap. + +The instruction delta tracks the reported wall-clock delta row for row, which is +what says the regression is **work-bound**. + +## The five measured arms (instructions retired, best-of-3) + +| bench | `0a21611fe` good | `23a8aad31` parent | `4784d5da7` #7983 | `843ef621f` tip | `1b53332f8` main | **#8010 fixed** | +|---|--:|--:|--:|--:|--:|--:| +| cycles | 1,788,433,524 | 1,788,778,236 | **2,759,621,970** | 2,758,613,331 | 2,758,649,326 | **1,788,432,369** | +| deeplist | 891,705,830 | 898,511,111 | **1,304,689,118** | 1,297,094,745 | 1,303,183,101 | **888,880,890** | +| interp | 11,619,044,867 | 11,616,644,282 | **14,908,419,553** | 14,890,479,866 | 14,893,088,080 | **11,615,285,338** | +| pipeline | 2,584,827,137 | 2,582,965,931 | **3,199,913,725** | 3,192,369,325 | 3,193,275,738 | **2,582,793,215** | +| iso_miss | 14,201,035,818 | 14,205,407,941 | **17,464,543,037** | 17,444,768,294 | 17,459,670,264 | **14,205,774,080** | +| churn | 3,092,382,790 | 3,091,040,177 | 3,127,189,885 | 3,122,830,475 | 3,123,870,835 | 3,088,954,190 | +| retain | 1,903,836,211 | 1,929,069,359 | 1,933,154,142 | 1,901,604,111 | 1,913,067,161 | 1,901,657,815 | +| asyncpipe | 1,205,121,699 | 1,206,723,078 | 1,233,483,901 | 1,231,925,146 | 1,234,911,466 | 1,206,410,762 | +| fib40 | 3,837,818,438 | 3,838,189,564 | 3,839,712,991 | 3,835,231,921 | 3,836,450,763 | 3,838,170,517 | + +The whole regression appears **at #7983 and nothing after it adds any** — the +`4784d5da7` column already equals the tip. `cycles` fixed matches the good +endpoint to **1,155 instructions out of 1.79e9 (0.00006%)**. + +## Mechanism + +The split is **by receiver kind, not program size**: `cycles` (`class Cell`), +`deeplist` (`class LNode`), `interp`, `pipeline`, `iso_miss` regressed; +`churn` and `retain` (`type … = { … }` object literals) did not. + +The emitted read PIC (`expr/property_get/generic_dispatch.rs`) derives its whole +cache token from the header shape word: + +``` +is_stamp = (parent_class_id - 0x8000_0000) u< 0x4000_0000 +token = is_stamp ? (parent_class_id | 1<<62) : keys_array +``` + +and its own comment states the premise: *"Everything else (class instances, +unstamped receivers) keeps the keys-pointer compare."* + +Rung 1 broke that premise **halfway**. It stamps a class instance — but LAZILY, +at the first by-name resolve — while **codegen INLINE-allocates `new C(…)` and +stores a literal `0` into that word**, never calling +`js_object_alloc_class_with_keys` at all. So one shape's population splits, and +at any site reading a field of a freshly allocated instance: + +1. instance #1 misses, is stamped, primes the **id** token; +2. instance #2 is newborn, computes the **keys-pointer** token → miss; +3. the handler stamps #2 and re-primes the same id (ids are per keys-array); +4. instance #3 is newborn → miss. Forever. Hit rate **0%**. + +### The single-build proof + +Three programs, one compiler, differing only in how many read passes they make +over the same 3,000,000-instance array: + +| program | instructions | delta | per read | +|---|--:|--:|--:| +| build array only | 2,564,930,818 | — | — | +| build + 1 read pass | 2,695,683,246 | **130,752,428** | **43.6** | +| build + 2 read passes | 2,742,277,736 | **46,594,490** | **15.5** | + +Pass 1 sees each instance NEWBORN; pass 2 sees the SAME instances already +stamped. **2.8×, and the only difference is the stamp.** + +## The fix that did NOT work (kept because measuring it is what found the truth) + +The first attempt birth-stamped in `js_object_alloc_class_with_keys` / +`js_object_alloc_class_dynamic_parent`, reading the memoized +`ShapeCacheEntry::runtime_shape_id`. It measured **zero recovery** — `cycles` +2,759,257,399, unchanged. `--trace llvm` on `cycles.ts` then showed why: the +`new Cell(…)` site is a bump-pointer allocation emitting +`store i64 8589934592` (parent_class_id 0 ‖ field_count 2) directly; the runtime +allocator is declared but never called. Worse, birth-stamping only the runtime +path would have created a NEW split for any class allocated both ways. + +★ **A fix whose subject never runs looks exactly like a fix that didn't help.** +The `.a` mtimes moved, the binaries differed, the unit test passed, and the +change was still inert on the hot path. + +## ★ #8009 LANDED MID-VALIDATION AND IS THE FIX + +`144867bfc` (#8009, C3 rung 2) reached `main` while this was being validated: it +mints a ShapeId per class at module init and stores it in the inline `new C(…)` +allocation, making the population uniformly STAMPED. Measured on current main +(`f58b73f4f`), the regression is **gone** — `cycles` 1,790,326,029 against the +good endpoint's 1,788,433,524 (+0.11%), `interp` +0.51%, `iso_miss` +0.61%. + +That obsoleted the second dead end below, and it was caught only because +`git diff origin/main..HEAD` showed a changelog fragment being DELETED — i.e. +main had gained one. **Re-fetch `main` before finishing; a branch cut 90 minutes +ago is not current in this repo.** + +### Dead end #2: holding the stamp at plain objects + +A `shape_word_is_stampable` predicate restoring the `class_id == 0` gate. It +recovered the corpus completely (`cycles` 1,788,432,369 — the good endpoint to +0.00006%), and it is the WRONG fix on top of #8009: the runtime would refuse to +READ a stamp codegen now writes at birth, priming the keys-pointer token while +the emitted PIC computes the id token — the same 0% hit rate from the opposite +side. + +## What #8009 left behind, and what PR #8010 now is + +#8009 stamps only the COMPILED entry point, +`js_object_alloc_class_inline_keys_stamped`. Three class-instance allocators are +still on rung 1's lazy self-heal, which its own doc states: + +* `js_object_alloc_class_with_keys` +* `js_object_alloc_class_dynamic_parent` +* `js_object_alloc_class_inline_keys` (the compatibility entry point) + +For any class reaching one of those the population is **still split**. The gate +test below FAILS on `main` as of #8009 (`left: 2199047503880` — a keys pointer; +`right: 4611686020574871741` — bit 62 | id) and passes once all three +birth-stamp. #8009's own test cannot see it: it asserts a newborn CARRIES a +stamp, which is a presence check that both-stamped and both-unstamped each +satisfy. Only the MIXTURE is the bug. + +PR #8010 is therefore: birth-stamp those three, plus the gate, plus these notes. +It is **neutral on this corpus** (all nine programs' classes take the compiled +path #8009 already fixed) — the value is the classes that do not, and the gate. + +## Question 2 — churn / retain / asyncpipe were flat; the mini's +13/+13/+27% is not this commit + +Peak RSS and GC collection counts are BOTH load-independent, so this is valid on +a busy box. `PERRY_GC_DIAG=1` **and** `PERRY_GC_TRACE=1` (DIAG alone prints +nothing); positive control — the printer emits 445 lines and 22 +`collection_kind":"minor"` for `cycles`. + +| bench | quantity | `0a21611fe` | `1b53332f8` main | Δ | +|---|---|--:|--:|--:| +| churn | instructions | 3,092,462,749 | 3,124,640,479 | +1.0% | +| | peak RSS (KB) | 24,976 | 24,992 | +0.06% | +| | minors / fulls | 88 / 0 | 88 / 0 | identical | +| retain | instructions | 1,936,036,952 | 1,931,641,207 | −0.2% | +| | peak RSS (KB) | 254,800 | 254,816 | +0.006% | +| | minors / fulls | 4 / 0 | 4 / 0 | identical | +| asyncpipe | instructions | 1,206,387,962 | 1,231,635,504 | +2.1% | +| | peak RSS (KB) | 38,832 | 39,168 | +0.9% | +| | minors / fulls | 1 / 0 | 1 / 0 | identical | +| **cycles** (control) | instructions | 1,790,911,190 | 2,759,606,611 | **+54%** | +| | peak RSS (KB) | 24,816 | 24,800 | −0.06% | +| | minors / fulls | 22 / 0 | 22 / 0 | identical | + +* **GC-scheduling explanation: refuted.** Collection counts are identical on all + four programs, zero full collections anywhere. Nothing was rescheduled. +* **Locality/footprint explanation: refuted.** Peak RSS is flat to ≤0.9%. +* **Work: flat** for churn (+1.0%) and retain (−0.2%). + +★ Note the control row: `cycles` regressed **54%** with RSS and collection counts +**also flat**. So flat RSS/counts can only REFUTE the scheduling and footprint +explanations — they can never confirm "nothing changed". The positive statement +for churn/retain is the instruction count, and it is flat. + +With no work added, no collection rescheduled and no footprint change, there is +no mechanism left for a 13% wall-clock move on churn or retain: **those two rows +are mini-side variance.** They should regain their node wins on the next sweep — +the fix leaves them 1.1% and 0.6% BELOW main. + +`asyncpipe` is the one row with a real attributable cost: **+2.1%**, not +27%, +and the fix returns it exactly to the good endpoint (1,206,410,762 vs +1,205,121,699). The remaining ~25 points are either mini variance or **idle/ +parked time**, which neither instructions retired nor cycles elapsed can observe +for an async program — that can only be settled on the quiet mini, and it is now +moot. + +## Validation + +* `cargo test -p perry-runtime --lib` (`RUST_TEST_THREADS=1`): **2278 pass, 0 fail**. +* Sabotage-verified with the fix **committed first**; restored and **REBUILT** + (`Compiling perry-runtime` = 1) before re-confirming green. +* `iso_miss` canary prints `checksum 437840 misses 0`, including under + `PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800 + PERRY_GC_VERIFY_EVACUATION=1`; `cycles`/`deeplist`/`interp` byte-identical + under the same knobs. +* Whole probe corpus output-verified against `m0810/expected/` at every arm. + +## Bisect hygiene notes + +* `git status` cannot see a stale `.a`. Every hop verified `libperry_runtime.a` + and `perry` mtimes moved after the checkout. +* The `d456b411e` dirty-but-corroborating sweep recorded in + MEASUREMENT-PROTOCOL.md held up: everything up to and including #7981 is flat.