diff --git a/CLAUDE.md b/CLAUDE.md index 563a28de26..d85623ce15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1627 +**Current Version:** 0.5.1628 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 592307c153..a4cf15a4dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5532,7 +5532,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "anyhow", "base64 0.22.1", @@ -5596,7 +5596,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "perry-dispatch", "serde", @@ -5604,7 +5604,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "cc", "libc", @@ -5613,7 +5613,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "aho-corasick", "anyhow", @@ -5630,7 +5630,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "anyhow", "perry-hir", @@ -5638,7 +5638,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "anyhow", "perry-hir", @@ -5646,7 +5646,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "anyhow", "perry-dispatch", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "anyhow", "perry-hir", @@ -5663,7 +5663,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "anyhow", "base64 0.22.1", @@ -5675,7 +5675,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "anyhow", "perry-hir", @@ -5683,7 +5683,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "async-trait", "clap", @@ -5707,14 +5707,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "serde", "serde_json", @@ -5722,7 +5722,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1627" +version = "0.5.1628" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5733,7 +5733,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "anyhow", "clap", @@ -5748,7 +5748,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "block2", "objc2", @@ -5758,7 +5758,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "argon2", "perry-ffi", @@ -5767,7 +5767,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "bcrypt", "perry-ffi", @@ -5775,7 +5775,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "perry-ffi", "rusqlite", @@ -5783,7 +5783,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "perry-ffi", "scraper", @@ -5791,7 +5791,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "perry-ffi", "rust_decimal", @@ -5799,7 +5799,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5807,7 +5807,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "perry-ffi", "perry-runtime", @@ -5815,7 +5815,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "bytes", "lazy_static", @@ -5828,7 +5828,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "base64 0.22.1", "bytes", @@ -5860,7 +5860,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "lazy_static", "perry-ffi", @@ -5870,7 +5870,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "bson", "futures-util", @@ -5882,7 +5882,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "bytes", "perry-ffi", @@ -5897,7 +5897,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "lettre", "perry-ffi", @@ -5907,7 +5907,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "notify", "perry-ffi", @@ -5919,7 +5919,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "perry-ffi", "printpdf", @@ -5927,7 +5927,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "fast_image_resize", "image", @@ -5938,7 +5938,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "lazy_static", "perry-ffi", @@ -5947,7 +5947,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "anyhow", "perry-ffi", @@ -5967,7 +5967,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "perry-ffi", "perry-runtime", @@ -5976,7 +5976,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "futures-util", "lazy_static", @@ -5989,7 +5989,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "brotli", "flate2", @@ -5999,7 +5999,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6009,7 +6009,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "anyhow", "perry-api-manifest", @@ -6029,11 +6029,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1627" +version = "0.5.1628" [[package]] name = "perry-parser" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "anyhow", "perry-diagnostics", @@ -6046,7 +6046,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "perex", "regex", @@ -6054,7 +6054,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "ahash", "base64 0.22.1", @@ -6112,14 +6112,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6202,21 +6202,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "dirs", "perry-ffi", @@ -6226,7 +6226,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "base64 0.22.1", "jni", @@ -6241,7 +6241,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "rand 0.10.2", "serde", @@ -6251,7 +6251,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6274,7 +6274,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "base64 0.22.1", "block2", @@ -6291,7 +6291,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "base64 0.22.1", "block2", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1627" +version = "0.5.1628" [[package]] name = "perry-ui-test" @@ -6319,11 +6319,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1627" +version = "0.5.1628" [[package]] name = "perry-ui-tvos" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "base64 0.22.1", "block2", @@ -6340,7 +6340,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "base64 0.22.1", "block2", @@ -6357,7 +6357,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "block2", "libc", @@ -6371,7 +6371,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "base64 0.22.1", "libc", @@ -6390,7 +6390,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "base64 0.22.1", "libc", @@ -6403,7 +6403,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "anyhow", "base64 0.22.1", @@ -6418,7 +6418,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1627" +version = "0.5.1628" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index d6babce049..10833aa805 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1627" +version = "0.5.1628" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/10860-inherited-cache-never-primed.md b/changelog.d/10860-inherited-cache-never-primed.md new file mode 100644 index 0000000000..def727bcde --- /dev/null +++ b/changelog.d/10860-inherited-cache-never-primed.md @@ -0,0 +1,39 @@ +Fixed two defects that made #10834's inherited-read cache **pure overhead** — +the probe ran on every read, never served, and the chain walk proceeded +unchanged. Shipped in v0.5.1626 and measurably slower than before it. + +**A. The prime site was gated on the wrong miss reason.** `get_field_ic_miss_impl` +primed only under `R::NotOwn`. A receiver with no keys array reports +`ObjectNoKeys` and returns from an earlier arm, several hundred lines before the +prime — and `Object.create(p)` with nothing of its own is exactly that shape, +the most common inherited-read receiver there is. `ObjectNoKeys` means the +object has no own properties at all, so the prime's precondition holds there +*more* strongly than under `NotOwn`. + +**B. The slot index ignored the class id.** `js_object_create` mints a fresh +synthetic class id per call, so N receivers from `Object.create(p)` have N class +ids and one shape. `entry_index` hashed only (shape, key), so all N shared a +slot and evicted each other, while the entry compare on `recv_class_id` made +every read miss, re-walk and re-prime: `hits=0 primes=6295655` over ten million +reads. + +Measured, `perf stat -e instructions:u`, min of 3, fitted 500 k → 5 M: + +| fixture | before | after | node | +|---|---|---|---| +| 8 `Object.create` receivers via an array | 1600 | **494** | 19.1 | +| single keyless receiver, 1-level chain | 1481 | **427** | 9.0 | +| single keyless receiver, 3-level chain | **2442** | **446** | 8.7 | + +The depth row is the one that matters: a keyless three-level chain paid 2442 +instructions per read, because every read re-walked all three hops. + +Counters after are `primes=1` / `primes=8` then hits — exactly one prime per +receiver. + +**Why #10834's own measurement missed both.** Its fixtures gave the receiver an +own property and mutated it in the loop (`O.x = k`, added to keep node's +optimiser honest). That incidental detail put the read on the `NotOwn` path, so +defect A never fired, and used a single receiver, so defect B never fired. On +that one shape the cache genuinely was a 43% win — which is why the reported +numbers were real and generalised badly. 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 f635405e45..9b290f6737 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 @@ -935,7 +935,32 @@ pub(super) fn get_field_ic_miss_impl( if diag { ic_diag_note(cache_slot, key, R::ObjectNoKeys); } - let value = js_object_get_field_by_name(obj, key); + // #10834 gated its only prime site on `miss_reason == NotOwn`, + // and this arm returns before reaching it. A receiver with no + // keys array has NO own properties at all, so "the key is not + // an own property" holds here MORE strongly than it does for + // `NotOwn` — and this is the single most common inherited-read + // shape there is: `Object.create(p)` with nothing of its own. + // + // Without this the lookup at the top of this function runs on + // every such read, always misses because nothing can ever be + // recorded, and the chain walk proceeds unchanged: measured at + // +106 instructions per read against the same binary with + // `PERRY_INHERITED_IC=0`, i.e. the cache was pure overhead for + // this shape. + if !inherited_declined { + // Already inside this function's `unsafe` block (line 874), + // so a nested one is `unused_unsafe` under -D warnings. + if let Some(value) = + crate::object::inherited_read_cache::inherited_read_cache_prime(obj, key) + { + return f64::from_bits(value.bits()); + } + } + // Past the cache, not through it: the lookup at the top of + // this function has already asked. + let value = + super::get_field_by_name::get_field_by_name_past_inherited_cache(obj, key); return f64::from_bits(value.bits()); } let key_count = shape.logical_key_count as usize; diff --git a/crates/perry-runtime/src/object/inherited_read_cache.rs b/crates/perry-runtime/src/object/inherited_read_cache.rs index 2042092f38..88bc6f1bf3 100644 --- a/crates/perry-runtime/src/object/inherited_read_cache.rs +++ b/crates/perry-runtime/src/object/inherited_read_cache.rs @@ -218,11 +218,24 @@ crate::perry_thread_local! { std::cell::UnsafeCell::new(vec![EMPTY_ENTRY; CACHE_SIZE].into_boxed_slice()); } +/// An entry is identified by (class id, ShapeId, key), so all three have to +/// reach the slot index. +/// +/// #10834 hashed only (shape, key). That is exactly wrong for the receivers +/// this cache exists to serve: `js_object_create` mints a FRESH synthetic +/// class id on every call, so N objects built by `Object.create(p)` have N +/// different class ids and ONE identical shape. Under a (shape, key) index +/// they all landed in the same direct-mapped slot and evicted one another, so +/// a site reading through eight of them primed on EVERY read and hit never: +/// measured `primes=6295655 hits=0` over ten million reads, a full chain walk +/// plus an entry write per read, +75 instructions against the same binary with +/// the cache off. #[inline(always)] -fn entry_index(shape: u32, key_ptr: usize) -> usize { +fn entry_index(class_id: u32, shape: u32, key_ptr: usize) -> usize { // Interned key pointers are 8- or 16-byte aligned, so their low bits are // zeros; fold the middle bits down before masking. - let h = ((key_ptr >> 4) as u64 ^ ((shape as u64) << 21)).wrapping_mul(0x9E37_79B9_7F4A_7C15); + let h = ((key_ptr >> 4) as u64 ^ ((shape as u64) << 21) ^ ((class_id as u64) << 43)) + .wrapping_mul(0x9E37_79B9_7F4A_7C15); (h >> 40) as usize & CACHE_MASK } @@ -395,7 +408,7 @@ pub(crate) unsafe fn inherited_read_cache_lookup( if recv_shape == 0 { return Lookup::Unknown; } - let index = entry_index(recv_shape, key as usize); + let index = entry_index(recv_class_id, recv_shape, key as usize); let entry = INHERITED_READ_CACHE.with(|cell| (*cell.get())[index]); if entry.key_ptr != key as usize || entry.recv_shape != recv_shape @@ -528,7 +541,7 @@ pub(crate) unsafe fn inherited_read_cache_prime( hop_count: note.hop_count, slot: NEGATIVE_SLOT, }; - let index = entry_index(note.recv_shape, note.key_ptr); + let index = entry_index(note.recv_class_id, note.recv_shape, note.key_ptr); INHERITED_READ_CACHE.with(|cell| { (*cell.get())[index] = entry; }); @@ -756,7 +769,7 @@ unsafe fn inherited_read_cache_walk( hop_count: hop_count as u8, slot, }; - let index = entry_index(recv_shape, key_addr); + let index = entry_index(recv_class_id, recv_shape, key_addr); INHERITED_READ_CACHE.with(|cell| { (*cell.get())[index] = entry; }); diff --git a/crates/perry-runtime/src/object/inherited_read_cache_tests.rs b/crates/perry-runtime/src/object/inherited_read_cache_tests.rs index 8e1fe8e6de..f8b7264883 100644 --- a/crates/perry-runtime/src/object/inherited_read_cache_tests.rs +++ b/crates/perry-runtime/src/object/inherited_read_cache_tests.rs @@ -264,6 +264,132 @@ fn set_prototype_of_on_an_interior_prototype_invalidates() { } } +/// Drive the read through the REAL inline-cache entry the compiled code calls, +/// not through `inherited_read_cache_prime` directly. +/// +/// That distinction is the whole point of the two tests below: both defects +/// they pin live in `get_field_ic_miss_impl`'s routing, so a test that calls +/// the cache's own functions cannot see either one. Measured against a build +/// without the fixes, these reads prime zero times (first test) or once per +/// read forever (second), and in both cases the cache is pure overhead — the +/// probe runs on every read, never serves, and the chain walk proceeds +/// unchanged. +unsafe fn read_through_the_inline_cache( + obj: *mut ObjectHeader, + k: *const crate::StringHeader, + slot: &mut crate::object::field_get_set::PicCacheSlot, + site: u64, +) -> f64 { + let bits = crate::value::js_nanbox_pointer(obj as i64).to_bits() as i64; + crate::object::field_get_set::js_object_get_field_ic(bits, k, site, slot) +} + +#[test] +fn a_receiver_with_no_own_keys_is_cached() { + let _scope = PrimeScope::new(); + unsafe { + let proto = crate::object::js_object_alloc(0, 4); + set(proto, "irc_nokeys", 11.0); + // `Object.create(p)` with nothing of its own: the receiver has no keys + // array at all, so the miss handler reports `ObjectNoKeys` rather than + // `NotOwn`. This is the single most common inherited-read shape there + // is, and the prime site was gated on `NotOwn` alone. + let created = crate::object::js_object_create(boxed(proto)); + let obj = crate::value::js_nanbox_get_pointer(created) as *mut ObjectHeader; + let k = key("irc_nokeys"); + let mut slot: crate::object::field_get_set::PicCacheSlot = std::ptr::null_mut(); + for _ in 0..4 { + let v = read_through_the_inline_cache(obj, k, &mut slot, 9001); + assert_eq!(v, 11.0, "the read must still answer correctly"); + } + assert!( + inherited_read_cache_primes() >= 1, + "a keyless receiver never reached the prime, so the cache can never \ + serve this shape and its probe is pure overhead on every read" + ); + assert!(inherited_read_cache_hits() >= 1, "primed but never served"); + } +} + +#[test] +fn several_object_create_receivers_do_not_evict_each_other() { + let _scope = PrimeScope::new(); + unsafe { + let proto = crate::object::js_object_alloc(0, 4); + set(proto, "irc_shared", 13.0); + // Eight receivers built the same way. `js_object_create` mints a FRESH + // synthetic class id per call, so these have eight DIFFERENT class ids + // and one identical shape — and the slot index hashed only + // (shape, key), so all eight landed in one direct-mapped slot. + let mut objs = Vec::new(); + for i in 0..8 { + let created = crate::object::js_object_create(boxed(proto)); + let o = crate::value::js_nanbox_get_pointer(created) as *mut ObjectHeader; + set(o, "irc_own", i as f64); + objs.push(o); + } + let k = key("irc_shared"); + let mut slot: crate::object::field_get_set::PicCacheSlot = std::ptr::null_mut(); + let rounds = 8; + for _ in 0..rounds { + for o in &objs { + let v = read_through_the_inline_cache(*o, k, &mut slot, 9002); + assert_eq!(v, 13.0, "the read must still answer correctly"); + } + } + // Account for EVERY read rather than bounding the hits, because a + // loose lower bound on hits is what an off-by-one hides in. + let primes = inherited_read_cache_primes(); + let hits = inherited_read_cache_hits(); + let declines = inherited_read_cache_declines(); + let neg = inherited_read_cache_neg_served(); + let reads = (objs.len() * rounds) as u64; + + assert_eq!( + primes, + objs.len() as u64, + "primed {primes} times for {} receivers. Exactly one prime per \ + receiver is the property: more means the entries are evicting \ + each other and every read pays a full chain walk AND an entry \ + write", + objs.len() + ); + + // At most ONE decline, and it is expected rather than tolerated. + // + // The inherited-read cache refuses to record a hop that the + // `[[Prototype]]` install funnel has not marked, and when its walk + // meets an unmarked hop it marks that hop and ABANDONS the walk + // without recording anything (`object::proto_validity`). Marking + // allocates a meta record, which can move the receiver, the hop and + // every address the walk is holding, so nothing it was holding may be + // touched afterwards — abandoning is not a shortcut, it is the only + // safe thing to do once the allocation has happened. + // + // These eight receivers share ONE prototype, so at most one read pays + // that: the first to reach an unmarked hop. Every later read finds it + // marked and primes normally. Without the marking stack in the tree + // this is 0; with it, 1. Both are correct, and the accounting below + // pins the difference to exactly that one read instead of loosening + // the hit count to absorb it. + assert!( + declines <= 1, + "{declines} declines: at most one mark-and-abandon is expected for \ + a single shared prototype" + ); + + assert_eq!( + hits + primes + declines + neg, + reads, + "every read must be exactly one of: served from an entry ({hits}), \ + the walk that recorded one ({primes}), a mark-and-abandon \ + ({declines}), or served from a negative entry ({neg}) — and they \ + sum to {}, not the {reads} reads performed", + hits + primes + declines + neg + ); + } +} + #[test] fn a_null_prototype_receiver_never_primes() { let _scope = PrimeScope::new();