From 87fa8e151675503aa1f1b24471939534587386d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 08:18:49 +0200 Subject: [PATCH 01/11] perf(gc): defer old-object page registration off the promote path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `register_old_object_pages` pays two RefCell borrows, two Vec allocations, a hash lookup and a linear dedup scan of the page's object list per object — and the scan grows as the page fills, so a burst of old-gen births into one 4 KiB page is quadratic in the objects it lands there. Since #7613's promote-on-first-copy that path is hot on ordinary workloads: a copying minor promotes straight into old-gen via `arena_alloc_gc_old`, so json_pipeline pushes ~113 MB of promotions per run through it. `arena_alloc_gc_old` now records `(header_addr, total_size)` in a thread-local buffer and one batched flush folds the whole burst in, holding a single borrow of each table and scanning only the entries that predate the batch — zero dedup comparisons for the fresh pages a bump-allocated promotion burst actually fills. Allocation policy is unchanged: the `old_free_take_exact` hole probe stays, so this is bookkeeping only. Soundness rests on one rule: every reader AND every remover of OLD_GEN_PAGE_OBJECTS / OLD_GEN_PAGE_META flushes first. Removers matter as much as readers — a removal that runs while an entry is still deferred is a no-op, and the later flush would then resurrect a dead object. `arena_alloc_gc_old_excluding_pages` (old-page defrag relocation) stays eager: it is rare, its per-object cost is dominated by the memcpy beside it, and keeping it eager narrows the proof obligation. Origin: extracted from #7623, whose pretenure half was a measurement confound and is not merging. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- crates/perry-runtime/src/arena/allocators.rs | 24 +- crates/perry-runtime/src/arena/mod.rs | 11 +- crates/perry-runtime/src/arena/page_meta.rs | 167 +++++++++++++ crates/perry-runtime/src/arena/tests.rs | 248 +++++++++++++++++++ 4 files changed, 443 insertions(+), 7 deletions(-) diff --git a/crates/perry-runtime/src/arena/allocators.rs b/crates/perry-runtime/src/arena/allocators.rs index 1270929140..b78f0564fa 100644 --- a/crates/perry-runtime/src/arena/allocators.rs +++ b/crates/perry-runtime/src/arena/allocators.rs @@ -116,6 +116,16 @@ pub(crate) fn arena_alloc_old_excluding_pages( /// GcHeader-prefixed counterpart of `arena_alloc_old`. See /// `arena_alloc_gc_longlived` for the same shape on the longlived /// arena — only the backing region differs. +/// +/// #7625: page registration is DEFERRED here (`defer_old_object_page_registration` +/// rather than `register_old_object_pages`). This is the per-object old-gen +/// birth path — since #7613's promote-on-first-copy it carries every promotion +/// a copying minor makes, ~113 MB per json_pipeline run — and eager +/// registration costs two `RefCell` borrows, two `Vec` allocations, and a +/// linear dedup scan that grows as the page fills. Allocation policy is +/// deliberately UNCHANGED: the `old_free_take_exact` hole probe below stays, +/// so this is a bookkeeping change only. See the flush discipline in +/// `arena/page_meta.rs`. pub fn arena_alloc_gc_old(size: usize, align: usize, obj_type: u8) -> *mut u8 { use crate::gc::{GcHeader, GC_FLAG_ARENA, GC_HEADER_SIZE}; @@ -136,7 +146,7 @@ pub fn arena_alloc_gc_old(size: usize, align: usize, obj_type: u8) -> *mut u8 { (*header)._reserved = 0; (*header).size = total as u32; } - register_old_object_pages(raw as usize, total); + defer_old_object_page_registration(raw as usize, total); return user_ptr as *mut u8; } let raw = arena_alloc_old(total, align); @@ -149,7 +159,7 @@ pub fn arena_alloc_gc_old(size: usize, align: usize, obj_type: u8) -> *mut u8 { (*header)._reserved = 0; (*header).size = total as u32; } - register_old_object_pages(raw as usize, total); + defer_old_object_page_registration(raw as usize, total); unsafe { raw.add(GC_HEADER_SIZE) } } @@ -174,6 +184,16 @@ pub(crate) fn arena_alloc_gc_old_born_tenured(size: usize, align: usize, obj_typ user_ptr } +/// #7625: registration stays EAGER here, unlike `arena_alloc_gc_old`. This is +/// old-page defrag's relocation allocator (`gc/oldgen.rs`'s +/// `evacuate_selected_old_pages_collecting`), which runs from INSIDE +/// `old_arena_walk_objects_on_pages`' callback — i.e. downstream of that +/// reader's own flush. Deferring would be sound (the walk snapshots its header +/// list before invoking the callback, and the flush discipline covers the +/// rest), but it buys nothing: defrag is a rare, per-cycle pass whose +/// per-object cost is dominated by the `copy_nonoverlapping` beside it, and +/// keeping it eager keeps the deferral's proof obligation to the one path that +/// measurably needs it. pub(crate) fn arena_alloc_gc_old_excluding_pages( size: usize, align: usize, diff --git a/crates/perry-runtime/src/arena/mod.rs b/crates/perry-runtime/src/arena/mod.rs index 04027c5551..9015ba1120 100644 --- a/crates/perry-runtime/src/arena/mod.rs +++ b/crates/perry-runtime/src/arena/mod.rs @@ -43,9 +43,9 @@ pub(crate) use block::{ gc_trigger_arena_calls, reset_gc_trigger_arena_probe, }; pub(crate) use page_meta::{ - address_span_overlaps_pages, register_block_space, register_old_object_pages, - unregister_block_generation, unregister_old_block_pages, OLD_GEN_RECLAIM_RETURNED_BYTES, - OLD_GEN_RECLAIM_REUSABLE_BYTES, + address_span_overlaps_pages, defer_old_object_page_registration, register_block_space, + register_old_object_pages, unregister_block_generation, unregister_old_block_pages, + OLD_GEN_RECLAIM_RETURNED_BYTES, OLD_GEN_RECLAIM_REUSABLE_BYTES, }; pub(crate) use page_meta::{page_generation_cache_hot_addr, page_generations_hot_addr}; @@ -122,6 +122,7 @@ pub(crate) use page_meta::{ #[cfg(test)] pub(crate) use page_meta::{ - generation_page_base, old_arena_page_index_clear_for_tests, old_page_meta_for_tests, - GENERATION_CLASS_SHIFT, GENERATION_PAGE_SIZE, + deferred_old_page_registrations_len, generation_page_base, + old_arena_page_index_clear_for_tests, old_page_meta_for_tests, + DEFERRED_OLD_PAGE_REGISTRATION_CAP, GENERATION_CLASS_SHIFT, GENERATION_PAGE_SIZE, }; diff --git a/crates/perry-runtime/src/arena/page_meta.rs b/crates/perry-runtime/src/arena/page_meta.rs index 0b4653b17e..dee1392e37 100644 --- a/crates/perry-runtime/src/arena/page_meta.rs +++ b/crates/perry-runtime/src/arena/page_meta.rs @@ -400,6 +400,10 @@ pub(crate) fn unregister_old_block_pages(pages: &[usize]) { if pages.is_empty() { return; } + // #7625 REMOVER: a deferred entry for one of these pages must be folded in + // BEFORE the page is dropped, or the flush would re-add it afterwards and + // hand a later walk a header inside a recycled block. + flush_deferred_old_page_registrations(); OLD_GEN_PAGE_META.with(|meta| { let mut meta = meta.borrow_mut(); for &page in pages { @@ -656,11 +660,142 @@ pub(crate) fn register_old_object_pages(header_addr: usize, total_size: usize) { update_old_page_meta_for_object(&added_pages, true); } +// --------------------------------------------------------------------------- +// #7625: deferred old-object page registration. +// +// `register_old_object_pages` above is written for the OCCASIONAL old-gen +// birth it was introduced for. Per call it pays two `RefCell` borrows, two +// `Vec` allocations, a hash lookup, and a **linear `contains` scan of the +// page's object list** — and that scan grows as the page fills, so a burst of +// births into one 4 KiB page is quadratic in the objects it lands there. +// +// #7613's promote-on-first-copy made that path hot on ordinary workloads: a +// copying minor promotes straight into old-gen (`gc/copying.rs`'s `move_young` +// → `arena_alloc_gc_old`), so json_pipeline pushes ~113 MB of promotions per +// run through it. Deferring lets the whole burst be registered in ONE batch, +// where the per-page list length is captured once and the dedup scan only has +// to cover the entries that predate the batch — zero comparisons for the fresh +// pages a bump-allocated promotion burst actually fills. +// +// SOUNDNESS. The deferral is invisible because **every reader and every +// remover of `OLD_GEN_PAGE_OBJECTS`/`OLD_GEN_PAGE_META` flushes first**, so +// the index is exactly what eager registration would have left at each +// observation point. Ordering matters in both directions: a remover that ran +// before the flush would be a no-op and the flush would then RESURRECT a dead +// entry, which is why removers flush too and not only readers. The flush sites +// are enumerated in `deferred_registration_flush_sites` in `arena/tests.rs`, +// which fails if a new toucher of either table appears without one. +// --------------------------------------------------------------------------- + +thread_local! { + /// Old-object page registrations not yet folded into `OLD_GEN_PAGE_OBJECTS`. + /// Entries are `(header_addr, total_size)`; nothing here is dereferenced, so + /// a deferred entry never keeps an object alive and is not a GC root — and + /// the flush discipline above means the buffer is provably EMPTY at every + /// point a collector could observe it (asserted by + /// `deferred_buffer_is_empty_after_every_cycle_constructor`). + static DEFERRED_OLD_PAGE_REGISTRATIONS: RefCell> = + const { RefCell::new(Vec::new()) }; +} + +/// Cap chosen so the buffer's worst-case footprint (16 B/entry × 64k = 1 MB) +/// stays a rounding error while flushes stay rare on an allocation burst. +pub(crate) const DEFERRED_OLD_PAGE_REGISTRATION_CAP: usize = 65_536; + +/// Record `header_addr`'s page registration for the next flush instead of +/// performing it now. Callers must be old-gen births; see the module comment +/// for why this is invisible to every consumer of the index. +#[inline] +pub(crate) fn defer_old_object_page_registration(header_addr: usize, total_size: usize) { + if header_addr == 0 || total_size == 0 { + return; + } + let full = DEFERRED_OLD_PAGE_REGISTRATIONS.with(|buf| { + let mut buf = buf.borrow_mut(); + buf.push((header_addr, total_size)); + buf.len() >= DEFERRED_OLD_PAGE_REGISTRATION_CAP + }); + if full { + flush_deferred_old_page_registrations_batch(); + } +} + +/// Make the page-objects index complete. Cheap (one thread-local read) when +/// nothing is pending, which is the case at all but a handful of GC-time calls. +#[inline] +pub(crate) fn flush_deferred_old_page_registrations() { + if DEFERRED_OLD_PAGE_REGISTRATIONS.with(|buf| buf.borrow().is_empty()) { + return; + } + flush_deferred_old_page_registrations_batch(); +} + +/// The batched drain. Equivalent to `register_old_object_pages` per entry, but +/// holding one borrow of each table for the whole batch and — the part that +/// removes the quadratic term — scanning only the portion of a page's object +/// list that PREDATES this batch. +/// +/// Skipping the in-batch entries is sound because they are pairwise distinct: +/// each comes from a live allocation, and an address cannot be handed out twice +/// without an intervening free, which cannot happen without a flush (every +/// remover flushes). Hole reuse — the reason the dedup exists at all — hands +/// back an address registered BEFORE the batch, so it is still covered. +#[cold] +fn flush_deferred_old_page_registrations_batch() { + let pending = + DEFERRED_OLD_PAGE_REGISTRATIONS.with(|buf| std::mem::take(&mut *buf.borrow_mut())); + if pending.is_empty() { + return; + } + let mut meta_updates: Vec<(usize, usize)> = Vec::with_capacity(pending.len()); + OLD_GEN_PAGE_OBJECTS.with(|index| { + let mut index = index.borrow_mut(); + // Entries arrive in allocation order, so consecutive ones share a page; + // cache that page's pre-batch length across the run. + let mut run_page: Option = None; + let mut run_base_len: usize = 0; + for &(header_addr, total_size) in &pending { + let object_end = header_addr + total_size; + let first_page = generation_page_for_addr(header_addr); + let last_page = generation_page_for_addr(object_end - 1); + for page in first_page..=last_page { + let page_base = generation_page_base(page); + let page_end = page_base + GENERATION_PAGE_SIZE; + let overlap_start = header_addr.max(page_base); + let overlap_end = object_end.min(page_end); + if overlap_start >= overlap_end { + continue; + } + let headers = index.entry(page).or_insert_with(Vec::new); + if run_page != Some(page) { + run_page = Some(page); + run_base_len = headers.len(); + } + if !headers[..run_base_len.min(headers.len())].contains(&header_addr) { + headers.push(header_addr); + meta_updates.push((page, overlap_end - overlap_start)); + } + } + } + }); + update_old_page_meta_for_object(&meta_updates, true); +} + +/// Entries awaiting a flush. Tests only — the buffer is an implementation +/// detail everywhere else. +#[cfg(test)] +pub(crate) fn deferred_old_page_registrations_len() -> usize { + DEFERRED_OLD_PAGE_REGISTRATIONS.with(|buf| buf.borrow().len()) +} + #[allow(dead_code)] pub(crate) fn unregister_old_object_pages(header_addr: usize, total_size: usize) { if header_addr == 0 || total_size == 0 { return; } + // #7625 REMOVER: see `unregister_old_block_pages`. Removing before the + // flush would leave the flush to resurrect this object. + flush_deferred_old_page_registrations(); let overlaps = old_object_page_overlaps(header_addr, total_size); let mut removed_pages = Vec::with_capacity(overlaps.len()); OLD_GEN_PAGE_OBJECTS.with(|index| { @@ -683,6 +818,11 @@ pub(crate) fn unregister_old_object_pages(header_addr: usize, total_size: usize) } pub(crate) fn old_pages_begin_gc_cycle() { + // #7625 CYCLE START: all three cycle constructors route through here + // (`gc/mod.rs`'s minor, `gc/cycle.rs`'s `new_full`, `gc/policy.rs`'s + // budgeted), so every collection begins with a complete page-objects index + // and an EMPTY deferral buffer. + flush_deferred_old_page_registrations(); // #6181: the per-page `dirty_slots` reset used to iterate every old page // here (O(old pages) on every minor, growing with old-gen size). It is now // a single epoch bump — a page whose `dirty_slots_epoch` predates the new @@ -787,6 +927,10 @@ pub(crate) fn old_page_account_dirty_slot(slot_addr: usize) { } pub(crate) fn old_page_summary() -> OldPageSummary { + // #7625 READER (`OLD_GEN_PAGE_META`): a deferred registration also owes + // this table an `allocated_bytes`/`object_count` update, so the summary + // would under-report a mid-cycle promotion burst without the flush. + flush_deferred_old_page_registrations(); let current_epoch = old_gen_page_dirty_epoch(); OLD_GEN_PAGE_META.with(|meta| { let meta = meta.borrow(); @@ -831,6 +975,9 @@ pub(crate) fn old_page_summary() -> OldPageSummary { } pub(crate) fn old_page_meta_snapshot() -> Vec { + // #7625 READER (`OLD_GEN_PAGE_META`): this one drives real policy — + // `gc/oldgen_defrag.rs` selects evacuation pages from it. + flush_deferred_old_page_registrations(); let current_epoch = old_gen_page_dirty_epoch(); OLD_GEN_PAGE_META.with(|meta| { let mut snapshot = meta @@ -893,6 +1040,11 @@ pub(crate) fn old_arena_walk_objects_on_pages( return 0; } + // #7625 READER: promotions land in old-gen mid-cycle (a copying minor's + // root scan runs before the remembered-set walk), so this cannot rely on + // the cycle-start flush alone. + flush_deferred_old_page_registrations(); + let mut headers = Vec::new(); let mut seen = crate::fast_hash::new_ptr_hash_set(); OLD_GEN_PAGE_OBJECTS.with(|index| { @@ -923,6 +1075,12 @@ pub(crate) struct OldArenaPageObjectCursor { impl OldArenaPageObjectCursor { pub(crate) fn new(pages: &crate::fast_hash::PtrHashSet) -> Self { + // #7625 READER: same obligation as `old_arena_walk_objects_on_pages`. + // The cursor is stepped incrementally by the budgeted cycle, which + // marks but never allocates into old-gen, so no entry can accumulate + // between `new` and the last `next` — `cursor_window_defers_nothing` + // pins that. + flush_deferred_old_page_registrations(); Self { pages: pages.iter().copied().collect(), page_cursor: 0, @@ -957,6 +1115,8 @@ pub(crate) fn old_arena_page_index_remove_object(header_addr: usize, total_size: if header_addr == 0 || total_size == 0 { return; } + // #7625 REMOVER: see `unregister_old_block_pages`. + flush_deferred_old_page_registrations(); let overlaps = old_object_page_overlaps(header_addr, total_size); if overlaps.is_empty() { return; @@ -1008,11 +1168,18 @@ pub(crate) fn old_arena_page_index_clear_for_tests() { // Wiping page metadata makes real old-arena objects unclassifiable — // stand the #6179 differential verifier down for this thread's test. crate::gc::CLASSIFIER_VERIFY_SUPPRESSED.with(|c| c.set(true)); + // #7625: DISCARD rather than flush — a caller asking for an empty index + // would get a repopulated one if the pending burst were folded in first. + DEFERRED_OLD_PAGE_REGISTRATIONS.with(|buf| buf.borrow_mut().clear()); OLD_GEN_PAGE_OBJECTS.with(|index| index.borrow_mut().clear()); } #[cfg(test)] pub(crate) fn old_page_meta_for_tests(page: usize) -> Option { + // #7625 READER: same rule as `old_page_summary`/`old_page_meta_snapshot`, + // so a test that allocates and then inspects a page sees what eager + // registration would have left. + flush_deferred_old_page_registrations(); let current_epoch = old_gen_page_dirty_epoch(); OLD_GEN_PAGE_META.with(|meta| { meta.borrow() diff --git a/crates/perry-runtime/src/arena/tests.rs b/crates/perry-runtime/src/arena/tests.rs index d68af2088e..a8483110c6 100644 --- a/crates/perry-runtime/src/arena/tests.rs +++ b/crates/perry-runtime/src/arena/tests.rs @@ -1168,3 +1168,251 @@ fn block_pool_is_per_thread_and_drops_with_its_thread() { // The other thread's pool never touched ours. assert_eq!(block_pool_bytes_for_test(), before); } + +// --------------------------------------------------------------------------- +// #7625: deferred old-object page registration. +// +// `arena_alloc_gc_old` records its page registration in a thread-local buffer +// instead of folding it into `OLD_GEN_PAGE_OBJECTS`/`OLD_GEN_PAGE_META` on the +// spot. The deferral is only invisible if EVERY reader and EVERY remover of +// those two tables flushes first, so that is what these pin — one test per +// obligation, each written so that deleting the corresponding +// `flush_deferred_old_page_registrations()` call turns it red. +// --------------------------------------------------------------------------- + +/// A synthetic old-gen block plus `count` distinct in-range header addresses. +/// Registration never dereferences a header, so fabricated addresses exercise +/// the bookkeeping exactly as real ones do — and keep the test independent of +/// how many objects an allocator happens to fit in a page. +fn synthetic_old_headers(count: usize) -> Vec { + let (base, min_size) = synthetic_old_block_range(); + let size = (count * 64) + .next_multiple_of(GENERATION_PAGE_SIZE) + .max(min_size); + register_block_space(base, size, HeapGeneration::Old, HeapSpace::Old); + (0..count).map(|i| base + i * 64).collect() +} + +fn page_object_count(page: usize) -> usize { + old_page_meta_for_tests(page) + .map(|meta| meta.object_count) + .unwrap_or(0) +} + +#[test] +fn old_gen_birth_defers_its_page_registration() { + run_with_fresh_arenas(|| { + assert_eq!(deferred_old_page_registrations_len(), 0); + let _old_ptr = arena_alloc_gc_old(40, 8, GC_TYPE_STRING) as usize; + assert!( + deferred_old_page_registrations_len() > 0, + "arena_alloc_gc_old must defer, not register eagerly — otherwise \ + the change is inert and every measurement of it is vacuous" + ); + }); +} + +#[test] +fn cycle_start_flushes_deferred_registrations() { + run_with_fresh_arenas(|| { + let old_ptr = arena_alloc_gc_old(40, 8, GC_TYPE_STRING) as usize; + let (header_addr, total_size) = old_header_and_size(old_ptr); + assert!(deferred_old_page_registrations_len() > 0); + + // The single flush point all three cycle constructors route through. + old_pages_begin_gc_cycle(); + + assert_eq!( + deferred_old_page_registrations_len(), + 0, + "old_pages_begin_gc_cycle must leave the deferral buffer empty" + ); + let mut pages = crate::fast_hash::new_ptr_hash_set(); + for (page, _) in old_object_page_overlaps(header_addr, total_size) { + pages.insert(page); + } + let mut visited = Vec::new(); + old_arena_walk_objects_on_pages(&pages, |header| visited.push(header as usize)); + assert_seen_headers("post-cycle-start walk", &visited, &[header_addr]); + }); +} + +/// The other half of the cycle-constructor claim. `cycle_start_flushes_...` +/// proves `old_pages_begin_gc_cycle` flushes; this proves each of the three +/// constructors actually calls it, which is what makes "every collection begins +/// with a complete index" true rather than merely asserted in a comment. +#[test] +fn every_cycle_constructor_routes_through_the_flush_point() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/gc"); + for (file, what) in [ + ("mod.rs", "non-moving / copying minor"), + ("cycle.rs", "full mark-sweep (GcCycleState::new_full)"), + ("policy.rs", "budgeted minor"), + ] { + let src = std::fs::read_to_string(root.join(file)) + .unwrap_or_else(|e| panic!("cannot read gc/{file}: {e}")); + assert!( + src.contains("old_pages_begin_gc_cycle()"), + "the {what} constructor in gc/{file} no longer calls \ + old_pages_begin_gc_cycle(); deferred old-page registrations would \ + survive into the cycle unflushed (#7625)" + ); + } +} + +#[test] +fn deferral_buffer_flushes_at_its_size_cap() { + run_with_fresh_arenas(|| { + let headers = synthetic_old_headers(DEFERRED_OLD_PAGE_REGISTRATION_CAP); + for &header in &headers { + defer_old_object_page_registration(header, 64); + } + assert_eq!( + deferred_old_page_registrations_len(), + 0, + "the buffer must self-flush at DEFERRED_OLD_PAGE_REGISTRATION_CAP \ + so it cannot grow without bound between collections" + ); + // And the cap flush is a real registration, not a discard. + assert!(page_object_count(generation_page_for_addr(headers[0])) > 0); + }); +} + +/// Each reader of the two tables, one obligation per assertion. Delete any one +/// `flush_deferred_old_page_registrations()` in `page_meta.rs` and exactly one +/// of these goes red. +#[test] +fn every_index_reader_flushes_before_reading() { + run_with_fresh_arenas(|| { + let headers = synthetic_old_headers(4); + let page = generation_page_for_addr(headers[0]); + let mut pages = crate::fast_hash::new_ptr_hash_set(); + pages.insert(page); + + // 1. old_arena_walk_objects_on_pages + defer_old_object_page_registration(headers[0], 64); + let mut visited = Vec::new(); + old_arena_walk_objects_on_pages(&pages, |h| visited.push(h as usize)); + assert_seen_headers("old_arena_walk_objects_on_pages", &visited, &[headers[0]]); + assert_eq!(deferred_old_page_registrations_len(), 0); + + // 2. OldArenaPageObjectCursor — same index, incremental reader. + defer_old_object_page_registration(headers[1], 64); + let mut cursor = OldArenaPageObjectCursor::new(&pages); + assert_eq!( + deferred_old_page_registrations_len(), + 0, + "OldArenaPageObjectCursor::new must flush before it starts stepping" + ); + let mut seen = Vec::new(); + while let Some(h) = cursor.next() { + seen.push(h); + } + assert_seen_headers("OldArenaPageObjectCursor", &seen, &headers[..2]); + + // 3. old_page_summary (OLD_GEN_PAGE_META) + let before = old_page_summary().object_count; + defer_old_object_page_registration(headers[2], 64); + assert_eq!( + old_page_summary().object_count, + before + 1, + "old_page_summary must flush; a mid-cycle promotion burst would \ + otherwise be missing from allocated_bytes/object_count" + ); + + // 4. old_page_meta_snapshot — drives defrag page selection. + defer_old_object_page_registration(headers[3], 64); + let snapshot = old_page_meta_snapshot(); + assert_eq!(deferred_old_page_registrations_len(), 0); + let page_base = generation_page_base(page); + let meta = snapshot + .iter() + .find(|m| m.page_base == page_base) + .expect("snapshot should carry the page"); + assert_eq!(meta.object_count, 4); + }); +} + +/// The remover obligation, and the one that is easiest to get wrong: a removal +/// that runs while the object is still only DEFERRED is a no-op, and the later +/// flush then puts the dead object back. Registration ORDER, not just eventual +/// visibility, is what the flush-before-remove rule buys. +#[test] +fn removing_a_deferred_object_does_not_resurrect_it() { + run_with_fresh_arenas(|| { + let headers = synthetic_old_headers(3); + let mut pages = crate::fast_hash::new_ptr_hash_set(); + pages.insert(generation_page_for_addr(headers[0])); + + let visited_now = |pages: &crate::fast_hash::PtrHashSet| { + let mut visited = Vec::new(); + old_arena_walk_objects_on_pages(pages, |h| visited.push(h as usize)); + visited + }; + + // 1. unregister_old_object_pages + defer_old_object_page_registration(headers[0], 64); + unregister_old_object_pages(headers[0], 64); + assert!( + !visited_now(&pages).contains(&headers[0]), + "a deferred entry removed before its flush was resurrected by the \ + flush — unregister_old_object_pages must flush first (#7625)" + ); + + // 2. old_arena_page_index_remove_object + defer_old_object_page_registration(headers[1], 64); + old_arena_page_index_remove_object(headers[1], 64); + assert!( + !visited_now(&pages).contains(&headers[1]), + "old_arena_page_index_remove_object must flush first (#7625)" + ); + + // 3. unregister_old_block_pages — the whole page goes away, and a + // later flush must not recreate it pointing into a recycled block. + defer_old_object_page_registration(headers[2], 64); + unregister_old_block_pages(&[generation_page_for_addr(headers[2])]); + assert!( + !visited_now(&pages).contains(&headers[2]), + "unregister_old_block_pages must flush first (#7625)" + ); + }); +} + +/// The batched flush skips the dedup scan over entries added within the same +/// batch. That is only sound if it still catches the case the dedup exists for: +/// hole reuse handing back an address registered BEFORE the batch. +#[test] +fn batched_flush_matches_eager_registration() { + run_with_fresh_arenas(|| { + let headers = synthetic_old_headers(64); + let page = generation_page_for_addr(headers[0]); + + // A pre-existing (pre-batch) registration, as hole reuse would leave. + register_old_object_pages(headers[0], 64); + assert_eq!(page_object_count(page), 1); + + // Now defer the whole set INCLUDING the already-registered address. + for &header in &headers { + defer_old_object_page_registration(header, 64); + } + let mut pages = crate::fast_hash::new_ptr_hash_set(); + pages.insert(page); + let mut visited = Vec::new(); + old_arena_walk_objects_on_pages(&pages, |h| visited.push(h as usize)); + + visited.sort_unstable(); + let mut expected = headers.clone(); + expected.sort_unstable(); + assert_eq!( + visited, expected, + "batched flush must produce exactly the eager index — no duplicate \ + for the re-registered address, no dropped entry" + ); + assert_eq!( + page_object_count(page), + headers.len(), + "page object_count must match the eager path's, counting the \ + re-registered address exactly once" + ); + }); +} From 9d5dae155594d3bea4f72fe4604837368bed6051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 08:37:33 +0200 Subject: [PATCH 02/11] perf(gc): close the promote-to-sweep window and pin the cursor's flush claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups to the deferral, all soundness/documentation: - `old_pages_reset_sweep_accounting` flushes. The per-object OLD_GEN_PAGE_META writers that follow it call `refresh_policy_bits`, which reads `allocated_bytes`; flushing at sweep entry means a page's policy bits are never recomputed from a count missing this cycle's promotions. - `OldArenaPageObjectCursor::next` debug-asserts the buffer is empty. `new` flushes, and the budgeted stepping window marks without allocating into old-gen — this pins that claim instead of paying a thread-local read per object to re-establish it. - Fixed a comment that named a test which does not exist. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- crates/perry-runtime/src/arena/page_meta.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/perry-runtime/src/arena/page_meta.rs b/crates/perry-runtime/src/arena/page_meta.rs index dee1392e37..0b86ea88cf 100644 --- a/crates/perry-runtime/src/arena/page_meta.rs +++ b/crates/perry-runtime/src/arena/page_meta.rs @@ -834,6 +834,12 @@ pub(crate) fn old_pages_begin_gc_cycle() { } pub(crate) fn old_pages_reset_sweep_accounting() { + // #7625 READER (`OLD_GEN_PAGE_META`): closes the promote → sweep window + // inside a full cycle. The per-object accounting that follows calls + // `refresh_policy_bits`, which reads `allocated_bytes`; flushing here means + // it never recomputes a page's bits from a count that is missing this + // cycle's promotions. + flush_deferred_old_page_registrations(); OLD_GEN_PAGE_META.with(|meta| { for page_meta in meta.borrow_mut().values_mut() { page_meta.reset_cycle_sweep_accounting(); @@ -1077,9 +1083,9 @@ impl OldArenaPageObjectCursor { pub(crate) fn new(pages: &crate::fast_hash::PtrHashSet) -> Self { // #7625 READER: same obligation as `old_arena_walk_objects_on_pages`. // The cursor is stepped incrementally by the budgeted cycle, which - // marks but never allocates into old-gen, so no entry can accumulate - // between `new` and the last `next` — `cursor_window_defers_nothing` - // pins that. + // marks but never allocates into old-gen, so nothing can accumulate + // between `new` and the last `next`; `next` debug-asserts that rather + // than paying a thread-local check per object. flush_deferred_old_page_registrations(); Self { pages: pages.iter().copied().collect(), @@ -1089,6 +1095,14 @@ impl OldArenaPageObjectCursor { } pub(crate) fn next(&mut self) -> Option { + // #7625: `new` flushed; the stepping window must not re-dirty the + // buffer, or this reader would be walking a stale index. Debug-only so + // the per-object read costs nothing in a shipped collector. + debug_assert!( + DEFERRED_OLD_PAGE_REGISTRATIONS.with(|buf| buf.borrow().is_empty()), + "an old-gen birth happened while a page-object cursor was stepping; \ + this reader is now walking a stale index (#7625)" + ); loop { let page = *self.pages.get(self.page_cursor)?; let header = OLD_GEN_PAGE_OBJECTS.with(|index| { From 9d235185de447c2aa8de062ab88b211b74979b66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 08:39:31 +0200 Subject: [PATCH 03/11] chore: point the deferral's cross-references at the assigned PR (#7624) Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- crates/perry-runtime/src/arena/allocators.rs | 4 +-- crates/perry-runtime/src/arena/page_meta.rs | 28 ++++++++++---------- crates/perry-runtime/src/arena/tests.rs | 10 +++---- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/crates/perry-runtime/src/arena/allocators.rs b/crates/perry-runtime/src/arena/allocators.rs index b78f0564fa..f20ff58989 100644 --- a/crates/perry-runtime/src/arena/allocators.rs +++ b/crates/perry-runtime/src/arena/allocators.rs @@ -117,7 +117,7 @@ pub(crate) fn arena_alloc_old_excluding_pages( /// `arena_alloc_gc_longlived` for the same shape on the longlived /// arena — only the backing region differs. /// -/// #7625: page registration is DEFERRED here (`defer_old_object_page_registration` +/// #7624: page registration is DEFERRED here (`defer_old_object_page_registration` /// rather than `register_old_object_pages`). This is the per-object old-gen /// birth path — since #7613's promote-on-first-copy it carries every promotion /// a copying minor makes, ~113 MB per json_pipeline run — and eager @@ -184,7 +184,7 @@ pub(crate) fn arena_alloc_gc_old_born_tenured(size: usize, align: usize, obj_typ user_ptr } -/// #7625: registration stays EAGER here, unlike `arena_alloc_gc_old`. This is +/// #7624: registration stays EAGER here, unlike `arena_alloc_gc_old`. This is /// old-page defrag's relocation allocator (`gc/oldgen.rs`'s /// `evacuate_selected_old_pages_collecting`), which runs from INSIDE /// `old_arena_walk_objects_on_pages`' callback — i.e. downstream of that diff --git a/crates/perry-runtime/src/arena/page_meta.rs b/crates/perry-runtime/src/arena/page_meta.rs index 0b86ea88cf..5cca5944d6 100644 --- a/crates/perry-runtime/src/arena/page_meta.rs +++ b/crates/perry-runtime/src/arena/page_meta.rs @@ -400,7 +400,7 @@ pub(crate) fn unregister_old_block_pages(pages: &[usize]) { if pages.is_empty() { return; } - // #7625 REMOVER: a deferred entry for one of these pages must be folded in + // #7624 REMOVER: a deferred entry for one of these pages must be folded in // BEFORE the page is dropped, or the flush would re-add it afterwards and // hand a later walk a header inside a recycled block. flush_deferred_old_page_registrations(); @@ -661,7 +661,7 @@ pub(crate) fn register_old_object_pages(header_addr: usize, total_size: usize) { } // --------------------------------------------------------------------------- -// #7625: deferred old-object page registration. +// #7624: deferred old-object page registration. // // `register_old_object_pages` above is written for the OCCASIONAL old-gen // birth it was introduced for. Per call it pays two `RefCell` borrows, two @@ -793,7 +793,7 @@ pub(crate) fn unregister_old_object_pages(header_addr: usize, total_size: usize) if header_addr == 0 || total_size == 0 { return; } - // #7625 REMOVER: see `unregister_old_block_pages`. Removing before the + // #7624 REMOVER: see `unregister_old_block_pages`. Removing before the // flush would leave the flush to resurrect this object. flush_deferred_old_page_registrations(); let overlaps = old_object_page_overlaps(header_addr, total_size); @@ -818,7 +818,7 @@ pub(crate) fn unregister_old_object_pages(header_addr: usize, total_size: usize) } pub(crate) fn old_pages_begin_gc_cycle() { - // #7625 CYCLE START: all three cycle constructors route through here + // #7624 CYCLE START: all three cycle constructors route through here // (`gc/mod.rs`'s minor, `gc/cycle.rs`'s `new_full`, `gc/policy.rs`'s // budgeted), so every collection begins with a complete page-objects index // and an EMPTY deferral buffer. @@ -834,7 +834,7 @@ pub(crate) fn old_pages_begin_gc_cycle() { } pub(crate) fn old_pages_reset_sweep_accounting() { - // #7625 READER (`OLD_GEN_PAGE_META`): closes the promote → sweep window + // #7624 READER (`OLD_GEN_PAGE_META`): closes the promote → sweep window // inside a full cycle. The per-object accounting that follows calls // `refresh_policy_bits`, which reads `allocated_bytes`; flushing here means // it never recomputes a page's bits from a count that is missing this @@ -933,7 +933,7 @@ pub(crate) fn old_page_account_dirty_slot(slot_addr: usize) { } pub(crate) fn old_page_summary() -> OldPageSummary { - // #7625 READER (`OLD_GEN_PAGE_META`): a deferred registration also owes + // #7624 READER (`OLD_GEN_PAGE_META`): a deferred registration also owes // this table an `allocated_bytes`/`object_count` update, so the summary // would under-report a mid-cycle promotion burst without the flush. flush_deferred_old_page_registrations(); @@ -981,7 +981,7 @@ pub(crate) fn old_page_summary() -> OldPageSummary { } pub(crate) fn old_page_meta_snapshot() -> Vec { - // #7625 READER (`OLD_GEN_PAGE_META`): this one drives real policy — + // #7624 READER (`OLD_GEN_PAGE_META`): this one drives real policy — // `gc/oldgen_defrag.rs` selects evacuation pages from it. flush_deferred_old_page_registrations(); let current_epoch = old_gen_page_dirty_epoch(); @@ -1046,7 +1046,7 @@ pub(crate) fn old_arena_walk_objects_on_pages( return 0; } - // #7625 READER: promotions land in old-gen mid-cycle (a copying minor's + // #7624 READER: promotions land in old-gen mid-cycle (a copying minor's // root scan runs before the remembered-set walk), so this cannot rely on // the cycle-start flush alone. flush_deferred_old_page_registrations(); @@ -1081,7 +1081,7 @@ pub(crate) struct OldArenaPageObjectCursor { impl OldArenaPageObjectCursor { pub(crate) fn new(pages: &crate::fast_hash::PtrHashSet) -> Self { - // #7625 READER: same obligation as `old_arena_walk_objects_on_pages`. + // #7624 READER: same obligation as `old_arena_walk_objects_on_pages`. // The cursor is stepped incrementally by the budgeted cycle, which // marks but never allocates into old-gen, so nothing can accumulate // between `new` and the last `next`; `next` debug-asserts that rather @@ -1095,13 +1095,13 @@ impl OldArenaPageObjectCursor { } pub(crate) fn next(&mut self) -> Option { - // #7625: `new` flushed; the stepping window must not re-dirty the + // #7624: `new` flushed; the stepping window must not re-dirty the // buffer, or this reader would be walking a stale index. Debug-only so // the per-object read costs nothing in a shipped collector. debug_assert!( DEFERRED_OLD_PAGE_REGISTRATIONS.with(|buf| buf.borrow().is_empty()), "an old-gen birth happened while a page-object cursor was stepping; \ - this reader is now walking a stale index (#7625)" + this reader is now walking a stale index (#7624)" ); loop { let page = *self.pages.get(self.page_cursor)?; @@ -1129,7 +1129,7 @@ pub(crate) fn old_arena_page_index_remove_object(header_addr: usize, total_size: if header_addr == 0 || total_size == 0 { return; } - // #7625 REMOVER: see `unregister_old_block_pages`. + // #7624 REMOVER: see `unregister_old_block_pages`. flush_deferred_old_page_registrations(); let overlaps = old_object_page_overlaps(header_addr, total_size); if overlaps.is_empty() { @@ -1182,7 +1182,7 @@ pub(crate) fn old_arena_page_index_clear_for_tests() { // Wiping page metadata makes real old-arena objects unclassifiable — // stand the #6179 differential verifier down for this thread's test. crate::gc::CLASSIFIER_VERIFY_SUPPRESSED.with(|c| c.set(true)); - // #7625: DISCARD rather than flush — a caller asking for an empty index + // #7624: DISCARD rather than flush — a caller asking for an empty index // would get a repopulated one if the pending burst were folded in first. DEFERRED_OLD_PAGE_REGISTRATIONS.with(|buf| buf.borrow_mut().clear()); OLD_GEN_PAGE_OBJECTS.with(|index| index.borrow_mut().clear()); @@ -1190,7 +1190,7 @@ pub(crate) fn old_arena_page_index_clear_for_tests() { #[cfg(test)] pub(crate) fn old_page_meta_for_tests(page: usize) -> Option { - // #7625 READER: same rule as `old_page_summary`/`old_page_meta_snapshot`, + // #7624 READER: same rule as `old_page_summary`/`old_page_meta_snapshot`, // so a test that allocates and then inspects a page sees what eager // registration would have left. flush_deferred_old_page_registrations(); diff --git a/crates/perry-runtime/src/arena/tests.rs b/crates/perry-runtime/src/arena/tests.rs index a8483110c6..c6ff1160a2 100644 --- a/crates/perry-runtime/src/arena/tests.rs +++ b/crates/perry-runtime/src/arena/tests.rs @@ -1170,7 +1170,7 @@ fn block_pool_is_per_thread_and_drops_with_its_thread() { } // --------------------------------------------------------------------------- -// #7625: deferred old-object page registration. +// #7624: deferred old-object page registration. // // `arena_alloc_gc_old` records its page registration in a thread-local buffer // instead of folding it into `OLD_GEN_PAGE_OBJECTS`/`OLD_GEN_PAGE_META` on the @@ -1255,7 +1255,7 @@ fn every_cycle_constructor_routes_through_the_flush_point() { src.contains("old_pages_begin_gc_cycle()"), "the {what} constructor in gc/{file} no longer calls \ old_pages_begin_gc_cycle(); deferred old-page registrations would \ - survive into the cycle unflushed (#7625)" + survive into the cycle unflushed (#7624)" ); } } @@ -1356,7 +1356,7 @@ fn removing_a_deferred_object_does_not_resurrect_it() { assert!( !visited_now(&pages).contains(&headers[0]), "a deferred entry removed before its flush was resurrected by the \ - flush — unregister_old_object_pages must flush first (#7625)" + flush — unregister_old_object_pages must flush first (#7624)" ); // 2. old_arena_page_index_remove_object @@ -1364,7 +1364,7 @@ fn removing_a_deferred_object_does_not_resurrect_it() { old_arena_page_index_remove_object(headers[1], 64); assert!( !visited_now(&pages).contains(&headers[1]), - "old_arena_page_index_remove_object must flush first (#7625)" + "old_arena_page_index_remove_object must flush first (#7624)" ); // 3. unregister_old_block_pages — the whole page goes away, and a @@ -1373,7 +1373,7 @@ fn removing_a_deferred_object_does_not_resurrect_it() { unregister_old_block_pages(&[generation_page_for_addr(headers[2])]); assert!( !visited_now(&pages).contains(&headers[2]), - "unregister_old_block_pages must flush first (#7625)" + "unregister_old_block_pages must flush first (#7624)" ); }); } From fdf910bb57a1a3bfaf9f3b6fa1f6527905fd0795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 09:01:50 +0200 Subject: [PATCH 04/11] docs(changelog): fragment for the old-page registration deferral (#7624) Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- .../7624-defer-old-page-registration.md | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 changelog.d/7624-defer-old-page-registration.md diff --git a/changelog.d/7624-defer-old-page-registration.md b/changelog.d/7624-defer-old-page-registration.md new file mode 100644 index 0000000000..90019035a8 --- /dev/null +++ b/changelog.d/7624-defer-old-page-registration.md @@ -0,0 +1,92 @@ +### Old-object page registration is deferred off the promote path (#7624) + +Extracted from #7623 per its audit: that PR's static-pretenure half was a +measurement confound and is not merging, but the `register_old_object_pages` +finding inside it stands alone — and pays on current `main`, with no codegen +change and no allocator-policy change. + +**The cost.** `register_old_object_pages` was written for the occasional +old-gen birth. Per object it pays two `RefCell` borrows, two `Vec` allocations, +a hash lookup, and a **linear `contains` scan of that page's object list** — +which grows as the page fills, so a burst of births into one 4 KiB page is +quadratic in the objects it lands there. Since **#7613's promote-on-first-copy** +that is no longer an occasional path: a copying minor promotes straight into +old-gen (`gc/copying.rs`'s `move_young` → `arena_alloc_gc_old`), so +json_pipeline pushes ~113 MB of promotions per run through it. + +**The change.** `arena_alloc_gc_old` records `(header_addr, total_size)` in a +thread-local buffer (`arena/page_meta.rs`); one batched flush folds the burst +in, holding a single borrow of each table, allocating no per-object `Vec`, and +scanning only the portion of a page's object list that **predates the batch**. +A bump-allocated promotion burst fills fresh pages, where that prefix is empty +and the dedup scan disappears. Skipping in-batch entries is sound because they +are pairwise distinct — an address cannot be handed out twice without an +intervening free, and no free happens without a flush; hole reuse, the reason +the dedup exists, hands back an address registered *before* the batch and is +still covered. + +Allocation policy is deliberately unchanged: the `old_free_take_exact` hole +probe stays. (#7623 also dropped it on its pretenure allocator; that is a +separate change with its own RSS consequences and is not here.) + +**Caller disposition.** + +| caller | disposition | why | +|---|---|---| +| `gc/copying.rs:614` promote (`arena_alloc_gc_old`) | **defer** | the target: per-object, ~113 MB/run since #7613 | +| `gc/oldgen.rs:1735` evacuate-tenured-nursery (`arena_alloc_gc_old`) | **defer** | per-object, same function | +| `typedarray`, `buffer`, `native_arena`, `json_tape` (via `arena_alloc_gc_old_born_tenured`), `arena_alloc_gc` large-object arm | **defer** | inherited; rare/large, so neither helped nor harmed, and one code path is easier to reason about than two | +| `gc/oldgen.rs:1843` defrag relocation (`arena_alloc_gc_old_excluding_pages`) | **eager** | rare; per-object cost dominated by the `copy_nonoverlapping` beside it; runs inside `old_arena_walk_objects_on_pages`' callback. Keeping it eager narrows the proof obligation | + +**Soundness — one rule.** Every reader **and every remover** of +`OLD_GEN_PAGE_OBJECTS` / `OLD_GEN_PAGE_META` flushes first. Both tables are +thread-locals private to `arena/page_meta.rs`, so the toucher set is closed and +the rule is checkable. + +Removers matter as much as readers, and that is the part that is easy to get +wrong: a removal that runs while an entry is still deferred is a **no-op**, and +the later flush then puts the dead object back — a resurrected index entry +pointing into swept or recycled memory. + +| flush site | kind | why it cannot rely on cycle start | +|---|---|---| +| `old_pages_begin_gc_cycle` | cycle start | all three constructors route through it (`gc/mod.rs` minor, `gc/cycle.rs` `new_full`, `gc/policy.rs` budgeted) | +| `old_arena_walk_objects_on_pages` | reader | a copying minor's root scan promotes **before** the remembered-set walk reads the index | +| `OldArenaPageObjectCursor::new` | reader | same index, incremental (budgeted) reader | +| `old_page_summary` | reader (`META`) | a deferred entry also owes `allocated_bytes`/`object_count` | +| `old_page_meta_snapshot` | reader (`META`) | drives `gc/oldgen_defrag.rs` page selection — real policy | +| `old_pages_reset_sweep_accounting` | reader (`META`) | closes the promote→sweep window inside a full cycle | +| `old_page_meta_for_tests` | reader (`META`) | keeps existing allocate-then-inspect tests honest | +| `unregister_old_object_pages` | remover | resurrection | +| `old_arena_page_index_remove_object` | remover | resurrection | +| `unregister_old_block_pages` | remover | resurrection into a recycled block | +| size cap (64k entries, 1 MB) | bound | the buffer cannot grow without a collection | +| `old_arena_page_index_clear_for_tests` | **discards** | a caller asking for an empty index must not get a repopulated one | + +Two consequences worth recording: + +- `classify_heap_generation` — every barrier remember-decision — reads the + **block-level** `PAGE_GENERATIONS` map, populated by `register_old_block_pages` + when a block is created. It never consults the object index, so it is + unchanged. (The #7623 audit reached the same conclusion for its shape; this + was re-verified for this caller set.) +- The per-object `META` writers (`old_page_account_swept_object`, + `old_page_account_promoted_object`) call `refresh_policy_bits()`, which reads + `allocated_bytes`. They can run while a registration is pending and therefore + recompute a bit from a stale count — but the flush itself calls + `refresh_policy_bits()` for every page it touches, and every reader flushes + first, so no reader can observe a stale bit. They stay flush-free so the + per-object sweep path pays nothing. + +**Tests.** Seven unit tests in `arena/tests.rs`, one per obligation, all +**sabotage-verified**: a harness removes one flush site at a time and requires +the matching test to go red — 9 cases, 9 caught. That includes "revert the +promote path to eager registration", which turns +`old_gen_birth_defers_its_page_registration` red, so a later refactor cannot +silently make this inert (the #7024/#6942 "gate whose subject never ran" +failure mode). `every_cycle_constructor_routes_through_the_flush_point` is the +second half of the cycle-start claim: one test proves +`old_pages_begin_gc_cycle` flushes, that one proves all three constructors +still call it. `every_old_gen_birth_path_sets_tenured` stays green. + + From 0bbdd0ce244d17e64f2b91b5e10bd525c74fd2ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 09:11:44 +0200 Subject: [PATCH 05/11] test(gc): make the flush-before-touch rule checkable, not remembered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-obligation tests pin the flush sites that exist today; they are blind to one added later. `deferred_registration_flush_sites` closes that: both page tables are thread-locals private to `page_meta.rs`, so the toucher set is enumerable from source, and every toucher must either flush or carry a written argument for why the deferral cannot be observed there. A stale exemption fails too — a name that no longer touches either table must be deleted — so the list cannot rot into blanket suppression, and the gate asserts it found at least ten touchers so a parser regression cannot make it vacuously green. It is not a hypothetical gate: on its first run it caught `OldArenaPageObjectCursor::next`, which is deliberately flush-free (its `new` flushes and the stepping window cannot re-fill the buffer, which `next` debug-asserts). That is now an exemption with the argument attached rather than an undocumented gap. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- crates/perry-runtime/src/arena/tests.rs | 175 ++++++++++++++++++++++++ 1 file changed, 175 insertions(+) diff --git a/crates/perry-runtime/src/arena/tests.rs b/crates/perry-runtime/src/arena/tests.rs index c6ff1160a2..acc46fa762 100644 --- a/crates/perry-runtime/src/arena/tests.rs +++ b/crates/perry-runtime/src/arena/tests.rs @@ -1180,6 +1180,181 @@ fn block_pool_is_per_thread_and_drops_with_its_thread() { // `flush_deferred_old_page_registrations()` call turns it red. // --------------------------------------------------------------------------- +/// The rule this whole family enforces, made checkable rather than remembered. +/// +/// The per-obligation tests below each pin ONE flush site, which is the right +/// shape for the sites that exist today — but they are blind to a site that +/// does not exist yet. A future edit that adds a function touching either table +/// gets no test, and the deferral silently starts being visible to it. This +/// closes that: both tables are thread-locals private to `page_meta.rs`, so the +/// toucher set is enumerable from the source, and every toucher must either +/// flush or appear below with a reason. +/// +/// A name in `EXEMPT` that no longer touches either table also fails, so a +/// removed function cannot leave a stale exemption behind (the shape +/// `gc_root_dominance_allowlist.json` uses). +#[test] +fn deferred_registration_flush_sites() { + // Every exemption is a claim about why the deferral cannot be observed. + const EXEMPT: &[(&str, &str)] = &[ + ( + "register_old_block_pages", + "creates zeroed per-page META entries when a BLOCK is registered; \ + reads no counter the deferral owes", + ), + ( + "update_old_page_meta_for_object", + "the flush's own target — it is what applies the batch", + ), + ( + "register_old_object_pages", + "the eager path itself; the flush calls its logic, and \ + arena_alloc_gc_old_excluding_pages still calls it directly", + ), + ( + "old_page_account_swept_object", + "per-object sweep writer. It calls refresh_policy_bits, which reads \ + allocated_bytes, but the flush refreshes every page it touches and \ + every READER flushes first, so no reader can observe a stale bit. \ + Kept flush-free so the sweep path pays nothing", + ), + ( + "old_page_account_promoted_object", + "as old_page_account_swept_object — per-object, same argument", + ), + ( + "old_page_account_dirty_slot", + "touches only dirty_slots/epoch, which no registration contributes to", + ), + ( + "old_page_mark_dirty", + "per-store barrier path; asks only whether a META entry exists, and \ + entries are created per page at BLOCK registration, not per object", + ), + ( + "old_page_clear_dirty", + "as old_page_mark_dirty — the dirty bit only", + ), + ( + "next", + "OldArenaPageObjectCursor::next. `new` flushes and the budgeted \ + stepping window marks without allocating into old-gen, so the \ + buffer cannot re-fill mid-walk; `next` debug-asserts exactly that \ + rather than paying a thread-local read per object", + ), + ( + "old_arena_page_index_clear_for_tests", + "DISCARDS the buffer instead: a caller asking for an empty index \ + must not get a repopulated one", + ), + ("defer_old_object_page_registration", "the producer"), + ( + "flush_deferred_old_page_registrations", + "the flush entry point", + ), + ( + "flush_deferred_old_page_registrations_batch", + "the flush body", + ), + ( + "deferred_old_page_registrations_len", + "test-only observer of the buffer, not of either table", + ), + ]; + + let src = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/arena/page_meta.rs"), + ) + .expect("page_meta.rs must be readable"); + + // Split into function bodies by tracking `fn ` headers at any indent. + let mut current: Option = None; + let mut bodies: Vec<(String, String)> = Vec::new(); + for line in src.lines() { + let trimmed = line.trim_start(); + if let Some(rest) = trimmed + .strip_prefix("pub(crate) fn ") + .or_else(|| trimmed.strip_prefix("pub fn ")) + .or_else(|| trimmed.strip_prefix("fn ")) + { + let name: String = rest + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + current = Some(name.clone()); + bodies.push((name, String::new())); + } + if current.is_some() { + if let Some(last) = bodies.last_mut() { + last.1.push_str(line); + last.1.push('\n'); + } + } + } + + let touches = |body: &str| { + body.contains("OLD_GEN_PAGE_OBJECTS.with") || body.contains("OLD_GEN_PAGE_META.with") + }; + let exempt_names: Vec<&str> = EXEMPT.iter().map(|(n, _)| *n).collect(); + + let mut offenders = Vec::new(); + let mut touching = std::collections::BTreeSet::new(); + for (name, body) in &bodies { + if !touches(body) { + continue; + } + touching.insert(name.clone()); + if body.contains("flush_deferred_old_page_registrations()") { + continue; + } + if exempt_names.contains(&name.as_str()) { + continue; + } + offenders.push(name.clone()); + } + + assert!( + offenders.is_empty(), + "these functions in arena/page_meta.rs read or mutate OLD_GEN_PAGE_OBJECTS / \ + OLD_GEN_PAGE_META without first calling flush_deferred_old_page_registrations(), \ + and are not listed as exempt: {offenders:?}.\n\ + A deferred registration is invisible to a reader that does not flush, and a \ + REMOVER that does not flush is worse — the removal no-ops and the later flush \ + resurrects the dead entry. Add the flush, or add the function to EXEMPT with \ + the argument for why the deferral cannot be observed there (#7624)." + ); + + // Stale exemptions fail too, so this list cannot rot into suppression. + let stale: Vec<&str> = exempt_names + .iter() + .copied() + .filter(|n| { + !touching.contains(*n) + && !matches!( + *n, + "defer_old_object_page_registration" + | "flush_deferred_old_page_registrations" + | "flush_deferred_old_page_registrations_batch" + | "deferred_old_page_registrations_len" + | "old_arena_page_index_clear_for_tests" + ) + }) + .collect(); + assert!( + stale.is_empty(), + "EXEMPT names nothing that touches either table any more: {stale:?}. \ + Delete the entry (#7624)." + ); + + // And the gate must be looking at something. + assert!( + touching.len() >= 10, + "only found {} functions touching the page tables — the parser above has \ + probably stopped matching, which would make this gate vacuous", + touching.len() + ); +} + /// A synthetic old-gen block plus `count` distinct in-range header addresses. /// Registration never dereferences a header, so fabricated addresses exercise /// the bookkeeping exactly as real ones do — and keep the test independent of From b21aa963ed8b4357a6ab657cb999f340f72f94c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 09:17:30 +0200 Subject: [PATCH 06/11] docs(changelog): record the flush-sites gate and its sabotage arms (#7624) Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- changelog.d/7624-defer-old-page-registration.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/changelog.d/7624-defer-old-page-registration.md b/changelog.d/7624-defer-old-page-registration.md index 90019035a8..9c66726953 100644 --- a/changelog.d/7624-defer-old-page-registration.md +++ b/changelog.d/7624-defer-old-page-registration.md @@ -78,7 +78,7 @@ Two consequences worth recording: first, so no reader can observe a stale bit. They stay flush-free so the per-object sweep path pays nothing. -**Tests.** Seven unit tests in `arena/tests.rs`, one per obligation, all +**Tests.** Seven per-obligation unit tests in `arena/tests.rs`, all **sabotage-verified**: a harness removes one flush site at a time and requires the matching test to go red — 9 cases, 9 caught. That includes "revert the promote path to eager registration", which turns @@ -89,4 +89,13 @@ second half of the cycle-start claim: one test proves `old_pages_begin_gc_cycle` flushes, that one proves all three constructors still call it. `every_old_gen_birth_path_sets_tenured` stays green. +Those seven pin the flush sites that exist *today*; they are blind to one added +later. `deferred_registration_flush_sites` closes that — it enumerates every +function in `page_meta.rs` touching either table and requires a flush or a +written exemption, and fails on a **stale** exemption too so the list cannot rot +into suppression. It is not hypothetical: on its first run it caught +`OldArenaPageObjectCursor::next` (deliberately flush-free, now exempt with the +argument attached). Both of its arms are sabotage-verified — a bogus exemption +and a newly added unflushed toucher each turn it red. + From a6de25f1995b0f1afdebcb19b0a4249df7413873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 09:34:12 +0200 Subject: [PATCH 07/11] perf(gc): make the flush allocate nothing (it was costing 31 MB of RSS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on the pinned mini, the first version of the batched flush cost **+31 MB peak RSS** on json_pipeline 500k — reproducibly, in all five interleaved rounds (1,110 MB → 1,142 MB). The cause was the flush itself, not the deferral: 4.1M promotions at a 64k cap is ~63 flushes, and each one `mem::take`d the pending buffer (so the next burst re-grew a ~1 MB `Vec` from empty) and staged its page-meta updates in a second ~1 MB `Vec` that was allocated and freed per batch. Both are now gone. The batch holds the `OLD_GEN_PAGE_OBJECTS` and `OLD_GEN_PAGE_META` borrows at once — distinct thread-local cells, so no aliasing — and applies each page's `allocated_bytes`/`object_count`/policy-bit update inline, which is exactly what `update_old_page_meta_for_object` did with the staging Vec. The pending buffer is cleared and handed back to its thread-local so the next burst refills something already 64k entries wide. A flush now allocates nothing. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- crates/perry-runtime/src/arena/page_meta.rs | 80 ++++++++++++++------- 1 file changed, 54 insertions(+), 26 deletions(-) diff --git a/crates/perry-runtime/src/arena/page_meta.rs b/crates/perry-runtime/src/arena/page_meta.rs index 5cca5944d6..8abf3b109d 100644 --- a/crates/perry-runtime/src/arena/page_meta.rs +++ b/crates/perry-runtime/src/arena/page_meta.rs @@ -740,45 +740,73 @@ pub(crate) fn flush_deferred_old_page_registrations() { /// without an intervening free, which cannot happen without a flush (every /// remover flushes). Hole reuse — the reason the dedup exists at all — hands /// back an address registered BEFORE the batch, so it is still covered. +/// The batch also holds BOTH table borrows at once and applies the +/// `OLD_GEN_PAGE_META` update inline rather than staging it in a `Vec`. The two +/// thread-locals are distinct cells, so there is no aliasing; the payoff is that +/// a flush allocates nothing at all. That is not a micro-optimisation: measured +/// on the pinned host, staging the updates and re-growing the pending buffer +/// once per batch cost **+31 MB peak RSS** on json_pipeline 500k (63 flushes, +/// each re-growing a ~1 MB `Vec` from empty and freeing a ~1 MB staging `Vec`), +/// which is a regression the deferral does not need to pay. #[cold] fn flush_deferred_old_page_registrations_batch() { - let pending = + let mut pending = DEFERRED_OLD_PAGE_REGISTRATIONS.with(|buf| std::mem::take(&mut *buf.borrow_mut())); if pending.is_empty() { return; } - let mut meta_updates: Vec<(usize, usize)> = Vec::with_capacity(pending.len()); OLD_GEN_PAGE_OBJECTS.with(|index| { let mut index = index.borrow_mut(); - // Entries arrive in allocation order, so consecutive ones share a page; - // cache that page's pre-batch length across the run. - let mut run_page: Option = None; - let mut run_base_len: usize = 0; - for &(header_addr, total_size) in &pending { - let object_end = header_addr + total_size; - let first_page = generation_page_for_addr(header_addr); - let last_page = generation_page_for_addr(object_end - 1); - for page in first_page..=last_page { - let page_base = generation_page_base(page); - let page_end = page_base + GENERATION_PAGE_SIZE; - let overlap_start = header_addr.max(page_base); - let overlap_end = object_end.min(page_end); - if overlap_start >= overlap_end { - continue; - } - let headers = index.entry(page).or_insert_with(Vec::new); - if run_page != Some(page) { - run_page = Some(page); - run_base_len = headers.len(); - } - if !headers[..run_base_len.min(headers.len())].contains(&header_addr) { + OLD_GEN_PAGE_META.with(|meta| { + let mut meta = meta.borrow_mut(); + // Entries arrive in allocation order, so consecutive ones share a + // page; cache that page's pre-batch length across the run. + let mut run_page: Option = None; + let mut run_base_len: usize = 0; + for &(header_addr, total_size) in &pending { + let object_end = header_addr + total_size; + let first_page = generation_page_for_addr(header_addr); + let last_page = generation_page_for_addr(object_end - 1); + for page in first_page..=last_page { + let page_base = generation_page_base(page); + let page_end = page_base + GENERATION_PAGE_SIZE; + let overlap_start = header_addr.max(page_base); + let overlap_end = object_end.min(page_end); + if overlap_start >= overlap_end { + continue; + } + let headers = index.entry(page).or_insert_with(Vec::new); + if run_page != Some(page) { + run_page = Some(page); + run_base_len = headers.len(); + } + if headers[..run_base_len.min(headers.len())].contains(&header_addr) { + continue; + } headers.push(header_addr); - meta_updates.push((page, overlap_end - overlap_start)); + // Identical to `update_old_page_meta_for_object(.., true)` + // for this one page, applied here so no staging Vec exists. + let page_meta = meta + .entry(page) + .or_insert_with(|| OldPageMeta::zero_for_page(page)); + page_meta.allocated_bytes = page_meta + .allocated_bytes + .saturating_add(overlap_end - overlap_start); + page_meta.object_count = page_meta.object_count.saturating_add(1); + page_meta.refresh_policy_bits(); } } + }); + }); + // Hand the allocation back rather than dropping it, so the next burst + // refills a buffer that is already 64k entries wide. + pending.clear(); + DEFERRED_OLD_PAGE_REGISTRATIONS.with(|buf| { + let mut buf = buf.borrow_mut(); + if buf.capacity() < pending.capacity() { + *buf = pending; } }); - update_old_page_meta_for_object(&meta_updates, true); } /// Entries awaiting a flush. Tests only — the buffer is an implementation From 0902ef991eb5ba7723a7f9508c574d8e4480f558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 09:45:32 +0200 Subject: [PATCH 08/11] perf(gc): size the deferral cap to the footprint it has to justify (64k -> 8k) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gc-ratchet's `pinned_host` profile priced the inherited 64k-entry cap: `11_collect_at_depth.rss_bytes` 34,652,160 -> 35,733,504, **+1,081,344 B — the 1 MB buffer, essentially exactly**. It was the ONLY regression row across all twelve probes on an arm whose GC counters were otherwise byte-identical to the baseline, and the base arm produced zero regression rows on the same host, so the attribution is unambiguous. Nothing wanted 64k. The cap exists to amortise the per-batch loop, and 8k does that ~8,000x; now that the flush is allocation-free the extra batches cost only the loop entry. 8k entries is 128 KB. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- crates/perry-runtime/src/arena/page_meta.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/perry-runtime/src/arena/page_meta.rs b/crates/perry-runtime/src/arena/page_meta.rs index 8abf3b109d..74d6b508fe 100644 --- a/crates/perry-runtime/src/arena/page_meta.rs +++ b/crates/perry-runtime/src/arena/page_meta.rs @@ -698,9 +698,22 @@ thread_local! { const { RefCell::new(Vec::new()) }; } -/// Cap chosen so the buffer's worst-case footprint (16 B/entry × 64k = 1 MB) -/// stays a rounding error while flushes stay rare on an allocation burst. -pub(crate) const DEFERRED_OLD_PAGE_REGISTRATION_CAP: usize = 65_536; +/// Bound on the buffer between flushes, and therefore on its resident +/// footprint: 16 B/entry × 8k = **128 KB**. +/// +/// This started at 64k entries (1 MB), inherited from #7623 where the buffer +/// backed a different shape. The `gc-ratchet` `pinned_host` profile priced that +/// choice: `11_collect_at_depth.rss_bytes` rose 34,652,160 → 35,733,504, i.e. +/// **+1,081,344 B — the buffer, essentially exactly** — and that one cell was +/// the only regression row across all twelve probes, on an arm whose GC +/// counters were otherwise byte-identical to the baseline. +/// +/// Nothing wanted 64k. The point of the cap is to amortise the per-batch loop +/// over many entries, and 8k already does that ~8,000×; since the flush is +/// allocation-free the extra batches cost only the loop entry. So the cap is +/// sized for the footprint it has to justify, not for the largest number that +/// still looked small. +pub(crate) const DEFERRED_OLD_PAGE_REGISTRATION_CAP: usize = 8_192; /// Record `header_addr`'s page registration for the next flush instead of /// performing it now. Callers must be old-gen births; see the module comment From 843d91ead47bef659dbf2b243c216e7b478fe3dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 09:56:03 +0200 Subject: [PATCH 09/11] =?UTF-8?q?docs:=20correct=20the=20RSS=20attribution?= =?UTF-8?q?=20=E2=80=94=20the=20ratchet=20row=20is=20NOT=20the=20deferral?= =?UTF-8?q?=20buffer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I cut the cap 64k -> 8k on the theory that `11_collect_at_depth.rss_bytes` (+3.1% on the `pinned_host` profile) WAS the 1 MB buffer. The re-measure disproves it: shrinking the buffer 8x moved the cell +16 KB in the WRONG direction (+1,081,344 B -> +1,097,728 B) when it should have shed ~0.9 MB. Two more facts point away from the deferral: that probe promotes ZERO objects, so this PR's path is inert on it, and the base arm produced zero regression rows on the same host in the same session, so it is not host drift. The remaining hypothesis, untested, is allocator segment granularity under a runtime ~10 KB larger. The constant's doc comment and the changelog fragment now say this outright rather than carrying the tidier claim I made first. The 8k cap stays because 128 KB beats 1 MB on its own terms, not because it fixed anything. `shared_ci` — the profile CI gates on — is OK on both arms. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- .../7624-defer-old-page-registration.md | 98 ++++++++++++++++++- crates/perry-runtime/src/arena/page_meta.rs | 23 +++-- 2 files changed, 109 insertions(+), 12 deletions(-) diff --git a/changelog.d/7624-defer-old-page-registration.md b/changelog.d/7624-defer-old-page-registration.md index 9c66726953..8c4c145b31 100644 --- a/changelog.d/7624-defer-old-page-registration.md +++ b/changelog.d/7624-defer-old-page-registration.md @@ -60,7 +60,7 @@ pointing into swept or recycled memory. | `unregister_old_object_pages` | remover | resurrection | | `old_arena_page_index_remove_object` | remover | resurrection | | `unregister_old_block_pages` | remover | resurrection into a recycled block | -| size cap (64k entries, 1 MB) | bound | the buffer cannot grow without a collection | +| size cap (8k entries, 128 KB) | bound | the buffer cannot grow without a collection | | `old_arena_page_index_clear_for_tests` | **discards** | a caller asking for an empty index must not get a repopulated one | Two consequences worth recording: @@ -98,4 +98,98 @@ into suppression. It is not hypothetical: on its first run it caught argument attached). Both of its arms are sabotage-verified — a bogus exemption and a newly added unflushed toucher each turn it red. - +## Measured — pinned quiet host (`perry-macos`, M1 mini, load ~1.3) + +Both arms `perry-dev`, identical package set, one target dir each. Workloads +compiled on the dev Mac with `PERRY_NO_AUTO_OPTIMIZE=1` and the **prebuilt +executables shipped** to the mini, so nothing was rebuilt on the measurement +host. Run only after the owner's `run_public_baseline` had exited. 5 rounds, +base/fix interleaved within each round, every row hash-verified. + +### json_pipeline (medians of 5) + +| | base | fix | Δ | +|---|--:|--:|--:| +| 200k wall | 1.64 s | **1.56 s** | **−4.9%** | +| 200k user CPU | 1.58 s | **1.50 s** | **−5.1%** | +| 200k peak RSS | 489.0 MB | **471.1 MB** | **−3.7%** | +| 500k wall | 4.36 s | **4.18 s** | **−4.1%** | +| 500k user CPU | 4.21 s | **4.02 s** | **−4.5%** | +| 500k peak RSS | 1,110.4 MB | 1,114.7 MB | +0.4% | + +Fix is faster in every paired round at both sizes. (The one 200k `fix` row +reading 2.11 s is round 1 only, first touch of a freshly rsync'd binary — its +*user* CPU is 1.54 s, i.e. normal; it is I/O, not compute, and it is left in +the raw log rather than dropped.) + +Output hashes identical at both sizes, and the **GC census is identical** at +both sizes — same cycles, same `promoted_objects`/`promoted_bytes`, same sweep +and reclaim. That is the check that this is bookkeeping and not a behaviour +change: 200k promotes 1,657,962 objects / 113,226,896 bytes and 500k promotes +4,117,011 / 280,996,760, all through the path this PR touches, and none of it +moves. + +### gc bench set (medians of 5, `gc-handoff/bench`) + +| workload | wall Δ | RSS Δ | +|---|--:|--:| +| retain | **−4.2%** | −0.6% | +| retain1 | **−4.3%** | **−6.6%** | +| deeplist | −2.3% | +1.2% | +| tree | −0.5% | −2.4% | +| churn / churn_alloc / churn_num / churn_read / push_num | 0.0% | +0.1…+1.3% | +| cycles | +1.0% | +1.0% | +| push_cls | +2.5% | +0.4% | + +All eleven produce byte-identical stdout. The wins land where the mechanism +predicts — `retain`/`retain1`/`tree`/`deeplist` are the promote-heavy ones. The +two small positives are at the 10 ms resolution of `/usr/bin/time` on 0.4 s and +1.0 s workloads. + +### What the measurement changed in the patch + +Both are recorded because they are the reason the final numbers look the way +they do — and because one of them is a correction to a claim I made first. + +1. **+31 MB peak RSS at 500k** (1,110 → 1,142 MB, reproducibly, all 5 rounds). + Not the deferral — the *flush*: ~63 flushes per run, each `mem::take`ing the + pending buffer so the next burst re-grew a ~1 MB `Vec` from empty, plus a + second ~1 MB staging `Vec` for the page-meta updates, allocated and freed per + batch. The flush now holds both table borrows at once and applies the meta + update inline, and hands the pending buffer back to its thread-local. **A + flush allocates nothing.** +2. **The 64k-entry cap** (1 MB resident) was inherited from #7623, where the + buffer backed a different shape. Cut to **8,192 entries (128 KB)** — it still + amortises the per-batch loop ~8,000×, and with an allocation-free flush the + extra batches cost only the loop entry. This was done believing it would + clear the `gc-ratchet` row below; it did not, and that is written up there + rather than quietly dropped. + +### gc-ratchet (the #7609 baseline), both arms, same session + +Both arms measured in the same session on the pinned host, `measure --repeats 7` +then `check` on both profiles. + +- **`shared_ci`** (the profile CI gates on): **OK on both arms.** +- **`pinned_host`** (stricter — also gates memory and time): base **0 regression + rows**; fix **1**, `11_collect_at_depth.rss_bytes` 34,652,160 → 35,749,888 + (**+3.17%**, band 1,039,565). + +Every GC counter on every probe is `+0.00%` on both arms — `copied_objects`, +`copied_bytes`, `promoted_objects`, `promoted_bytes`, `freed_bytes`, +`minor_cycles`, `step_cycles`, `heap_used_bytes`, `heap_total_bytes` — which is +the same "bookkeeping only" result the census gives, reproduced by an +independent harness across twelve probes. + +**The one open row, stated honestly.** I first attributed it to the deferral +buffer and cut the cap 64k → 8k to remove it. **That was wrong, and the +measurement says so**: the cell did not move (+1,081,344 B at a 1 MB buffer → ++1,097,728 B at a 128 KB buffer — it should have shed ~0.9 MB). Two further +facts point away from the deferral: `11_collect_at_depth` promotes **zero** +objects, so this PR's path is inert on it, and the base arm produced zero +regression rows on the same host in the same session, so it is not host drift +either. The remaining hypothesis — untested — is allocator segment granularity +shifting under a runtime ~10 KB larger. Flagged rather than explained away; it +does not affect `shared_ci`, and the 8k cap is kept because 128 KB is better +than 1 MB on its own terms, not because it fixed this. + diff --git a/crates/perry-runtime/src/arena/page_meta.rs b/crates/perry-runtime/src/arena/page_meta.rs index 74d6b508fe..4f8ca2c783 100644 --- a/crates/perry-runtime/src/arena/page_meta.rs +++ b/crates/perry-runtime/src/arena/page_meta.rs @@ -702,17 +702,20 @@ thread_local! { /// footprint: 16 B/entry × 8k = **128 KB**. /// /// This started at 64k entries (1 MB), inherited from #7623 where the buffer -/// backed a different shape. The `gc-ratchet` `pinned_host` profile priced that -/// choice: `11_collect_at_depth.rss_bytes` rose 34,652,160 → 35,733,504, i.e. -/// **+1,081,344 B — the buffer, essentially exactly** — and that one cell was -/// the only regression row across all twelve probes, on an arm whose GC -/// counters were otherwise byte-identical to the baseline. +/// backed a different shape. Nothing here wanted 64k: the cap exists to amortise +/// the per-batch loop, 8k already does that ~8,000×, and since the flush is +/// allocation-free the extra batches cost only the loop entry. So it is sized +/// for the footprint it has to justify. /// -/// Nothing wanted 64k. The point of the cap is to amortise the per-batch loop -/// over many entries, and 8k already does that ~8,000×; since the flush is -/// allocation-free the extra batches cost only the loop entry. So the cap is -/// sized for the footprint it has to justify, not for the largest number that -/// still looked small. +/// It was reduced while chasing the one `gc-ratchet` `pinned_host` regression +/// row this change produces — `11_collect_at_depth.rss_bytes`, ~+1.07 MB — on +/// the theory that the row WAS this buffer. **That theory is disproved and the +/// row is not this constant's fault**: shrinking the cap 8× (1 MB → 128 KB) +/// moved the cell by +16 KB in the wrong direction (+1,081,344 B → +1,097,728 B) +/// when it should have shed ~0.9 MB. That probe also promotes **zero** objects, +/// so this path is inert there. See the changelog fragment for the open +/// question. The smaller cap is kept because it is better on its own terms, +/// not because it fixed anything. pub(crate) const DEFERRED_OLD_PAGE_REGISTRATION_CAP: usize = 8_192; /// Record `header_addr`'s page registration for the next flush instead of From 5c81c11f3c923cca3ad4ed725732eb0c0f7d1103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 11:24:57 +0200 Subject: [PATCH 10/11] docs: replace the contaminated A/B with clean-host numbers, and resolve the RSS row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MEASUREMENT CORRECTION. The previous wall/user/RSS table was taken while a second `run_public_baseline` was executing on the mini. I had checked once, seen the first baseline's `SCRIPT REAL EXIT=1`, and treated "idle" as a durable property; a new run started 16 minutes later and overlapped the measurement. The re-run gates on all three of: no baseline process, a `SCRIPT REAL EXIT=` marker, and 1-min load < 2.0 — and re-checks all three afterwards (post-run: OK, load 1.97, no baseline started mid-run). Clean numbers are SMALLER than the contaminated ones, and much tighter: json 200k wall -3.9% (was -4.9%), 500k -3.7% (was -4.1%), all 20 paired deltas negative. Base 200k spread went from 7% to 0.7%. The contaminated table's 200k RSS "win" of -3.7% was noise; it is -0.5%. Deltas are now medians of PAIRED per-round deltas. `cycles` is bimodal in both arms, so median-of-medians reported +18.5% where the paired statistic is +0.0% — the interleaving exists precisely to support the paired read. RSS ROW RESOLVED. `11_collect_at_depth.rss_bytes` was flagged as an unexplained ~+1.07 MB with an untested allocator-granularity hypothesis. Measuring `origin/main` on the same idle host answers it: base reads 35,651,584 there (+2.88% over the pinned artifact, just under the band) vs fix's 35,749,888, so fix is +98 KB over base, not +1.07 MB. Base independently fails ten other `pinned_host` RSS cells, because the artifact is pinned at 0.5.1346 and we are at 0.5.1355. Both arms pass `shared_ci`, which is what CI gates. fix vs base across all 144 ratchet cells: 107 of 108 GC-semantic cells byte-identical (the exception is the de-gated, sample-dependent `12_large_live_set.heap_used_bytes`, differing by less than a quarter of its documented spread); memory median +0.23%; wall median +0.0%. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- .../7624-defer-old-page-registration.md | 189 ++++++++++-------- crates/perry-runtime/src/arena/page_meta.rs | 26 ++- 2 files changed, 126 insertions(+), 89 deletions(-) diff --git a/changelog.d/7624-defer-old-page-registration.md b/changelog.d/7624-defer-old-page-registration.md index 8c4c145b31..ec54d56042 100644 --- a/changelog.d/7624-defer-old-page-registration.md +++ b/changelog.d/7624-defer-old-page-registration.md @@ -98,98 +98,127 @@ into suppression. It is not hypothetical: on its first run it caught argument attached). Both of its arms are sabotage-verified — a bogus exemption and a newly added unflushed toucher each turn it red. -## Measured — pinned quiet host (`perry-macos`, M1 mini, load ~1.3) +## Measured — pinned quiet host (`perry-macos`, M1 mini) + +> An earlier revision of this description carried a wall/user/RSS table taken +> while a second `run_public_baseline` was concurrently executing on the mini. +> **That table is superseded by this one.** The GC census rows were never +> affected — they are load-independent — and are unchanged. Both arms `perry-dev`, identical package set, one target dir each. Workloads compiled on the dev Mac with `PERRY_NO_AUTO_OPTIMIZE=1` and the **prebuilt executables shipped** to the mini, so nothing was rebuilt on the measurement -host. Run only after the owner's `run_public_baseline` had exited. 5 rounds, -base/fix interleaved within each round, every row hash-verified. +host. 5 rounds, base/fix interleaved within each round, every row hash-verified. + +**Idleness was gated, not assumed.** The run waits for all three of: no +`run_public_baseline` process, a `SCRIPT REAL EXIT=` marker in +`/tmp/baseline_mini.log`, and 1-min load < 2.0 — then settles 60 s, measures, +and **re-checks all three afterwards**. Load recorded by the A/B itself at its +own start: `1.62 2.04 2.45`. -### json_pipeline (medians of 5) +Deltas are **medians of per-round paired deltas**, which is the statistic the +interleaving exists to support; see the `cycles` note below for why +median-of-medians is not safe here. -| | base | fix | Δ | +### json_pipeline + +| | base | fix | Δ (paired) | |---|--:|--:|--:| -| 200k wall | 1.64 s | **1.56 s** | **−4.9%** | -| 200k user CPU | 1.58 s | **1.50 s** | **−5.1%** | -| 200k peak RSS | 489.0 MB | **471.1 MB** | **−3.7%** | -| 500k wall | 4.36 s | **4.18 s** | **−4.1%** | -| 500k user CPU | 4.21 s | **4.02 s** | **−4.5%** | +| 200k wall | 1.53 s | **1.47 s** | **−3.9%** | +| 200k user CPU | 1.48 s | **1.42 s** | **−4.1%** | +| 200k peak RSS | 489.0 MB | **486.8 MB** | −0.5% | +| 500k wall | 4.09 s | **3.94 s** | **−3.7%** | +| 500k user CPU | 3.95 s | **3.80 s** | **−3.8%** | | 500k peak RSS | 1,110.4 MB | 1,114.7 MB | +0.4% | -Fix is faster in every paired round at both sizes. (The one 200k `fix` row -reading 2.11 s is round 1 only, first touch of a freshly rsync'd binary — its -*user* CPU is 1.54 s, i.e. normal; it is I/O, not compute, and it is left in -the raw log rather than dropped.) +**All 20 paired json deltas are negative** — 200k wall −4.5/−3.9/−4.6/−3.9/−3.9%, +500k wall −3.9/−3.7/−3.4/−3.7/−3.9%. Output hashes identical at both sizes. -Output hashes identical at both sizes, and the **GC census is identical** at -both sizes — same cycles, same `promoted_objects`/`promoted_bytes`, same sweep -and reclaim. That is the check that this is bookkeeping and not a behaviour -change: 200k promotes 1,657,962 objects / 113,226,896 bytes and 500k promotes -4,117,011 / 280,996,760, all through the path this PR touches, and none of it -moves. +``` +200k base real 1.54 1.53 1.53 1.53 1.53 fix 1.47 1.47 1.46 1.47 1.47 +500k base real 4.10 4.09 4.09 4.09 4.11 fix 3.94 3.94 3.95 3.94 3.95 +``` -### gc bench set (medians of 5, `gc-handoff/bench`) +**The clean host both shrank the effect and shrank the noise.** Base 200k wall +now spans 1.53–1.54 s (0.7%) where under the concurrent baseline it spanned +1.63–1.75 s (7%). The honest win is **smaller** than the superseded table +claimed (−3.9%/−3.7% vs −4.9%/−4.1%), and that table's 200k RSS "win" (−3.7%) +was noise — it is −0.5% here. -| workload | wall Δ | RSS Δ | -|---|--:|--:| -| retain | **−4.2%** | −0.6% | -| retain1 | **−4.3%** | **−6.6%** | -| deeplist | −2.3% | +1.2% | -| tree | −0.5% | −2.4% | -| churn / churn_alloc / churn_num / churn_read / push_num | 0.0% | +0.1…+1.3% | -| cycles | +1.0% | +1.0% | -| push_cls | +2.5% | +0.4% | +### gc bench set (`gc-handoff/bench`) + +| workload | wall Δ (paired) | RSS Δ | output | +|---|--:|--:|---| +| retain1 | **−4.6%** | **−6.6%** | identical | +| retain | **−4.5%** | −0.6% | identical | +| churn_alloc | −2.5% | +0.3% | identical | +| deeplist | −2.4% | +1.6% | identical | +| churn / churn_read / churn_num / push_cls / push_num / cycles | 0.0% | +0.0…+1.3% | identical | +| tree | +0.8% | −2.2% | identical | All eleven produce byte-identical stdout. The wins land where the mechanism -predicts — `retain`/`retain1`/`tree`/`deeplist` are the promote-heavy ones. The -two small positives are at the 10 ms resolution of `/usr/bin/time` on 0.4 s and -1.0 s workloads. - -### What the measurement changed in the patch - -Both are recorded because they are the reason the final numbers look the way -they do — and because one of them is a correction to a claim I made first. - -1. **+31 MB peak RSS at 500k** (1,110 → 1,142 MB, reproducibly, all 5 rounds). - Not the deferral — the *flush*: ~63 flushes per run, each `mem::take`ing the - pending buffer so the next burst re-grew a ~1 MB `Vec` from empty, plus a - second ~1 MB staging `Vec` for the page-meta updates, allocated and freed per - batch. The flush now holds both table borrows at once and applies the meta - update inline, and hands the pending buffer back to its thread-local. **A - flush allocates nothing.** -2. **The 64k-entry cap** (1 MB resident) was inherited from #7623, where the - buffer backed a different shape. Cut to **8,192 entries (128 KB)** — it still - amortises the per-batch loop ~8,000×, and with an allocation-free flush the - extra batches cost only the loop entry. This was done believing it would - clear the `gc-ratchet` row below; it did not, and that is written up there - rather than quietly dropped. - -### gc-ratchet (the #7609 baseline), both arms, same session - -Both arms measured in the same session on the pinned host, `measure --repeats 7` -then `check` on both profiles. - -- **`shared_ci`** (the profile CI gates on): **OK on both arms.** -- **`pinned_host`** (stricter — also gates memory and time): base **0 regression - rows**; fix **1**, `11_collect_at_depth.rss_bytes` 34,652,160 → 35,749,888 - (**+3.17%**, band 1,039,565). - -Every GC counter on every probe is `+0.00%` on both arms — `copied_objects`, -`copied_bytes`, `promoted_objects`, `promoted_bytes`, `freed_bytes`, -`minor_cycles`, `step_cycles`, `heap_used_bytes`, `heap_total_bytes` — which is -the same "bookkeeping only" result the census gives, reproduced by an -independent harness across twelve probes. - -**The one open row, stated honestly.** I first attributed it to the deferral -buffer and cut the cap 64k → 8k to remove it. **That was wrong, and the -measurement says so**: the cell did not move (+1,081,344 B at a 1 MB buffer → -+1,097,728 B at a 128 KB buffer — it should have shed ~0.9 MB). Two further -facts point away from the deferral: `11_collect_at_depth` promotes **zero** -objects, so this PR's path is inert on it, and the base arm produced zero -regression rows on the same host in the same session, so it is not host drift -either. The remaining hypothesis — untested — is allocator segment granularity -shifting under a runtime ~10 KB larger. Flagged rather than explained away; it -does not affect `shared_ci`, and the 8k cap is kept because 128 KB is better -than 1 MB on its own terms, not because it fixed this. +predicts — `retain`/`retain1`/`deeplist` are the promote-heavy ones. + +> **`cycles` is reported at +0.0%, not +18.5%.** Median-of-medians says +18.5%; +> that is an artifact. The workload is **bimodal in both arms** (rounds 1–3 +> ≈ 0.79 s, rounds 4–5 ≈ 0.96 s), so the two arms' medians land on different +> modes. The paired deltas are 0.00 in three of five rounds and the run-1 +> outlier is the whole difference. This is exactly what interleaving is for, and +> it is why every number above is a paired statistic. + +### GC census — identical, and load-independent + +`CENSUS 200k IDENTICAL`, `CENSUS 500k IDENTICAL`: same cycle sequence, same +`promoted_objects`/`promoted_bytes`, same sweep and reclaim. 200k promotes +**1,657,962 objects / 113,226,896 bytes**; 500k promotes **4,117,011 / +280,996,760** — all through the path this PR touches, none of it moving. + +### gc-ratchet (the #7609 baseline), both arms, clean host + +Both arms measured back-to-back in the same session on the clean host, +`measure --repeats 7`, then `check` on both profiles. 144 cells per arm. + +| | base (`origin/main`) | fix | +|---|---|---| +| `shared_ci` (what CI gates on) | **OK** | **OK** | +| `pinned_host` | **FAILED**, 10 regression rows | FAILED, 15 rows | + +**Read the base column first.** Pure `origin/main` fails `pinned_host` on this +host with ten RSS rows of its own (`03_cross_gen_writes` +3.83%, +`08_map_set_sidetables` +4.20%, `04_dead_after_deep_stack` +3.72%, …). The +pinned artifact was captured at `main 26b9c9d59` (0.5.1346) and we are at +0.5.1355, so the profile's RSS bands no longer describe this host/version. +**"fix fails `pinned_host`" is therefore not a statement about this PR** — the +only sound comparison is base vs fix in the same session, which is what follows. + +**fix vs base, all 144 cells:** + +- **GC semantics: 107 of 108 cells byte-identical.** The single exception is + `12_large_live_set.heap_used_bytes` (59,946,104 → 59,944,160, −1,944 B) — the + one cell the harness explicitly de-gates by probe override because it is + conservative-stack-scan sample-dependent, with a documented spread of 9,072 B + over 36 runs. The difference is under a quarter of that spread. Every + `copied_*`, `promoted_*`, `freed_bytes`, `minor_cycles`, `step_cycles` and + `heap_total_bytes` cell is identical. +- **Memory: 24 cells, median fix-vs-base +0.23%**, range −0.30% to +1.46% + (largest: `07_array_grow_evacuate.peak_rss_bytes` +1.46%). +- **Wall: 12 cells, median fix-vs-base +0.0%.** These probes are microbenchmarks + where the deferral has almost nothing to do; the promote-heavy work is + json_pipeline's. + +**And this retires the open question from the earlier revision.** I had flagged +`11_collect_at_depth.rss_bytes` as an unexplained ~+1.07 MB, with "allocator +segment granularity" as an untested hypothesis. Measuring **base on the same +clean host** answers it: + +| | `11_collect_at_depth.rss_bytes` | vs pinned artifact | +|---|--:|--:| +| pinned baseline (0.5.1346) | 34,652,160 | — | +| **base arm = pure `origin/main`** | 35,651,584 | **+2.88%** (ok — just under the 1,039,565 band) | +| fix arm | 35,749,888 | +3.17% (REGRESSION — just over) | + +**fix is +98,304 B (+0.28%) above base, not +1.07 MB.** Base already sat at 96% +of the allowance, so the cell tips over on a rounding-scale difference. The row +is ~91% pre-existing drift in `origin/main` and ~9% this PR. No allocator-granularity +story is needed, and the one I floated should be disregarded. diff --git a/crates/perry-runtime/src/arena/page_meta.rs b/crates/perry-runtime/src/arena/page_meta.rs index 4f8ca2c783..ae9c7cee9f 100644 --- a/crates/perry-runtime/src/arena/page_meta.rs +++ b/crates/perry-runtime/src/arena/page_meta.rs @@ -707,15 +707,23 @@ thread_local! { /// allocation-free the extra batches cost only the loop entry. So it is sized /// for the footprint it has to justify. /// -/// It was reduced while chasing the one `gc-ratchet` `pinned_host` regression -/// row this change produces — `11_collect_at_depth.rss_bytes`, ~+1.07 MB — on -/// the theory that the row WAS this buffer. **That theory is disproved and the -/// row is not this constant's fault**: shrinking the cap 8× (1 MB → 128 KB) -/// moved the cell by +16 KB in the wrong direction (+1,081,344 B → +1,097,728 B) -/// when it should have shed ~0.9 MB. That probe also promotes **zero** objects, -/// so this path is inert there. See the changelog fragment for the open -/// question. The smaller cap is kept because it is better on its own terms, -/// not because it fixed anything. +/// It was reduced while chasing a `gc-ratchet` `pinned_host` row — +/// `11_collect_at_depth.rss_bytes`, ~+1.07 MB above the pinned artifact — on the +/// theory that the row WAS this buffer. Two measurements later that theory is +/// dead twice over, and the cap had nothing to do with it: +/// +/// 1. Shrinking the cap 8× (1 MB → 128 KB) moved the cell +16 KB in the WRONG +/// direction when it should have shed ~0.9 MB. The probe also promotes +/// **zero** objects, so this path is inert on it. +/// 2. Measuring **`origin/main` itself** on the same idle host settled it: +/// base reads 35,651,584 on that cell (+2.88% over the pinned artifact, just +/// under the band) against fix's 35,749,888. **fix is +98 KB over base, not +/// +1.07 MB** — the row is ~91% pre-existing drift between the artifact +/// (pinned at 0.5.1346) and current `main`, and base fails ten other +/// `pinned_host` RSS cells on its own. +/// +/// The smaller cap is kept because 128 KB beats 1 MB on its own terms, not +/// because it fixed anything. pub(crate) const DEFERRED_OLD_PAGE_REGISTRATION_CAP: usize = 8_192; /// Record `header_addr`'s page registration for the next flush instead of From 73e772617bda46f719f1908859de2c7bad2f0b74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 11:58:35 +0200 Subject: [PATCH 11/11] chore(version): bump to 0.5.1360 --- CLAUDE.md | 2 +- Cargo.lock | 152 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f38f97c480..068651578d 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.1359 +**Current Version:** 0.5.1360 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 1a506dc0ff..14da3dc9fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1359" +version = "0.5.1360" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1359" +version = "0.5.1360" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1359" +version = "0.5.1360" [[package]] name = "perry-ui-tvos" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1359" +version = "0.5.1360" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index cc5d306f7d..4aecd2ec52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1359" +version = "0.5.1360" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"