From 46af94da0164ffb20989d1dafa9b24c26a65ab56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 07:10:41 +0200 Subject: [PATCH 1/2] perf(gc): an inline-slot store onto the cached dirty page skips both barrier classifications; Map dense key needs one round trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write_barrier_decoded_parent classified the parent's page and the child's page on every remembered store before reaching mark_dirty_old_page, where the one-entry dirty-page cache then usually answered. The cache's invariant (cached ⟹ recorded in DIRTY_OLD_PAGES and stamped dirty) is exactly what an inline-slot store on that page would establish, so the barrier now returns right after the SATB prologue when the slot's page is the cached one — the second and third push into the same bucket, and every push into a large array whose tail sits on one page, pay neither classification. dense_integer_key: as u32 saturates, so the round-trip compare alone decides every case the three range tests pre-screened. Claude-Session: https://claude.ai/code/session_01FUvFrRNZyc5qknBiJbYbby --- crates/perry-runtime/src/gc/barrier/mod.rs | 20 ++++++++++ .../src/gc/tests/barrier_decoded_parent.rs | 38 +++++++++++++++++++ crates/perry-runtime/src/map.rs | 8 ++-- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/crates/perry-runtime/src/gc/barrier/mod.rs b/crates/perry-runtime/src/gc/barrier/mod.rs index 69d864bf8c..fa5ba07323 100644 --- a/crates/perry-runtime/src/gc/barrier/mod.rs +++ b/crates/perry-runtime/src/gc/barrier/mod.rs @@ -1220,6 +1220,26 @@ pub(super) fn write_barrier_decoded_parent( // Old → young check. Runtime-owned malloc GC objects are outside // the nursery and must be treated as old when the caller uses the // external-slot path for fields or side buffers. + // An inline slot whose page is the one the dirty-page cache names has + // nothing left to owe the remembered set: the cache's invariant + // (`dirty_page_cache`) is "cached ⟹ recorded in DIRTY_OLD_PAGES AND + // stamped dirty in the page metadata", and that is exactly what + // `remember_old_to_young_inline_slot` would establish for this slot. The + // SATB shading already ran in the caller's prologue. Answering here skips + // both page-generation classifications — the parent's and the child's — + // which is the whole cost of the barrier on the second and third push into + // the same bucket, or on every push into a large array whose tail sits on + // one page. + if !external_slot + && slot_addr != 0 + && slot_addr >= parent_addr + && super::dirty_page_cache::dirty_old_page_already_marked( + crate::arena::generation_page_for_addr(slot_addr), + ) + { + bump_write_barrier_trace_counter(BarrierTraceCounter::DirtyPageCacheHits); + return; + } if !barrier_parent_needs_remembering(parent_addr, external_slot) { bump_write_barrier_trace_counter(BarrierTraceCounter::ParentNotOldSkips); return; diff --git a/crates/perry-runtime/src/gc/tests/barrier_decoded_parent.rs b/crates/perry-runtime/src/gc/tests/barrier_decoded_parent.rs index ada657d3e6..a3c98de5ba 100644 --- a/crates/perry-runtime/src/gc/tests/barrier_decoded_parent.rs +++ b/crates/perry-runtime/src/gc/tests/barrier_decoded_parent.rs @@ -64,6 +64,44 @@ fn runtime_write_barrier_slot_remembers_old_to_young_edge() { reset_remembered_set(); } +/// A second inline-slot store onto a page the dirty-page cache already names +/// is answered by the cache alone: still exactly one remembered page, and +/// the parent/child classifications are not consulted (the cache invariant +/// makes the page's record sufficient). +#[test] +fn inline_slot_store_onto_the_cached_dirty_page_is_a_cache_hit() { + let _guard = GcTestIsolationGuard::new(); + reset_remembered_set(); + + let young = crate::arena::arena_alloc_gc(40, 8, GC_TYPE_OBJECT) as usize; + let (old_obj, fields) = unsafe { alloc_old_test_object(2) }; + let child_bits = ptr_bits(young); + unsafe { + *fields = child_bits; + *fields.add(1) = child_bits; + } + let page = crate::arena::generation_page_for_addr(fields as usize); + assert!(!old_page_dirty_for(page)); + + runtime_write_barrier_slot(old_obj as usize, fields as usize, child_bits); + assert_eq!(remembered_dirty_page_count(), 1); + assert!(old_page_dirty_for(page)); + + // Same page, next slot: the cache hit must leave the record untouched and + // must not require the child to be young — a value the classifier would + // reject still returns through the cache, because the page is covered. + let old_child = crate::arena::arena_alloc_gc_old(40, 8, GC_TYPE_OBJECT) as usize; + runtime_write_barrier_slot(old_obj as usize, fields as usize + 8, ptr_bits(old_child)); + assert_eq!( + remembered_dirty_page_count(), + 1, + "a store onto the cached dirty page adds no record" + ); + assert!(old_page_dirty_for(page)); + + reset_remembered_set(); +} + /// The validated-parent entry codegen takes behind its `GC_FLAG_TENURED` gate /// must remember exactly what the tag-dispatching entry remembers. #[test] diff --git a/crates/perry-runtime/src/map.rs b/crates/perry-runtime/src/map.rs index 82d2b94a0e..09b84ba1d8 100644 --- a/crates/perry-runtime/src/map.rs +++ b/crates/perry-runtime/src/map.rs @@ -336,9 +336,11 @@ struct NumericIndex { #[inline] fn dense_integer_key(key: NumericKey) -> Option { let value = f64::from_bits(key.0); - if !value.is_finite() || value < 0.0 || value > u32::MAX as f64 { - return None; - } + // `as u32` saturates (NaN → 0, negatives → 0, > u32::MAX and +inf → + // u32::MAX), so the round trip alone decides every case the three range + // tests used to pre-screen: a value that is not a finite integer in + // 0..=u32::MAX never converts back to itself. One conversion pair instead + // of three compares and a conversion pair, on every dense Map lookup. let integer = value as u32; (integer as f64 == value).then_some(integer) } From 470b3f352417d54c59eac15cb6eb4c6ef6757b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 07:32:00 +0200 Subject: [PATCH 2/2] changelog: fragment for #8916 Claude-Session: https://claude.ai/code/session_01FUvFrRNZyc5qknBiJbYbby --- changelog.d/8916-barrier-dirty-page-early-exit.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/8916-barrier-dirty-page-early-exit.md diff --git a/changelog.d/8916-barrier-dirty-page-early-exit.md b/changelog.d/8916-barrier-dirty-page-early-exit.md new file mode 100644 index 0000000000..e05a31d6ea --- /dev/null +++ b/changelog.d/8916-barrier-dirty-page-early-exit.md @@ -0,0 +1 @@ +- **gc:** an inline-slot store onto the page the one-entry dirty-page cache already holds returns right after the SATB prologue, skipping both the parent and the child classification — the cache's invariant (cached ⟹ recorded in `DIRTY_OLD_PAGES` and stamped dirty) already covers the store whatever the child is. Counted under `BarrierTraceCounter::DirtyPageCacheHits`. `Map` dense integer keys decide with one `as u32` round trip instead of three range tests plus the round trip. `codehz/ecs` "5k entities: 3 commands each + sync": 4.142 → 3.961 ms/op (+4.4%, 15/15 paired runs) on top of #8897.