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`. 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. /// 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.