Skip to content

perf(gc): defer old-object page registration off the promote path (from #7623) - #7624

Merged
proggeramlug merged 11 commits into
mainfrom
perf/old-page-registration-deferral
Aug 8, 2026
Merged

perf(gc): defer old-object page registration off the promote path (from #7623)#7624
proggeramlug merged 11 commits into
mainfrom
perf/old-page-registration-deferral

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Extracted from #7623 per its audit. That PR's static-pretenure half was a
measurement confound and is not merging; this is the finding buried inside it
that stands on its own — and unlike the pretenure half, it pays on current
main
, with no new codegen and no new allocator policy.

The cost

register_old_object_pages is written for the occasional old-gen birth it was
introduced for. Per object it pays:

  • two RefCell borrows (OLD_GEN_PAGE_OBJECTS, then OLD_GEN_PAGE_META),
  • two Vec allocations (old_object_page_overlaps, added_pages),
  • a hash lookup, and
  • a linear contains scan of that page's object list, which grows as the
    page fills — quadratic in the objects a burst lands on one 4 KiB page.

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), and PERRY_GC_DIAG puts a number on it — json_pipeline
200k promotes 1,657,956 objects / 113,226,480 bytes in a single minor, every
one through that function. That is the same ~113 MB the #7623 audit measured as
the moved cohort, now attributed to the code path that bills for it.

The change

arena_alloc_gc_old records (header_addr, total_size) in a thread-local
buffer; one batched flush folds the whole burst in. The flush holds a single
borrow of each table, allocates no per-object Vec, and — the part that
removes the quadratic term — scans 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 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. Hole reuse — the
reason the dedup exists at all — hands back an address registered before the
batch, so it is still covered (batched_flush_matches_eager_registration pins
exactly that case).

Allocation policy is deliberately unchanged. #7623 also dropped the
old_free_take_exact hole probe on its pretenure allocator; that is a policy
change with its own RSS consequences and it is not here. This PR is bookkeeping
only — which is why the GC census can be, and is, asserted byte-identical.

Soundness: one rule, and removers matter as much as readers

Every reader and every remover of OLD_GEN_PAGE_OBJECTS /
OLD_GEN_PAGE_META flushes first.

Readers alone would not be enough. 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. That is the
failure mode removing_a_deferred_object_does_not_resurrect_it exists for.

Both tables are thread-locals private to arena/page_meta.rs, so the toucher
set is closed and the rule is checkable rather than remembered:
deferred_registration_flush_sites enumerates every function in that file that
touches either table and requires it to flush or to carry a written argument
for why the deferral cannot be observed there. Stale exemptions fail too. It is
not hypothetical — on its first run it caught OldArenaPageObjectCursor::next.

Full per-site and per-caller tables are in the changelog fragment. Two points
worth stating here:

  • classify_heap_generation — every barrier remember-decision — reads the
    block-level PAGE_GENERATIONS map, which register_old_block_pages
    populates when a block is created. It never consults the object index, so it
    is unaffected. (Same conclusion the gc: pretenure-accumulator admission infrastructure (#7598, scope-reduced per audit) #7623 audit reached for its shape;
    re-verified for this caller set.)
  • arena_alloc_gc_old_excluding_pages (old-page defrag relocation) stays
    eager. Deferring would be sound, but it is rare, its per-object cost is
    dominated by the copy_nonoverlapping beside it, and keeping it eager
    narrows the proof obligation to the one path that measurably needs it.

Evidence

Eight unit tests, one per obligation plus the source-level gate. All
sabotage-verified: a harness removes one flush site at a time and requires
the matching test to go red — 9 cases, 9 caught, including "revert the
promote path to eager registration", so a later refactor cannot silently make
this PR inert (the #7024/#6942 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.

The GC census is byte-identical between arms (same cycles, same
promoted_objects/promoted_bytes, same sweep and reclaim), output hashes
match at 200k and 500k, and all 11 gc-bench workloads produce byte-identical
stdout. GC zeal + from-space protection pass on both arms with the instrument
provably live.

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

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.

json_pipeline

base fix Δ (paired)
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%

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.

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

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.

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

Not in scope

Summary by CodeRabbit

  • Performance
    • Improved garbage-collection performance by batching old-generation object page registration.
    • Preserved existing allocation, reuse, and collection behavior while reducing redundant bookkeeping work.
  • Bug Fixes
    • Ensured page metadata is synchronized before removals, collection operations, and object traversal.
  • Tests
    • Added comprehensive coverage for deferred registration, flushing boundaries, metadata consistency, and duplicate handling.
  • Documentation
    • Documented the registration strategy, safety requirements, and benchmark results.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Old-generation allocation now defers page registration into a bounded buffer. Flushes update page indexes before GC operations, metadata reads, traversal, and removals. Tests cover capacity limits, lifecycle boundaries, cleanup ordering, deduplication, and eager-registration equivalence.

Changes

Old-page registration lifecycle

Layer / File(s) Summary
Defer old-generation allocation registration
crates/perry-runtime/src/arena/allocators.rs, crates/perry-runtime/src/arena/mod.rs, changelog.d/...
Old-generation allocations defer page registration. Defragmentation allocation remains eager. The API and test constants are re-exported.
Flush deferred registrations at lifecycle boundaries
crates/perry-runtime/src/arena/page_meta.rs, changelog.d/...
A bounded thread-local buffer batches and deduplicates registrations. Flushes occur before GC operations, metadata reads, traversal, cursor creation, and removals.
Validate batching and cleanup behavior
crates/perry-runtime/src/arena/tests.rs
Tests cover deferred allocation, capacity-triggered flushes, cycle constructors, index readers, removal ordering, source inspection, deduplication, and eager-registration equivalence.
Record registration measurements and invariants
changelog.d/...
The changelog records benchmark results, allocation behavior, metadata invariants, and RSS observations.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OldAllocator
  participant PageMetadata
  participant OldPageIndex
  participant GCOrReader

  OldAllocator->>PageMetadata: defer old-object page registration
  PageMetadata->>PageMetadata: buffer registrations
  GCOrReader->>PageMetadata: request GC operation or index data
  PageMetadata->>PageMetadata: flush deferred registrations
  PageMetadata->>OldPageIndex: update page objects and metadata
  PageMetadata-->>GCOrReader: continue with complete index state
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7443 — This change modifies registration timing in the old-generation hole-reuse path introduced by that allocation work.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the performance change: deferring old-object page registration from the GC promotion path.
Description check ✅ Passed The description explains the change, rationale, safety checks, tests, benchmarks, scope, and relation to #7623, despite not using the template headings.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/old-page-registration-deferral

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
crates/perry-runtime/src/arena/tests.rs (2)

1386-1401: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the single-page assumption explicit.

The test walks only page = generation_page_for_addr(headers[0]) and then asserts visited equals all 64 headers. That holds only while 64 headers at a 64-byte stride fit in one page, which requires GENERATION_PAGE_SIZE >= 4096. If GENERATION_PAGE_SIZE is ever reduced, this test fails with a confusing element-mismatch diff rather than a clear cause.

Add an assertion that states the requirement.

🧪 Proposed assertion
         let headers = synthetic_old_headers(64);
         let page = generation_page_for_addr(headers[0]);
+        assert_eq!(
+            generation_page_for_addr(headers[63]),
+            page,
+            "this test assumes all 64 headers land on one page"
+        );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/arena/tests.rs` around lines 1386 - 1401, Add an
explicit assertion in the test around the generation page setup, using the
existing GENERATION_PAGE_SIZE constant, to require that 64 headers at a 64-byte
stride fit within one page (at least 4096 bytes). Keep the existing single-page
walk and visited-header assertions unchanged.

1245-1261: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The source-text scan proves textual presence, not that the constructor calls the flush point.

The test reads three files and checks for the substring old_pages_begin_gc_cycle(). A commented-out call, a call in an unrelated function in the same file, or a call behind a disabled cfg all satisfy the assertion. A rename or a move of the call into a shared helper produces a false failure.

A behavioral test is stronger: construct each cycle through its real constructor with a pending registration in the buffer, then assert deferred_old_page_registrations_len() is 0. cycle_start_flushes_deferred_registrations already shows this pattern for old_pages_begin_gc_cycle directly.

If the three constructors cannot be driven from a unit test, keep this scan and narrow it to the enclosing function using an AST match rather than a whole-file substring.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/arena/tests.rs` around lines 1245 - 1261, Replace
the whole-file substring assertions in
every_cycle_constructor_routes_through_the_flush_point with behavioral coverage
that invokes each real cycle constructor from mod.rs, cycle.rs, and policy.rs
using a pending deferred old-page registration, then asserts
deferred_old_page_registrations_len() is zero; reuse the setup pattern from
cycle_start_flushes_deferred_registrations. If direct construction is not
possible, retain the scan but restrict it to the relevant enclosing constructor
via an AST-based match.
crates/perry-runtime/src/arena/page_meta.rs (1)

757-779: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting the page-overlap iteration shared with old_object_page_overlaps.

Lines 758-768 duplicate the overlap computation in old_object_page_overlaps (lines 596-617) exactly. The duplication is deliberate; the inline form avoids a Vec allocation per entry, which is the point of this change. The risk is divergence: a future fix to one copy silently changes page accounting in the other.

An allocation-free iterator helper, used by both, keeps the performance property and removes the second copy.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/arena/page_meta.rs` around lines 757 - 779, Extract
the duplicated page-overlap calculation into an allocation-free iterator helper,
then reuse it in both old_object_page_overlaps and the pending-object loop near
the visible overlap logic. Preserve the current overlap boundaries, page
traversal, and per-entry accounting without introducing a Vec allocation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/arena/page_meta.rs`:
- Around line 753-756: Update the comment above run_page and run_base_len to
avoid claiming entries are strictly allocation-ordered or always share a page
consecutively. Document that entries may revisit an earlier page, and that
recapturing run_base_len from the current headers.len() only widens the
deduplication window safely because in-batch addresses are pairwise distinct as
established above.

---

Nitpick comments:
In `@crates/perry-runtime/src/arena/page_meta.rs`:
- Around line 757-779: Extract the duplicated page-overlap calculation into an
allocation-free iterator helper, then reuse it in both old_object_page_overlaps
and the pending-object loop near the visible overlap logic. Preserve the current
overlap boundaries, page traversal, and per-entry accounting without introducing
a Vec allocation.

In `@crates/perry-runtime/src/arena/tests.rs`:
- Around line 1386-1401: Add an explicit assertion in the test around the
generation page setup, using the existing GENERATION_PAGE_SIZE constant, to
require that 64 headers at a 64-byte stride fit within one page (at least 4096
bytes). Keep the existing single-page walk and visited-header assertions
unchanged.
- Around line 1245-1261: Replace the whole-file substring assertions in
every_cycle_constructor_routes_through_the_flush_point with behavioral coverage
that invokes each real cycle constructor from mod.rs, cycle.rs, and policy.rs
using a pending deferred old-page registration, then asserts
deferred_old_page_registrations_len() is zero; reuse the setup pattern from
cycle_start_flushes_deferred_registrations. If direct construction is not
possible, retain the scan but restrict it to the relevant enclosing constructor
via an AST-based match.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a82fb53e-3feb-4058-a549-317a24a6d52a

📥 Commits

Reviewing files that changed from the base of the PR and between 38ff7ec and a2b55ba.

📒 Files selected for processing (4)
  • crates/perry-runtime/src/arena/allocators.rs
  • crates/perry-runtime/src/arena/mod.rs
  • crates/perry-runtime/src/arena/page_meta.rs
  • crates/perry-runtime/src/arena/tests.rs

Comment on lines +753 to +756
// 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<usize> = None;
let mut run_base_len: usize = 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Tighten the comment: entries are not strictly ordered by address.

The comment states entries arrive in allocation order so consecutive entries share a page. The hole-reuse path in arena_alloc_gc_old can return an address below a previously deferred bump allocation, so pending can revisit an earlier page.

The cache stays correct in that case. On a revisit, run_base_len is recaptured at the current headers.len(), which is greater than or equal to the pre-batch length, so the dedup window only widens and never misses a pre-batch entry. Widening is safe only because in-batch addresses are pairwise distinct, which lines 738-742 already establish.

State that reasoning here. The current wording presents strict ordering as a property, and a future change could rely on it.

📝 Proposed comment change
-        // Entries arrive in allocation order, so consecutive ones share a page;
-        // cache that page's pre-batch length across the run.
+        // Consecutive entries usually share a page, so cache that page's
+        // pre-batch list length across the run. Hole reuse can hand back a
+        // lower address, so a page CAN be revisited within one batch; the
+        // recaptured length is then >= the pre-batch length, which only widens
+        // the dedup window. That is safe because in-batch addresses are
+        // pairwise distinct (see the doc comment above).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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<usize> = None;
let mut run_base_len: usize = 0;
// Consecutive entries usually share a page, so cache that page's
// pre-batch list length across the run. Hole reuse can hand back a
// lower address, so a page CAN be revisited within one batch; the
// recaptured length is then >= the pre-batch length, which only widens
// the dedup window. That is safe because in-batch addresses are
// pairwise distinct (see the doc comment above).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/arena/page_meta.rs` around lines 753 - 756, Update
the comment above run_page and run_base_len to avoid claiming entries are
strictly allocation-ordered or always share a page consecutively. Document that
entries may revisit an earlier page, and that recapturing run_base_len from the
current headers.len() only widens the deduplication window safely because
in-batch addresses are pairwise distinct as established above.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Local verification (interim — pinned-host A/B still queued behind a public-baseline run)

The pinned mini is busy with a run_public_baseline regeneration, so no wall
number is quoted yet. Everything below is either deterministic (so host load
cannot affect it) or explicitly labelled as a loaded-host pre-check.

The subject is live, and sized

PERRY_GC_DIAG=1 on json_pipeline 200k, base arm:

[gc-copy-minor] ran copied_objects=0 copied_bytes=0
  promoted_objects=1657956 promoted_bytes=113226480 ...

1,657,956 objects — 113,226,480 bytes — promoted in a single copying minor,
every one of them through arena_alloc_gc_oldregister_old_object_pages.
That is the burst this PR targets, and it confirms the #7623 audit's ~113 MB
figure from the other direction. At ~40–100 B/object a 4 KiB page holds tens of
objects, so the eager dedup contains scan alone runs on the order of 10^8
comparisons inside that one pause, plus ~3.3M Vec allocations and ~3.3M
RefCell borrow pairs.

The collector's behaviour is unchanged

The GC census is byte-identical between arms after normalising only the
timing fields — same cycle sequence, same promoted_objects, same
promoted_bytes, same sweep_freed, same block_reclaim, same step sizing:

CENSUS IDENTICAL (time fields normalised)

Output hashes identical at 200k and 500k, and byte-identical stdout on all 11
gc-bench workloads (churn_alloc churn churn_read churn_num cycles deeplist push_cls push_num retain retain1 tree). This is what "bookkeeping only" is
supposed to mean, and it is checked rather than asserted.

Sabotage: 9 cases, 9 caught

Each flush site removed one at a time; the test that is supposed to catch it
must go red.

sabotage caught by
drop flush at cycle start (old_pages_begin_gc_cycle) cycle_start_flushes_deferred_registrations
drop flush in reader old_arena_walk_objects_on_pages every_index_reader_flushes_before_reading
drop flush in reader OldArenaPageObjectCursor::new every_index_reader_flushes_before_reading
drop flush in reader old_page_summary every_index_reader_flushes_before_reading
drop flush in remover unregister_old_object_pages removing_a_deferred_object_does_not_resurrect_it
drop flush in remover old_arena_page_index_remove_object removing_a_deferred_object_does_not_resurrect_it
drop flush in remover unregister_old_block_pages removing_a_deferred_object_does_not_resurrect_it
disable the size cap deferral_buffer_flushes_at_its_size_cap
revert the promote path to eager registration old_gen_birth_defers_its_page_registration

The last row is the #7024/#6942 guard: if a later refactor makes this PR inert,
a test goes red rather than the suite staying quietly green.

GC zeal + from-space protection, both arms

Workloads recompiled with PERRY_GC_MOVING_LOOP_POLLS=1 — without it zeal only
fires at event-loop boundaries and a compute-only loop never collects, so a
green run would mean nothing. Run under PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1:

ok  churn_alloc  rc=0 both, outputs match
ok  tree         rc=0 both, outputs match
ok  retain       rc=0 both, outputs match
ok  cycles       rc=0 both, outputs match
base churn_alloc: gc-fromspace-protect lines = 110
fix  churn_alloc: gc-fromspace-protect lines = 110

The instrument was live (110 protected retirements per arm) and the counts match.

Gates

cargo fmt --check, check_file_size.sh, workspace_architecture.py
(--self-test + --check), gc_store_site_inventory.py (--self-test +
check), addr_class_inventory.py (--self-test + check),
class_id_collisions.py, raw_handle_debt.py (998, exactly the baseline),
check_test_registration.py (--self-test + check) — all pass.
cargo test -p perry-runtime --no-fail-fast: 1892 pass, 1 pre-existing
timing-flake (promise::keyed_table::settling_many_keys_is_not_quadratic,
green on rerun; this host was at load 93).

gc-root-dominance is N/A — no codegen change.

Loaded-host pre-check (NOT a citable number)

Dev Mac at load ~29–90, so this is a directional signal only, best-of-N user
CPU: json_pipeline 200k 1.85 s → 1.76 s (−4.9%, fix faster in all 5 paired
rounds); 500k 5.20 s → 5.05 s (−2.9%, fix faster in 2 of 3). Output hashes
identical in every round.

Pinned-host wall/RSS/pause plus the official gc_ratchet check on both arms
will follow as a separate comment.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Pinned-host A/B — raw output

Run on perry-macos (M1 mini, load ~1.3) only after the owner's run_public_baseline exited. Both arms perry-dev with an identical package set; workloads compiled on the dev Mac with PERRY_NO_AUTO_OPTIMIZE=1 and the prebuilt executables shipped here, so nothing was rebuilt on the measurement host. base/fix interleaved within each of 5 rounds.

json_pipeline + gc bench set + GC census
 0:46  up 2 days, 19:30, 4 users, load averages: 3.20 3.49 3.04
### host:  0:46  up 2 days, 19:30, 4 users, load averages: 3.20 3.49 3.04
### fixtures: /Users/perry/perry-bench.noindex/w7592/fix
=============== json_pipeline (interleaved, 5 rounds) ===============
JSON size=200k r=1 arm=base real=1.65 user=1.59 rss=489029632 hash=0a883457
JSON size=200k r=1 arm=fix real=2.11 user=1.54 rss=486703104 hash=0a883457
JSON size=200k r=2 arm=base real=1.63 user=1.57 rss=495648768 hash=0a883457
JSON size=200k r=2 arm=fix real=1.59 user=1.53 rss=471138304 hash=0a883457
JSON size=200k r=3 arm=base real=1.64 user=1.58 rss=489013248 hash=0a883457
JSON size=200k r=3 arm=fix real=1.56 user=1.50 rss=471138304 hash=0a883457
JSON size=200k r=4 arm=base real=1.63 user=1.58 rss=489029632 hash=0a883457
JSON size=200k r=4 arm=fix real=1.56 user=1.50 rss=486768640 hash=0a883457
JSON size=200k r=5 arm=base real=1.64 user=1.58 rss=489029632 hash=0a883457
JSON size=200k r=5 arm=fix real=1.56 user=1.50 rss=471138304 hash=0a883457
JSON size=200k OUTPUT IDENTICAL (0e1746053cc681abd930d46da0405be4)
JSON size=500k r=1 arm=base real=4.35 user=4.19 rss=1110425600 hash=b7e8a588
JSON size=500k r=1 arm=fix real=4.17 user=4.01 rss=1114701824 hash=b7e8a588
JSON size=500k r=2 arm=base real=4.39 user=4.24 rss=1110441984 hash=b7e8a588
JSON size=500k r=2 arm=fix real=4.16 user=4.02 rss=1114701824 hash=b7e8a588
JSON size=500k r=3 arm=base real=4.36 user=4.21 rss=1110441984 hash=b7e8a588
JSON size=500k r=3 arm=fix real=4.19 user=4.02 rss=1119617024 hash=b7e8a588
JSON size=500k r=4 arm=base real=4.32 user=4.18 rss=1110425600 hash=b7e8a588
JSON size=500k r=4 arm=fix real=4.18 user=4.04 rss=1114718208 hash=b7e8a588
JSON size=500k r=5 arm=base real=4.38 user=4.22 rss=1110441984 hash=b7e8a588
JSON size=500k r=5 arm=fix real=4.22 user=4.06 rss=1114701824 hash=b7e8a588
JSON size=500k OUTPUT IDENTICAL (3c52c67cad92851461164656c1f854f7)
=============== gc bench set ===============
GCBENCH w=churn_alloc r=1 arm=base real=0.44 user=0.42 rss=26132480 out=b1a303c5ee4984f0
GCBENCH w=churn_alloc r=1 arm=fix real=0.99 user=0.43 rss=26214400 out=b1a303c5ee4984f0
GCBENCH w=churn_alloc r=2 arm=base real=0.42 user=0.42 rss=26148864 out=b1a303c5ee4984f0
GCBENCH w=churn_alloc r=2 arm=fix real=0.42 user=0.41 rss=26198016 out=b1a303c5ee4984f0
GCBENCH w=churn_alloc r=3 arm=base real=0.42 user=0.42 rss=26148864 out=b1a303c5ee4984f0
GCBENCH w=churn_alloc r=3 arm=fix real=0.42 user=0.41 rss=26198016 out=b1a303c5ee4984f0
GCBENCH w=churn_alloc r=4 arm=base real=0.42 user=0.42 rss=26132480 out=b1a303c5ee4984f0
GCBENCH w=churn_alloc r=4 arm=fix real=0.42 user=0.41 rss=26214400 out=b1a303c5ee4984f0
GCBENCH w=churn_alloc r=5 arm=base real=0.42 user=0.42 rss=26132480 out=b1a303c5ee4984f0
GCBENCH w=churn_alloc r=5 arm=fix real=0.42 user=0.41 rss=26214400 out=b1a303c5ee4984f0
GCBENCH w=churn r=1 arm=base real=0.76 user=0.73 rss=26198016 out=b5e09d6f74eabc7e
GCBENCH w=churn r=1 arm=fix real=1.23 user=0.75 rss=26214400 out=b5e09d6f74eabc7e
GCBENCH w=churn r=2 arm=base real=0.74 user=0.74 rss=26181632 out=b5e09d6f74eabc7e
GCBENCH w=churn r=2 arm=fix real=0.74 user=0.73 rss=26214400 out=b5e09d6f74eabc7e
GCBENCH w=churn r=3 arm=base real=0.74 user=0.74 rss=26181632 out=b5e09d6f74eabc7e
GCBENCH w=churn r=3 arm=fix real=0.74 user=0.73 rss=26198016 out=b5e09d6f74eabc7e
GCBENCH w=churn r=4 arm=base real=0.74 user=0.74 rss=26181632 out=b5e09d6f74eabc7e
GCBENCH w=churn r=4 arm=fix real=0.74 user=0.73 rss=26198016 out=b5e09d6f74eabc7e
GCBENCH w=churn r=5 arm=base real=0.74 user=0.73 rss=26181632 out=b5e09d6f74eabc7e
GCBENCH w=churn r=5 arm=fix real=0.74 user=0.73 rss=26214400 out=b5e09d6f74eabc7e
GCBENCH w=churn_read r=1 arm=base real=0.40 user=0.38 rss=6144000 out=71c7160bc02b39e2
GCBENCH w=churn_read r=1 arm=fix real=0.87 user=0.40 rss=6225920 out=71c7160bc02b39e2
GCBENCH w=churn_read r=2 arm=base real=0.38 user=0.38 rss=6160384 out=71c7160bc02b39e2
GCBENCH w=churn_read r=2 arm=fix real=0.38 user=0.38 rss=6225920 out=71c7160bc02b39e2
GCBENCH w=churn_read r=3 arm=base real=0.38 user=0.38 rss=6144000 out=71c7160bc02b39e2
GCBENCH w=churn_read r=3 arm=fix real=0.38 user=0.38 rss=6242304 out=71c7160bc02b39e2
GCBENCH w=churn_read r=4 arm=base real=0.38 user=0.38 rss=6144000 out=71c7160bc02b39e2
GCBENCH w=churn_read r=4 arm=fix real=0.38 user=0.38 rss=6225920 out=71c7160bc02b39e2
GCBENCH w=churn_read r=5 arm=base real=0.38 user=0.38 rss=6144000 out=71c7160bc02b39e2
GCBENCH w=churn_read r=5 arm=fix real=0.38 user=0.38 rss=6242304 out=71c7160bc02b39e2
GCBENCH w=churn_num r=1 arm=base real=0.19 user=0.17 rss=25329664 out=cb0abd53a8366dbc
GCBENCH w=churn_num r=1 arm=fix real=0.66 user=0.18 rss=25493504 out=cb0abd53a8366dbc
GCBENCH w=churn_num r=2 arm=base real=0.17 user=0.17 rss=25313280 out=cb0abd53a8366dbc
GCBENCH w=churn_num r=2 arm=fix real=0.17 user=0.17 rss=25509888 out=cb0abd53a8366dbc
GCBENCH w=churn_num r=3 arm=base real=0.17 user=0.17 rss=25329664 out=cb0abd53a8366dbc
GCBENCH w=churn_num r=3 arm=fix real=0.17 user=0.17 rss=25509888 out=cb0abd53a8366dbc
GCBENCH w=churn_num r=4 arm=base real=0.17 user=0.17 rss=25329664 out=cb0abd53a8366dbc
GCBENCH w=churn_num r=4 arm=fix real=0.17 user=0.17 rss=25493504 out=cb0abd53a8366dbc
GCBENCH w=churn_num r=5 arm=base real=0.17 user=0.17 rss=25313280 out=cb0abd53a8366dbc
GCBENCH w=churn_num r=5 arm=fix real=0.17 user=0.17 rss=25509888 out=cb0abd53a8366dbc
GCBENCH w=cycles r=1 arm=base real=0.86 user=0.84 rss=31965184 out=37d8a25342c5a94b
GCBENCH w=cycles r=1 arm=fix real=1.34 user=0.86 rss=32276480 out=37d8a25342c5a94b
GCBENCH w=cycles r=2 arm=base real=1.03 user=1.02 rss=31817728 out=37d8a25342c5a94b
GCBENCH w=cycles r=2 arm=fix real=1.03 user=1.02 rss=32129024 out=37d8a25342c5a94b
GCBENCH w=cycles r=3 arm=base real=1.03 user=1.02 rss=31768576 out=37d8a25342c5a94b
GCBENCH w=cycles r=3 arm=fix real=1.03 user=1.02 rss=32129024 out=37d8a25342c5a94b
GCBENCH w=cycles r=4 arm=base real=0.84 user=0.84 rss=31965184 out=37d8a25342c5a94b
GCBENCH w=cycles r=4 arm=fix real=1.03 user=1.03 rss=32129024 out=37d8a25342c5a94b
GCBENCH w=cycles r=5 arm=base real=1.02 user=1.01 rss=31801344 out=37d8a25342c5a94b
GCBENCH w=cycles r=5 arm=fix real=1.03 user=1.03 rss=32112640 out=37d8a25342c5a94b
GCBENCH w=deeplist r=1 arm=base real=1.36 user=1.31 rss=144998400 out=085c348f64a3b543
GCBENCH w=deeplist r=1 arm=fix real=1.79 user=1.30 rss=149536768 out=085c348f64a3b543
GCBENCH w=deeplist r=2 arm=base real=1.33 user=1.31 rss=161267712 out=085c348f64a3b543
GCBENCH w=deeplist r=2 arm=fix real=1.30 user=1.28 rss=149520384 out=085c348f64a3b543
GCBENCH w=deeplist r=3 arm=base real=1.33 user=1.31 rss=147832832 out=085c348f64a3b543
GCBENCH w=deeplist r=3 arm=fix real=1.30 user=1.28 rss=149536768 out=085c348f64a3b543
GCBENCH w=deeplist r=4 arm=base real=1.33 user=1.31 rss=163135488 out=085c348f64a3b543
GCBENCH w=deeplist r=4 arm=fix real=1.30 user=1.29 rss=162873344 out=085c348f64a3b543
GCBENCH w=deeplist r=5 arm=base real=1.33 user=1.32 rss=144867328 out=085c348f64a3b543
GCBENCH w=deeplist r=5 arm=fix real=1.31 user=1.29 rss=161873920 out=085c348f64a3b543
GCBENCH w=push_cls r=1 arm=base real=0.42 user=0.40 rss=26116096 out=b1a303c5ee4984f0
GCBENCH w=push_cls r=1 arm=fix real=0.90 user=0.42 rss=26230784 out=b1a303c5ee4984f0
GCBENCH w=push_cls r=2 arm=base real=0.40 user=0.40 rss=26132480 out=b1a303c5ee4984f0
GCBENCH w=push_cls r=2 arm=fix real=0.40 user=0.40 rss=26214400 out=b1a303c5ee4984f0
GCBENCH w=push_cls r=3 arm=base real=0.40 user=0.40 rss=26116096 out=b1a303c5ee4984f0
GCBENCH w=push_cls r=3 arm=fix real=0.41 user=0.40 rss=26230784 out=b1a303c5ee4984f0
GCBENCH w=push_cls r=4 arm=base real=0.40 user=0.40 rss=26116096 out=b1a303c5ee4984f0
GCBENCH w=push_cls r=4 arm=fix real=0.41 user=0.40 rss=26230784 out=b1a303c5ee4984f0
GCBENCH w=push_cls r=5 arm=base real=0.40 user=0.40 rss=26132480 out=b1a303c5ee4984f0
GCBENCH w=push_cls r=5 arm=fix real=0.41 user=0.40 rss=26214400 out=b1a303c5ee4984f0
GCBENCH w=push_num r=1 arm=base real=0.15 user=0.13 rss=25411584 out=b1a303c5ee4984f0
GCBENCH w=push_num r=1 arm=fix real=0.62 user=0.15 rss=25444352 out=b1a303c5ee4984f0
GCBENCH w=push_num r=2 arm=base real=0.14 user=0.13 rss=25411584 out=b1a303c5ee4984f0
GCBENCH w=push_num r=2 arm=fix real=0.14 user=0.13 rss=25427968 out=b1a303c5ee4984f0
GCBENCH w=push_num r=3 arm=base real=0.14 user=0.13 rss=25427968 out=b1a303c5ee4984f0
GCBENCH w=push_num r=3 arm=fix real=0.14 user=0.13 rss=25444352 out=b1a303c5ee4984f0
GCBENCH w=push_num r=4 arm=base real=0.14 user=0.13 rss=25427968 out=b1a303c5ee4984f0
GCBENCH w=push_num r=4 arm=fix real=0.14 user=0.13 rss=25444352 out=b1a303c5ee4984f0
GCBENCH w=push_num r=5 arm=base real=0.14 user=0.13 rss=25427968 out=b1a303c5ee4984f0
GCBENCH w=push_num r=5 arm=fix real=0.14 user=0.13 rss=25427968 out=b1a303c5ee4984f0
GCBENCH w=retain r=1 arm=base real=2.16 user=2.09 rss=396279808 out=24013858c8060aa6
GCBENCH w=retain r=1 arm=fix real=2.52 user=2.01 rss=393936896 out=24013858c8060aa6
GCBENCH w=retain r=2 arm=base real=2.13 user=2.09 rss=396279808 out=24013858c8060aa6
GCBENCH w=retain r=2 arm=fix real=2.03 user=2.00 rss=393936896 out=24013858c8060aa6
GCBENCH w=retain r=3 arm=base real=2.13 user=2.09 rss=396296192 out=24013858c8060aa6
GCBENCH w=retain r=3 arm=fix real=2.04 user=2.00 rss=393936896 out=24013858c8060aa6
GCBENCH w=retain r=4 arm=base real=2.13 user=2.09 rss=396279808 out=24013858c8060aa6
GCBENCH w=retain r=4 arm=fix real=2.04 user=2.00 rss=393936896 out=24013858c8060aa6
GCBENCH w=retain r=5 arm=base real=2.13 user=2.09 rss=396263424 out=24013858c8060aa6
GCBENCH w=retain r=5 arm=fix real=2.03 user=1.99 rss=393953280 out=24013858c8060aa6
GCBENCH w=retain1 r=1 arm=base real=0.71 user=0.68 rss=186531840 out=dbb4498f673634c6
GCBENCH w=retain1 r=1 arm=fix real=1.16 user=0.66 rss=174292992 out=dbb4498f673634c6
GCBENCH w=retain1 r=2 arm=base real=0.69 user=0.68 rss=186531840 out=dbb4498f673634c6
GCBENCH w=retain1 r=2 arm=fix real=0.66 user=0.65 rss=174292992 out=dbb4498f673634c6
GCBENCH w=retain1 r=3 arm=base real=0.69 user=0.68 rss=186515456 out=dbb4498f673634c6
GCBENCH w=retain1 r=3 arm=fix real=0.66 user=0.64 rss=174292992 out=dbb4498f673634c6
GCBENCH w=retain1 r=4 arm=base real=0.69 user=0.68 rss=186515456 out=dbb4498f673634c6
GCBENCH w=retain1 r=4 arm=fix real=0.66 user=0.65 rss=174292992 out=dbb4498f673634c6
GCBENCH w=retain1 r=5 arm=base real=0.70 user=0.68 rss=186531840 out=dbb4498f673634c6
GCBENCH w=retain1 r=5 arm=fix real=0.66 user=0.65 rss=174276608 out=dbb4498f673634c6
GCBENCH w=tree r=1 arm=base real=9.68 user=9.59 rss=241909760 out=a895f1d76b1be1eb
GCBENCH w=tree r=1 arm=fix real=10.20 user=9.66 rss=236371968 out=a895f1d76b1be1eb
GCBENCH w=tree r=2 arm=base real=9.64 user=9.56 rss=239403008 out=a895f1d76b1be1eb
GCBENCH w=tree r=2 arm=fix real=9.46 user=9.40 rss=233963520 out=a895f1d76b1be1eb
GCBENCH w=tree r=3 arm=base real=9.46 user=9.39 rss=239190016 out=a895f1d76b1be1eb
GCBENCH w=tree r=3 arm=fix real=9.68 user=9.62 rss=236355584 out=a895f1d76b1be1eb
GCBENCH w=tree r=4 arm=base real=9.69 user=9.62 rss=240877568 out=a895f1d76b1be1eb
GCBENCH w=tree r=4 arm=fix real=9.59 user=9.52 rss=233979904 out=a895f1d76b1be1eb
GCBENCH w=tree r=5 arm=base real=9.59 user=9.51 rss=241942528 out=a895f1d76b1be1eb
GCBENCH w=tree r=5 arm=fix real=9.52 user=9.46 rss=235126784 out=a895f1d76b1be1eb
=============== GC census (deterministic; must be identical) ===============
CENSUS 200k IDENTICAL
--- 200k promote (base): promoted_objects=1657962 promoted_bytes=113226896
CENSUS 500k IDENTICAL
--- 500k promote (base): promoted_objects=4117011 promoted_bytes=280996760
AB REAL EXIT: 0

gc-ratchet (#7609 baseline), both arms — verdicts and every rss/wall row
### host:  0:51  up 2 days, 19:35, 4 users, load averages: 3.66 3.72 3.27
### node: v26.5.1  pinned: 26.5.1
gc-ratchet: /Users/perry/defreg/repo/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json is structurally valid
=================== ARM base ===================
gc-ratchet: wrote measurement to /Users/perry/defreg-ab/ratchet-base.json
  01_nursery_churn: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0% peak_rss_bytes=0% wall_ms=1.28054%
  02_survivor_promotion: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.079713% peak_rss_bytes=0.078833% wall_ms=0.925456%
  03_cross_gen_writes: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.059453% peak_rss_bytes=0.054201% wall_ms=0.661938%
  04_dead_after_deep_stack: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.059737% peak_rss_bytes=0.054289% wall_ms=0.593467%
  05_closure_capture: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.026853% peak_rss_bytes=0.025927% wall_ms=0.677973%
  06_string_retention: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.556399% peak_rss_bytes=0.551655% wall_ms=1.627786%
  07_array_grow_evacuate: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.101678% peak_rss_bytes=0.100604% wall_ms=1.094043%
  08_map_set_sidetables: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.061013% peak_rss_bytes=0.053996% wall_ms=0.293576%
  09_try_catch_roots: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.081004% peak_rss_bytes=0.080613% wall_ms=1.471191%
  10_store_receiver_across_alloc: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.098425% peak_rss_bytes=0.097182% wall_ms=2.853844%
  11_collect_at_depth: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.045935% peak_rss_bytes=0.045455% wall_ms=2.8298%
  12_large_live_set: correctness=pass heap_used_bytes=0.006606% heap_total_bytes=0% rss_bytes=1.915185% peak_rss_bytes=1.912405% wall_ms=0.32217%
| `11_collect_at_depth` | rss_bytes | 34,652,160 | 35,667,968 | +2.93% | 8,388,608 | no | ok |
| `11_collect_at_depth` | peak_rss_bytes | 35,045,376 | 36,044,800 | +2.85% | 8,388,608 | no | ok |
| `11_collect_at_depth` | wall_ms | 79 | 88 | +11.84% | 100 | no | ok |
| `12_large_live_set` | rss_bytes | 189,874,176 | 191,627,264 | +0.92% | 28,481,126 | no | ok |
| `12_large_live_set` | peak_rss_bytes | 190,267,392 | 191,905,792 | +0.86% | 28,540,109 | no | ok |
| `12_large_live_set` | wall_ms | 3,471 | 3,754 | +8.13% | 1,041 | no | ok |
### Cells excluded from the gating family by probe override
gc-ratchet: OK
| `10_store_receiver_across_alloc` | wall_ms | 62 | 69 | +10.74% | 15 | yes | ok |
| `11_collect_at_depth` | rss_bytes | 34,652,160 | 35,667,968 | +2.93% | 1,039,565 | yes | ok |
| `11_collect_at_depth` | peak_rss_bytes | 35,045,376 | 36,044,800 | +2.85% | 1,051,361 | yes | ok |
| `11_collect_at_depth` | wall_ms | 79 | 88 | +11.84% | 15 | yes | ok |
| `12_large_live_set` | rss_bytes | 189,874,176 | 191,627,264 | +0.92% | 5,696,225 | yes | ok |
| `12_large_live_set` | peak_rss_bytes | 190,267,392 | 191,905,792 | +0.86% | 5,708,022 | yes | ok |
| `12_large_live_set` | wall_ms | 3,471 | 3,754 | +8.13% | 347 | yes | ok |
### Cells excluded from the gating family by probe override
=================== ARM fix ===================
gc-ratchet: wrote measurement to /Users/perry/defreg-ab/ratchet-fix.json
  01_nursery_churn: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.093067% peak_rss_bytes=0.092421% wall_ms=0.347077%
  02_survivor_promotion: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.079334% peak_rss_bytes=0.078895% wall_ms=1.147118%
  03_cross_gen_writes: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.059032% peak_rss_bytes=0.054025% wall_ms=1.291832%
  04_dead_after_deep_stack: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.059453% peak_rss_bytes=0.054289% wall_ms=0.702617%
  05_closure_capture: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.026795% peak_rss_bytes=0.025913% wall_ms=1.096342%
  06_string_retention: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.0999% peak_rss_bytes=0.444006% wall_ms=0.702215%
  07_array_grow_evacuate: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.251256% peak_rss_bytes=0.248139% wall_ms=1.312744%
  08_map_set_sidetables: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.060938% peak_rss_bytes=0.054054% wall_ms=0.331221%
  09_try_catch_roots: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.162075% peak_rss_bytes=0.160836% wall_ms=1.34547%
  10_store_receiver_across_alloc: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.098184% peak_rss_bytes=0.097466% wall_ms=2.516699%
  11_collect_at_depth: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.091659% peak_rss_bytes=0.091199% wall_ms=3.097844%
  12_large_live_set: correctness=pass heap_used_bytes=0.006846% heap_total_bytes=0% rss_bytes=0.938396% peak_rss_bytes=0.936687% wall_ms=0.208168%
| `11_collect_at_depth` | rss_bytes | 34,652,160 | 35,749,888 | +3.17% | 8,388,608 | no | ok |
| `11_collect_at_depth` | peak_rss_bytes | 35,045,376 | 35,930,112 | +2.52% | 8,388,608 | no | ok |
| `11_collect_at_depth` | wall_ms | 79 | 88 | +11.27% | 100 | no | ok |
| `12_large_live_set` | rss_bytes | 189,874,176 | 188,563,456 | -0.69% | 28,481,126 | no | ok |
| `12_large_live_set` | peak_rss_bytes | 190,267,392 | 188,907,520 | -0.71% | 28,540,109 | no | ok |
| `12_large_live_set` | wall_ms | 3,471 | 3,724 | +7.27% | 1,041 | no | ok |
### Cells excluded from the gating family by probe override
gc-ratchet: OK
| `10_store_receiver_across_alloc` | wall_ms | 62 | 69 | +10.20% | 15 | yes | ok |
| `11_collect_at_depth` | rss_bytes | 34,652,160 | 35,749,888 | +3.17% | 1,039,565 | yes | REGRESSION |
| `11_collect_at_depth` | peak_rss_bytes | 35,045,376 | 35,930,112 | +2.52% | 1,051,361 | yes | ok |
| `11_collect_at_depth` | wall_ms | 79 | 88 | +11.27% | 15 | yes | ok |
| `12_large_live_set` | rss_bytes | 189,874,176 | 188,563,456 | -0.69% | 5,696,225 | yes | ok |
| `12_large_live_set` | peak_rss_bytes | 190,267,392 | 188,907,520 | -0.71% | 5,708,022 | yes | ok |
| `12_large_live_set` | wall_ms | 3,471 | 3,724 | +7.27% | 347 | yes | ok |
### Cells excluded from the gating family by probe override

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@changelog.d/7624-defer-old-page-registration.md`:
- Around line 149-160: Update the “+31 MB peak RSS at 500k” measurement in the
changelog to explicitly label it as an intermediate or historical result,
distinguishing it from the final 500k peak RSS value of 1,114.7 MB while
preserving the existing explanation of the allocation-heavy flush.
- Around line 125-130: Update the 200k GC census values in the changelog to
match the PR summary, or explicitly identify the separate runs that produced
each set of values; keep the reported object and byte counts internally
consistent.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 034930e9-a560-4dce-9f32-de588f2ce42a

📥 Commits

Reviewing files that changed from the base of the PR and between e480be4 and 823cae8.

📒 Files selected for processing (2)
  • changelog.d/7624-defer-old-page-registration.md
  • crates/perry-runtime/src/arena/page_meta.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-runtime/src/arena/page_meta.rs

Comment on lines +125 to +130
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files:\n'
git ls-files 'changelog.d/*.md' | rg '(^|/)7624-defer-old-page-registration\.md$|changelog\.d' | sed -n '1,80p'

printf '\nTarget changelog lines 100-140:\n'
cat -n changelog.d/7624-defer-old-page-registration.md | sed -n '100,145p'

printf '\nPR/summary mentions of these census values:\n'
rg -n '1,657,9(56|62)|113,226,(480|896)|promoted_objects|promoted_bytes|200k|500k' . -S | sed -n '1,220p'

Repository: PerryTS/perry

Length of output: 50369


Keep the 200k GC census in the changelog consistent.

changelog.d/7624-defer-old-page-registration.md reports 1,657,962 objects / 113,226,896 bytes, while the PR summary used 1,657,956 objects / 113,226,480 bytes for the same 200k workload. Use one run or state which runs produced each value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@changelog.d/7624-defer-old-page-registration.md` around lines 125 - 130,
Update the 200k GC census values in the changelog to match the PR summary, or
explicitly identify the separate runs that produced each set of values; keep the
reported object and byte counts internally consistent.

Comment on lines +149 to +160
### 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.**

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files matching target:"
git ls-files 'changelog.d/7624-defer-old-page-registration.md' || true

echo
echo "Relevant sections:"
if [ -f changelog.d/7624-defer-old-page-registration.md ]; then
  wc -l changelog.d/7624-defer-old-page-registration.md
  sed -n '1,220p' changelog.d/7624-defer-old-page-registration.md | cat -n
fi

echo
echo "Search for RSS/value mentions:"
rg -n "RSS|peak RSS|1,110|1,114|1,142|flush|allocation|allocates" changelog.d/7624-defer-old-page-registration.md || true

Repository: PerryTS/perry

Length of output: 15061


Label the RSS measurement as historical.

1,114.7 MB is the final 500k peak RSS in the measured table, while 1,110 → 1,142 MB describes the allocation-heavy flush that was changed away from. Mark the +31 MB line as an intermediate/historical result so it does not conflict with the final numbers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@changelog.d/7624-defer-old-page-registration.md` around lines 149 - 160,
Update the “+31 MB peak RSS at 500k” measurement in the changelog to explicitly
label it as an intermediate or historical result, distinguishing it from the
final 500k peak RSS value of 1,114.7 MB while preserving the existing
explanation of the allocation-heavy flush.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Clean-host re-run — raw output (supersedes the earlier A/B)

The earlier A/B ran while a second run_public_baseline was executing on the mini. This one is gated on three conditions — no baseline process, a SCRIPT REAL EXIT= marker in the log, and 1-min load < 2.0 — settles 60 s, then re-checks all three afterwards.

gate + post-run idleness check
### waiting for a genuinely idle mini (Sat Aug  8 10:01:01 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 10:01:01 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 10:04:02 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 10:07:02 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 10:10:03 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 10:13:04 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 10:16:04 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 10:19:05 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 10:22:06 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 10:25:06 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 10:28:06 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 10:31:07 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 10:34:07 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 10:37:07 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 10:40:08 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 10:43:08 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 10:46:08 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 10:49:09 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 10:52:09 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 10:55:09 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 10:58:10 CEST 2026)
### waiting for a genuinely idle mini (Sat Aug  8 11:01:45 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 11:01:46 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 11:04:46 CEST 2026)
### BUSY: run_public_baseline running  (Sat Aug  8 11:07:46 CEST 2026)
### BUSY: load 2.30 >= 2.0  (Sat Aug  8 11:10:47 CEST 2026)
### idle: OK load=1.49  (Sat Aug  8 11:13:47 CEST 2026)
ab exit=0
ratchet exit=0
### post-run: OK load=1.97
A/B: json_pipeline + gc bench + census (5 interleaved rounds)
### host:  2:14  up 2 days, 20:58, 4 users, load averages: 1.62 2.04 2.45
### fixtures: /Users/perry/perry-bench.noindex/w7592/fix
=============== json_pipeline (interleaved, 5 rounds) ===============
JSON size=200k r=1 arm=base real=1.54 user=1.48 rss=489259008 hash=0a883457
JSON size=200k r=1 arm=fix real=1.47 user=1.42 rss=486703104 hash=0a883457
JSON size=200k r=2 arm=base real=1.53 user=1.48 rss=489013248 hash=0a883457
JSON size=200k r=2 arm=fix real=1.47 user=1.42 rss=486768640 hash=0a883457
JSON size=200k r=3 arm=base real=1.53 user=1.48 rss=495828992 hash=0a883457
JSON size=200k r=3 arm=fix real=1.46 user=1.41 rss=486899712 hash=0a883457
JSON size=200k r=4 arm=base real=1.53 user=1.48 rss=489013248 hash=0a883457
JSON size=200k r=4 arm=fix real=1.47 user=1.42 rss=486752256 hash=0a883457
JSON size=200k r=5 arm=base real=1.53 user=1.48 rss=489013248 hash=0a883457
JSON size=200k r=5 arm=fix real=1.47 user=1.41 rss=486752256 hash=0a883457
JSON size=200k OUTPUT IDENTICAL (0e1746053cc681abd930d46da0405be4)
JSON size=500k r=1 arm=base real=4.10 user=3.95 rss=1110441984 hash=b7e8a588
JSON size=500k r=1 arm=fix real=3.94 user=3.80 rss=1114718208 hash=b7e8a588
JSON size=500k r=2 arm=base real=4.09 user=3.95 rss=1110425600 hash=b7e8a588
JSON size=500k r=2 arm=fix real=3.94 user=3.79 rss=1114718208 hash=b7e8a588
JSON size=500k r=3 arm=base real=4.09 user=3.95 rss=1110425600 hash=b7e8a588
JSON size=500k r=3 arm=fix real=3.95 user=3.81 rss=1114718208 hash=b7e8a588
JSON size=500k r=4 arm=base real=4.09 user=3.94 rss=1111048192 hash=b7e8a588
JSON size=500k r=4 arm=fix real=3.94 user=3.79 rss=1114718208 hash=b7e8a588
JSON size=500k r=5 arm=base real=4.11 user=3.96 rss=1110441984 hash=b7e8a588
JSON size=500k r=5 arm=fix real=3.95 user=3.81 rss=1114718208 hash=b7e8a588
JSON size=500k OUTPUT IDENTICAL (3c52c67cad92851461164656c1f854f7)
=============== gc bench set ===============
GCBENCH w=churn_alloc r=1 arm=base real=0.41 user=0.39 rss=26132480 out=b1a303c5ee4984f0
GCBENCH w=churn_alloc r=1 arm=fix real=0.42 user=0.39 rss=26214400 out=b1a303c5ee4984f0
GCBENCH w=churn_alloc r=2 arm=base real=0.40 user=0.39 rss=26148864 out=b1a303c5ee4984f0
GCBENCH w=churn_alloc r=2 arm=fix real=0.39 user=0.39 rss=26198016 out=b1a303c5ee4984f0
GCBENCH w=churn_alloc r=3 arm=base real=0.40 user=0.39 rss=26148864 out=b1a303c5ee4984f0
GCBENCH w=churn_alloc r=3 arm=fix real=0.39 user=0.39 rss=26214400 out=b1a303c5ee4984f0
GCBENCH w=churn_alloc r=4 arm=base real=0.40 user=0.39 rss=26132480 out=b1a303c5ee4984f0
GCBENCH w=churn_alloc r=4 arm=fix real=0.39 user=0.39 rss=26198016 out=b1a303c5ee4984f0
GCBENCH w=churn_alloc r=5 arm=base real=0.40 user=0.39 rss=26148864 out=b1a303c5ee4984f0
GCBENCH w=churn_alloc r=5 arm=fix real=0.39 user=0.39 rss=26214400 out=b1a303c5ee4984f0
GCBENCH w=churn r=1 arm=base real=0.71 user=0.69 rss=26181632 out=b5e09d6f74eabc7e
GCBENCH w=churn r=1 arm=fix real=0.71 user=0.69 rss=26214400 out=b5e09d6f74eabc7e
GCBENCH w=churn r=2 arm=base real=0.69 user=0.69 rss=26198016 out=b5e09d6f74eabc7e
GCBENCH w=churn r=2 arm=fix real=0.69 user=0.68 rss=26198016 out=b5e09d6f74eabc7e
GCBENCH w=churn r=3 arm=base real=0.69 user=0.69 rss=26181632 out=b5e09d6f74eabc7e
GCBENCH w=churn r=3 arm=fix real=0.69 user=0.68 rss=26214400 out=b5e09d6f74eabc7e
GCBENCH w=churn r=4 arm=base real=0.69 user=0.69 rss=26198016 out=b5e09d6f74eabc7e
GCBENCH w=churn r=4 arm=fix real=0.69 user=0.69 rss=26198016 out=b5e09d6f74eabc7e
GCBENCH w=churn r=5 arm=base real=0.69 user=0.69 rss=26198016 out=b5e09d6f74eabc7e
GCBENCH w=churn r=5 arm=fix real=0.69 user=0.68 rss=26198016 out=b5e09d6f74eabc7e
GCBENCH w=churn_read r=1 arm=base real=0.38 user=0.35 rss=6144000 out=71c7160bc02b39e2
GCBENCH w=churn_read r=1 arm=fix real=0.38 user=0.35 rss=6225920 out=71c7160bc02b39e2
GCBENCH w=churn_read r=2 arm=base real=0.36 user=0.35 rss=6144000 out=71c7160bc02b39e2
GCBENCH w=churn_read r=2 arm=fix real=0.36 user=0.35 rss=6225920 out=71c7160bc02b39e2
GCBENCH w=churn_read r=3 arm=base real=0.36 user=0.35 rss=6144000 out=71c7160bc02b39e2
GCBENCH w=churn_read r=3 arm=fix real=0.36 user=0.35 rss=6225920 out=71c7160bc02b39e2
GCBENCH w=churn_read r=4 arm=base real=0.36 user=0.35 rss=6144000 out=71c7160bc02b39e2
GCBENCH w=churn_read r=4 arm=fix real=0.36 user=0.35 rss=6225920 out=71c7160bc02b39e2
GCBENCH w=churn_read r=5 arm=base real=0.36 user=0.35 rss=6144000 out=71c7160bc02b39e2
GCBENCH w=churn_read r=5 arm=fix real=0.36 user=0.35 rss=6225920 out=71c7160bc02b39e2
GCBENCH w=churn_num r=1 arm=base real=0.17 user=0.15 rss=25329664 out=cb0abd53a8366dbc
GCBENCH w=churn_num r=1 arm=fix real=0.18 user=0.15 rss=25509888 out=cb0abd53a8366dbc
GCBENCH w=churn_num r=2 arm=base real=0.16 user=0.15 rss=25329664 out=cb0abd53a8366dbc
GCBENCH w=churn_num r=2 arm=fix real=0.16 user=0.15 rss=25509888 out=cb0abd53a8366dbc
GCBENCH w=churn_num r=3 arm=base real=0.16 user=0.15 rss=25329664 out=cb0abd53a8366dbc
GCBENCH w=churn_num r=3 arm=fix real=0.16 user=0.15 rss=25509888 out=cb0abd53a8366dbc
GCBENCH w=churn_num r=4 arm=base real=0.16 user=0.15 rss=25313280 out=cb0abd53a8366dbc
GCBENCH w=churn_num r=4 arm=fix real=0.16 user=0.15 rss=25493504 out=cb0abd53a8366dbc
GCBENCH w=churn_num r=5 arm=base real=0.16 user=0.15 rss=25313280 out=cb0abd53a8366dbc
GCBENCH w=churn_num r=5 arm=fix real=0.16 user=0.15 rss=25493504 out=cb0abd53a8366dbc
GCBENCH w=cycles r=1 arm=base real=0.81 user=0.78 rss=31932416 out=37d8a25342c5a94b
GCBENCH w=cycles r=1 arm=fix real=0.99 user=0.96 rss=32145408 out=37d8a25342c5a94b
GCBENCH w=cycles r=2 arm=base real=0.79 user=0.78 rss=31948800 out=37d8a25342c5a94b
GCBENCH w=cycles r=2 arm=fix real=0.79 user=0.78 rss=32260096 out=37d8a25342c5a94b
GCBENCH w=cycles r=3 arm=base real=0.79 user=0.78 rss=31948800 out=37d8a25342c5a94b
GCBENCH w=cycles r=3 arm=fix real=0.79 user=0.78 rss=32292864 out=37d8a25342c5a94b
GCBENCH w=cycles r=4 arm=base real=0.95 user=0.95 rss=31801344 out=37d8a25342c5a94b
GCBENCH w=cycles r=4 arm=fix real=0.97 user=0.96 rss=32145408 out=37d8a25342c5a94b
GCBENCH w=cycles r=5 arm=base real=0.96 user=0.95 rss=31801344 out=37d8a25342c5a94b
GCBENCH w=cycles r=5 arm=fix real=0.96 user=0.96 rss=32112640 out=37d8a25342c5a94b
GCBENCH w=deeplist r=1 arm=base real=1.27 user=1.23 rss=163495936 out=085c348f64a3b543
GCBENCH w=deeplist r=1 arm=fix real=1.24 user=1.20 rss=146194432 out=085c348f64a3b543
GCBENCH w=deeplist r=2 arm=base real=1.24 user=1.22 rss=161267712 out=085c348f64a3b543
GCBENCH w=deeplist r=2 arm=fix real=1.22 user=1.20 rss=163872768 out=085c348f64a3b543
GCBENCH w=deeplist r=3 arm=base real=1.24 user=1.23 rss=144998400 out=085c348f64a3b543
GCBENCH w=deeplist r=3 arm=fix real=1.21 user=1.20 rss=149307392 out=085c348f64a3b543
GCBENCH w=deeplist r=4 arm=base real=1.25 user=1.23 rss=161267712 out=085c348f64a3b543
GCBENCH w=deeplist r=4 arm=fix real=1.22 user=1.20 rss=149618688 out=085c348f64a3b543
GCBENCH w=deeplist r=5 arm=base real=1.25 user=1.23 rss=144900096 out=085c348f64a3b543
GCBENCH w=deeplist r=5 arm=fix real=1.22 user=1.20 rss=149585920 out=085c348f64a3b543
GCBENCH w=push_cls r=1 arm=base real=0.40 user=0.38 rss=26132480 out=b1a303c5ee4984f0
GCBENCH w=push_cls r=1 arm=fix real=0.40 user=0.38 rss=26214400 out=b1a303c5ee4984f0
GCBENCH w=push_cls r=2 arm=base real=0.38 user=0.38 rss=26116096 out=b1a303c5ee4984f0
GCBENCH w=push_cls r=2 arm=fix real=0.38 user=0.38 rss=26230784 out=b1a303c5ee4984f0
GCBENCH w=push_cls r=3 arm=base real=0.38 user=0.37 rss=26132480 out=b1a303c5ee4984f0
GCBENCH w=push_cls r=3 arm=fix real=0.38 user=0.38 rss=26230784 out=b1a303c5ee4984f0
GCBENCH w=push_cls r=4 arm=base real=0.38 user=0.37 rss=26132480 out=b1a303c5ee4984f0
GCBENCH w=push_cls r=4 arm=fix real=0.38 user=0.38 rss=26230784 out=b1a303c5ee4984f0
GCBENCH w=push_cls r=5 arm=base real=0.38 user=0.37 rss=26116096 out=b1a303c5ee4984f0
GCBENCH w=push_cls r=5 arm=fix real=0.38 user=0.38 rss=26214400 out=b1a303c5ee4984f0
GCBENCH w=push_num r=1 arm=base real=0.14 user=0.12 rss=25427968 out=b1a303c5ee4984f0
GCBENCH w=push_num r=1 arm=fix real=0.15 user=0.12 rss=25444352 out=b1a303c5ee4984f0
GCBENCH w=push_num r=2 arm=base real=0.13 user=0.12 rss=25411584 out=b1a303c5ee4984f0
GCBENCH w=push_num r=2 arm=fix real=0.13 user=0.12 rss=25427968 out=b1a303c5ee4984f0
GCBENCH w=push_num r=3 arm=base real=0.13 user=0.12 rss=25411584 out=b1a303c5ee4984f0
GCBENCH w=push_num r=3 arm=fix real=0.13 user=0.12 rss=25444352 out=b1a303c5ee4984f0
GCBENCH w=push_num r=4 arm=base real=0.13 user=0.12 rss=25411584 out=b1a303c5ee4984f0
GCBENCH w=push_num r=4 arm=fix real=0.13 user=0.12 rss=25427968 out=b1a303c5ee4984f0
GCBENCH w=push_num r=5 arm=base real=0.13 user=0.12 rss=25427968 out=b1a303c5ee4984f0
GCBENCH w=push_num r=5 arm=fix real=0.13 user=0.12 rss=25427968 out=b1a303c5ee4984f0
GCBENCH w=retain r=1 arm=base real=2.01 user=1.95 rss=396279808 out=24013858c8060aa6
GCBENCH w=retain r=1 arm=fix real=1.92 user=1.86 rss=393936896 out=24013858c8060aa6
GCBENCH w=retain r=2 arm=base real=1.98 user=1.95 rss=396279808 out=24013858c8060aa6
GCBENCH w=retain r=2 arm=fix real=1.90 user=1.86 rss=393936896 out=24013858c8060aa6
GCBENCH w=retain r=3 arm=base real=1.99 user=1.95 rss=396279808 out=24013858c8060aa6
GCBENCH w=retain r=3 arm=fix real=1.90 user=1.87 rss=393936896 out=24013858c8060aa6
GCBENCH w=retain r=4 arm=base real=2.01 user=1.97 rss=396263424 out=24013858c8060aa6
GCBENCH w=retain r=4 arm=fix real=1.90 user=1.86 rss=393953280 out=24013858c8060aa6
GCBENCH w=retain r=5 arm=base real=1.98 user=1.95 rss=396263424 out=24013858c8060aa6
GCBENCH w=retain r=5 arm=fix real=1.90 user=1.86 rss=393953280 out=24013858c8060aa6
GCBENCH w=retain1 r=1 arm=base real=0.67 user=0.63 rss=186515456 out=dbb4498f673634c6
GCBENCH w=retain1 r=1 arm=fix real=0.64 user=0.60 rss=174292992 out=dbb4498f673634c6
GCBENCH w=retain1 r=2 arm=base real=0.65 user=0.63 rss=186515456 out=dbb4498f673634c6
GCBENCH w=retain1 r=2 arm=fix real=0.62 user=0.60 rss=174292992 out=dbb4498f673634c6
GCBENCH w=retain1 r=3 arm=base real=0.65 user=0.63 rss=186515456 out=dbb4498f673634c6
GCBENCH w=retain1 r=3 arm=fix real=0.62 user=0.60 rss=174292992 out=dbb4498f673634c6
GCBENCH w=retain1 r=4 arm=base real=0.65 user=0.63 rss=186515456 out=dbb4498f673634c6
GCBENCH w=retain1 r=4 arm=fix real=0.62 user=0.60 rss=174292992 out=dbb4498f673634c6
GCBENCH w=retain1 r=5 arm=base real=0.65 user=0.63 rss=186515456 out=dbb4498f673634c6
GCBENCH w=retain1 r=5 arm=fix real=0.62 user=0.60 rss=174292992 out=dbb4498f673634c6
GCBENCH w=tree r=1 arm=base real=9.10 user=9.01 rss=239206400 out=a895f1d76b1be1eb
GCBENCH w=tree r=1 arm=fix real=9.17 user=9.07 rss=233963520 out=a895f1d76b1be1eb
GCBENCH w=tree r=2 arm=base real=9.19 user=9.11 rss=239255552 out=a895f1d76b1be1eb
GCBENCH w=tree r=2 arm=fix real=8.86 user=8.79 rss=234242048 out=a895f1d76b1be1eb
GCBENCH w=tree r=3 arm=base real=9.19 user=9.12 rss=241926144 out=a895f1d76b1be1eb
GCBENCH w=tree r=3 arm=fix real=9.32 user=9.26 rss=234045440 out=a895f1d76b1be1eb
GCBENCH w=tree r=4 arm=base real=9.01 user=8.93 rss=239091712 out=a895f1d76b1be1eb
GCBENCH w=tree r=4 arm=fix real=9.13 user=9.06 rss=233422848 out=a895f1d76b1be1eb
GCBENCH w=tree r=5 arm=base real=9.00 user=8.93 rss=239419392 out=a895f1d76b1be1eb
GCBENCH w=tree r=5 arm=fix real=9.01 user=8.94 rss=236929024 out=a895f1d76b1be1eb
=============== GC census (deterministic; must be identical) ===============
CENSUS 200k IDENTICAL
--- 200k promote (base): promoted_objects=1657962 promoted_bytes=113226896
CENSUS 500k IDENTICAL
--- 500k promote (base): promoted_objects=4117011 promoted_bytes=280996760
AB REAL EXIT: 0

gc-ratchet, both arms, clean host
### host:  2:18  up 2 days, 21:02, 4 users, load averages: 2.48 2.11 2.37
### node: v26.5.1  pinned: 26.5.1
=== preflight: structural validate
gc-ratchet: /Users/perry/defreg/repo/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json is structurally valid
validate exit=0
=================== ARM base ===================
measure exit=0
  08_map_set_sidetables: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.061013% peak_rss_bytes=0.053996% wall_ms=0.592301%
  09_try_catch_roots: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.121507% peak_rss_bytes=0.120919% wall_ms=1.452433%
  10_store_receiver_across_alloc: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.049237% peak_rss_bytes=0.048614% wall_ms=2.840299%
  11_collect_at_depth: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.091912% peak_rss_bytes=0.09095% wall_ms=2.367439%
  12_large_live_set: correctness=pass heap_used_bytes=0.008888% heap_total_bytes=0% rss_bytes=1.689336% peak_rss_bytes=1.686851% wall_ms=0.360772%
--- check (shared_ci)
shared_ci REAL EXIT=0
gc-ratchet: OK
--- check (pinned_host)
pinned_host REAL EXIT=1
| `03_cross_gen_writes` | rss_bytes | 26,542,080 | 27,557,888 | +3.83% | 796,262 | yes | REGRESSION |
| `03_cross_gen_writes` | peak_rss_bytes | 29,294,592 | 30,228,480 | +3.19% | 878,838 | yes | REGRESSION |
| `04_dead_after_deep_stack` | rss_bytes | 26,460,160 | 27,443,200 | +3.72% | 793,805 | yes | REGRESSION |
| `04_dead_after_deep_stack` | peak_rss_bytes | 29,229,056 | 30,179,328 | +3.25% | 876,872 | yes | REGRESSION |
| `06_string_retention` | rss_bytes | 31,555,584 | 32,505,856 | +3.01% | 946,668 | yes | REGRESSION |
| `08_map_set_sidetables` | rss_bytes | 25,772,032 | 26,853,376 | +4.20% | 773,161 | yes | REGRESSION |
| `08_map_set_sidetables` | peak_rss_bytes | 29,409,280 | 30,343,168 | +3.18% | 882,278 | yes | REGRESSION |
| `09_try_catch_roots` | rss_bytes | 39,239,680 | 40,452,096 | +3.09% | 1,177,190 | yes | REGRESSION |
| `10_store_receiver_across_alloc` | rss_bytes | 32,292,864 | 33,275,904 | +3.04% | 968,786 | yes | REGRESSION |
| `10_store_receiver_across_alloc` | peak_rss_bytes | 32,702,464 | 33,701,888 | +3.06% | 981,074 | yes | REGRESSION |
gc-ratchet: FAILED
=================== ARM fix ===================
measure exit=0
  08_map_set_sidetables: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0% peak_rss_bytes=0% wall_ms=1.631381%
  09_try_catch_roots: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.162075% peak_rss_bytes=0.160836% wall_ms=1.337032%
  10_store_receiver_across_alloc: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.098135% peak_rss_bytes=0.097418% wall_ms=1.439082%
  11_collect_at_depth: correctness=pass heap_used_bytes=0% heap_total_bytes=0% rss_bytes=0.04583% peak_rss_bytes=0.0456% wall_ms=3.491918%
  12_large_live_set: correctness=pass heap_used_bytes=0.006846% heap_total_bytes=0% rss_bytes=0.573514% peak_rss_bytes=0.572469% wall_ms=0.292633%
--- check (shared_ci)
shared_ci REAL EXIT=0
gc-ratchet: OK
--- check (pinned_host)
pinned_host REAL EXIT=1
| `01_nursery_churn` | rss_bytes | 34,111,488 | 35,209,216 | +3.22% | 1,023,345 | yes | REGRESSION |
| `03_cross_gen_writes` | rss_bytes | 26,542,080 | 27,738,112 | +4.51% | 796,262 | yes | REGRESSION |
| `03_cross_gen_writes` | peak_rss_bytes | 29,294,592 | 30,310,400 | +3.47% | 878,838 | yes | REGRESSION |
| `04_dead_after_deep_stack` | rss_bytes | 26,460,160 | 27,557,888 | +4.15% | 793,805 | yes | REGRESSION |
| `04_dead_after_deep_stack` | peak_rss_bytes | 29,229,056 | 30,179,328 | +3.25% | 876,872 | yes | REGRESSION |
| `06_string_retention` | rss_bytes | 31,555,584 | 32,817,152 | +4.00% | 946,668 | yes | REGRESSION |
| `06_string_retention` | peak_rss_bytes | 32,047,104 | 33,226,752 | +3.68% | 961,413 | yes | REGRESSION |
| `07_array_grow_evacuate` | rss_bytes | 31,375,360 | 32,636,928 | +4.02% | 941,261 | yes | REGRESSION |
| `07_array_grow_evacuate` | peak_rss_bytes | 31,883,264 | 33,046,528 | +3.65% | 956,498 | yes | REGRESSION |
| `08_map_set_sidetables` | rss_bytes | 25,772,032 | 26,886,144 | +4.32% | 773,161 | yes | REGRESSION |
| `08_map_set_sidetables` | peak_rss_bytes | 29,409,280 | 30,310,400 | +3.06% | 882,278 | yes | REGRESSION |
| `09_try_catch_roots` | rss_bytes | 39,239,680 | 40,435,712 | +3.05% | 1,177,190 | yes | REGRESSION |
| `09_try_catch_roots` | peak_rss_bytes | 39,485,440 | 40,747,008 | +3.20% | 1,184,563 | yes | REGRESSION |
| `10_store_receiver_across_alloc` | rss_bytes | 32,292,864 | 33,390,592 | +3.40% | 968,786 | yes | REGRESSION |
| `11_collect_at_depth` | rss_bytes | 34,652,160 | 35,749,888 | +3.17% | 1,039,565 | yes | REGRESSION |
gc-ratchet: FAILED
RATCHET REAL EXIT: 0

Ralph Küpper added 11 commits August 8, 2026 11:53
`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
…h claim

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
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
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
…4k -> 8k)

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
…al buffer

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
…ve the RSS row

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
@proggeramlug
proggeramlug force-pushed the perf/old-page-registration-deferral branch from f807ecb to 73e7726 Compare August 8, 2026 09:58
@proggeramlug
proggeramlug merged commit c1365ed into main Aug 8, 2026
@proggeramlug
proggeramlug deleted the perf/old-page-registration-deferral branch August 8, 2026 09:58
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit before merge — verified, merged as v0.5.1360

Census reproduced to the byte on my own build: 3 cycles, 1,657,966
objects / 113,227,216 bytes
promoted in a single minor — matching your figure
and confirming the subject is live on a per-object path. Output hash unchanged;
PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 byte-identical.

Sabotage: removing all nine flush sites at once turns three tests red —
deferred_registration_flush_sites, batched_flush_matches_eager_registration
and cycle_start_flushes_deferred_registrations. Full runtime suite 1,902/0;
all six lint scripts + file-size + fmt clean.

On the corrected numbers: −3.9% / −3.7% is a smaller win than the
contaminated table claimed, and the 200k "RSS win" of −3.7% correctly became
−0.5% noise. That is the right outcome — a real ~4% on a hot path, honestly
sized, with base spread collapsing 7% → 0.7% once the host was clean. I would
rather merge a verified 4% than an unverified 5%.

What makes this PR unusually good, for the record

  1. The remover insight. Readers-flush-first is the obvious rule; you found
    that a removal while an entry is deferred is a no-op, so the later flush
    resurrects a dead object into the index pointing at swept memory. That
    is a failure mode nobody briefed you on, and it has its own named test.
  2. The rule is checkable rather than remembered — a source-level gate over
    page_meta.rs that fails on a new unflushed toucher and on a stale
    exemption, which caught OldArenaPageObjectCursor::next on its first run.
    For a closed thread-local toucher set that is the right enforcement shape.
  3. Three self-corrections, all published rather than buried: the +31 MB RSS
    from per-batch Vec churn (fixed to allocation-free); the wrong attribution
    of the ratchet row to the buffer (retired after measuring that the probe
    promotes zero objects, so the path is inert there); and the contaminated
    A/B itself. The pinned_host row resolved to +98 KB over base, not
    +1.07 MB
    , with base independently failing ten RSS cells against an
    artifact pinned at 0.5.1346 — ~91% pre-existing drift.
  4. The $?-after-pipe bug you found in your own ratchet wrapper would have
    read a red gate as green. Finding that while blocked on a host is the most
    useful kind of waiting.

One data point for #7630/#7633

My census here reads 113,227,216 bytes / 1,657,966 objects promoted at 200k
on current main — the same figure I measured independently before this PR.
#7630's step-zero profile records 101 MB, and cites "v0.5.1360-era" when
main was v0.5.1357. Whoever picks up #7633 should confirm that profile was
taken on main rather than a tree carrying unmerged PRs, because the
attribution ordering it rests on (layout family ahead of the old-page family)
is exactly what a different tree can flip — and the old-page family is what
this PR just removed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant