Skip to content

perf(gc): stop re-recording the page the write barrier just recorded (#7187 Phase B) - #7298

Merged
proggeramlug merged 3 commits into
mainfrom
perf/7187b-dirty-page-cache
Aug 3, 2026
Merged

perf(gc): stop re-recording the page the write barrier just recorded (#7187 Phase B)#7298
proggeramlug merged 3 commits into
mainfrom
perf/7187b-dirty-page-cache

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Phase B of #7187. Phase A (lazy barrier arming) landed as #7250; this is the
other half of the same finding — the redundancy that remains even when the
barrier is armed
.

What the measurement said

mark_dirty_old_page is the tail of the remembered-set half of the write
barrier: it inserts the written slot's 4 KiB page into the thread's
DIRTY_OLD_PAGES modbuf and mirrors the fact into the arena's per-page
metadata. Two thread-local accesses and two hash operations, on every
old→young store.

On benchmarks/app-patterns/kernels/batch.ts with the barrier armed it fires
1,774,374 times and produces 517 distinct pages — 99.97% of the work
re-inserts a page that is already in the set. #7170's ranked profile puts the
symbol at 6.73% of that whole program.

The mechanism, picked from the data

The obvious shapes were all simulated over the exact page sequence the
barrier produces, before anything was implemented:

shape hit rate calls left
1-entry (chosen) 99.7817% 3,873
2-entry LRU 99.8730% 2,253
4-entry LRU 99.8730% 2,253
8/32-entry direct-mapped 99.8730% 2,253
512-entry direct-mapped 99.9423% 1,023
2048-entry direct-mapped 99.9531% 832

The stores arrive in long same-page runs — 3,872 runs over 1,774,374 calls,
mean 458, longest 13,803 — so the redundancy is consecutive repetition and
one entry captures it. Every larger shape buys ≤0.17 further percentage points
for more state, an index computation and, for the direct-mapped variants,
kilobytes of thread-local storage on a path that runs on every heap store.
One usize and one compare is what the data supports.

The invariant

If the cache holds page P, then on this thread P ∈ DIRTY_OLD_PAGES
and P's OldPageMeta.dirty is already true.

Those are exactly what mark_dirty_old_page(P) establishes, so under the
invariant the call is a pure no-op. The cache can only suppress a repeat of
a recording that already happened, never a first one
— the remembered set
stays complete, which is the entire correctness bar here: a page holding an
old→young edge that is not recorded is a live object freed by the next minor,
i.e. heap corruption rather than a slow program.

Maintained by three rules, and the narrowness of the first is the argument:

  1. Populate only after both halves have just been established.
    arena::old_page_mark_dirty now reports whether a metadata entry existed to
    stamp; a page recorded in the modbuf but not in the metadata is deliberately
    not cached, so the metadata can never drift behind. Half a recording is
    not a recording.
  2. Invalidate at every point that can falsify either half. For the modbuf
    that is clear_one_dirty_old_page — the sole removal; every other touch
    of DIRTY_OLD_PAGES is an insert, a read, or the snapshot. For the metadata
    it is arena::old_page_clear_dirty and arena::unregister_old_block_pages,
    the only two places a dirty bit goes false or a page's metadata vanishes.
  3. Thread-local, like both structures it summarises. A process-global cache
    would let thread A's mark suppress thread B's, dropping the page from B's
    modbuf entirely.

Interaction with Phase A: an unarmed barrier never reaches
mark_dirty_old_page, so while unarmed this cache is never consulted and never
populated. The reconstruct that arms the barrier rebuilds the log through
StickyRememberedSet::restoremark_dirty_old_page, i.e. through the same
function, and only ever inserts — so it populates the cache exactly as the
barrier would and cannot falsify the invariant.

Before / after

batch.ts armed with a leading perry/gc collect() (Phase A otherwise
leaves it unarmed, and an unarmed barrier has no Phase B to measure). macOS
arm64, perry-dev profile, one target dir per arm, base pinned to this
branch's merge-base cdc7dee87, all three artifacts (perry,
libperry_runtime.a, libperry_stdlib.a) hash-different between arms.

write_barrier counter base after
calls 2,265,777 2,265,777
non_pointer_child_skips 280,536 280,536
parent_not_old_skips 211,497 211,497
old_to_young_slow_hits 1,773,744 1,773,744
dirty_page_mark_attempts 1,773,744 1,773,744
dirty_page_cache_hits (new) 1,770,501
reaching the modbuf + metadata 1,773,744 3,243
new_dirty_pages 517 517
new_inserts 517 517

Every pre-existing counter is identical; the only difference is the new one.

The page set is proven unchanged in-process, not by diffing two ASLR'd
runs.
A temporary instrumented build (not in this diff) recorded both the set
of pages the barrier asked to mark and the set that actually reached the
recording body, and asserted them equal at exit:

base : attempts=1774374  slow_path_calls=1774374  slow_path_distinct=517  PAGE_SET_EQUAL=true
after: attempts=1774374  slow_path_calls=3873     slow_path_distinct=517  PAGE_SET_EQUAL=true

batch.ts output stays byte-identical to the pinned Node 26.5.1 oracle
(checksum: 1601043.5000) in both arms.

No codegen change was needed; this is entirely runtime-side.

Tests

Four new --lib tests in gc/tests/dirty_page_cache.rs (per CLAUDE.md,
crates/*/tests/*.rs integration suites do not run per-PR). Each asserts its
own subject was live rather than that nothing broke — counters are read under
TestGcTraceCaptureGuard::force_enabled(), not behind the if tracing { … }
that skips the assertion entirely in a default cargo test run:

  1. repeats hit the cache (dirty_page_cache_hits == 63/64) and a genuinely
    new page is never swallowed;
  2. a clear really invalidates — so a store to the same page afterwards is
    recorded again, new_dirty_pages and all;
  3. a run of 256 stores that is 255/256 cache hits still leaves
    verify_old_to_young_edges_covered().missing_edges == 0 and the young child
    marked through the dirty page;
  4. the arena dirty stamp never drifts behind the modbuf.

Sabotage-checked, both directions:

  • delete the fast path (if false &&) → 3 of the 4 fail, including
    "a zero here is an inert fast path, and every other assertion in this file
    would still pass"
    ;
  • delete the clear-side invalidation → the completeness test fails with
    "the clear removed the page from the modbuf but left the cache claiming it
    is recorded — the next store to that page would be dropped and the old→young
    edge lost for good"
    .

What is NOT verified

Summary by CodeRabbit

  • Performance

    • Improved garbage-collection write-barrier performance by avoiding repeated tracking of already-recorded dirty pages.
    • Preserved remembered-set accuracy and minor-collection completeness.
  • Diagnostics

    • Added telemetry reporting for dirty-page cache hits.
  • Bug Fixes

    • Ensured cache state is invalidated when dirty-page metadata or remembered sets are cleared.
  • Tests

    • Added coverage for cache reuse, invalidation, new-page tracking, and collection correctness.

Ralph Küpper added 2 commits August 3, 2026 10:49
…7187 Phase B)

`mark_dirty_old_page` fires 1,774,374 times on `batch.ts` with the barrier
armed and produces 517 distinct pages: 99.97% of its two thread-local
accesses and two hash operations re-insert a page that is already there.
#7170's ranked profile puts the symbol at 6.73% of that whole program.

A one-entry thread-local last-page cache answers those repeats. The shape is
picked from the page sequence rather than from intuition: simulated over the
exact sequence the barrier produces, one entry hits 99.7817%, and every
larger shape (2/4-entry LRU, 8..2048-entry direct-mapped) buys at most 0.17
further percentage points for more state and more work per call. The stores
arrive in long same-page runs (3,872 runs, mean 458, longest 13,803), so the
redundancy is consecutive repetition and one entry captures it.

Invariant: if the cache holds page P then, on this thread, P is in
DIRTY_OLD_PAGES *and* P's `OldPageMeta.dirty` is already true — exactly what
`mark_dirty_old_page(P)` establishes, so the call is a pure no-op. The cache
can only suppress a repeat of a recording that already happened, never a
first one, so the remembered set stays complete. Maintained by: populate only
after both halves have just been established (`old_page_mark_dirty` now
reports whether a metadata entry existed, and a half-recorded page is not
cached); invalidate at every point that can falsify either half
(`clear_one_dirty_old_page`, the sole DIRTY_OLD_PAGES removal, plus
`arena::old_page_clear_dirty` and `arena::unregister_old_block_pages`); and
keep it thread-local, like both structures it summarises.

Measured before/after on `batch.ts`: every pre-existing barrier counter is
identical across the arms (calls 2,265,777; old_to_young_slow_hits and
dirty_page_mark_attempts 1,773,744; new_dirty_pages 517), the new
`dirty_page_cache_hits` goes 0 -> 1,770,501, and calls reaching the modbuf
drop 1,773,744 -> 3,243. The page set is proven unchanged in-process (the
set of pages the barrier asked to mark equals the set that reached the
recording body: 517 in both arms).
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3d11221c-fc14-466f-bfdb-c00070760a3e

📥 Commits

Reviewing files that changed from the base of the PR and between 82643fc and ef0ce3f.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/gc/barrier.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-runtime/src/gc/barrier.rs

📝 Walkthrough

Walkthrough

Adds a thread-local one-entry cache for recorded dirty old-generation pages. The write barrier uses cache hits to skip repeated remembered-set insertion. Page metadata removal and clearing invalidate the cache. Telemetry and four GC tests validate cache behavior.

Changes

Dirty old-page cache

Layer / File(s) Summary
Cache contract and runtime wiring
crates/perry-runtime/src/gc/dirty_page_cache.rs, crates/perry-runtime/src/gc/mod.rs
Adds thread-local lookup, recording, invalidation, and test helpers for the dirty-page cache.
Barrier marking and invalidation
crates/perry-runtime/src/gc/barrier.rs, crates/perry-runtime/src/arena/page_meta.rs
Uses cache hits during old-page marking. Records pages after remembered-set and metadata updates. Invalidates the cache when page state is cleared or unregistered.
Telemetry and behavioral validation
crates/perry-runtime/src/gc/telemetry.rs, crates/perry-runtime/src/gc/tests/dirty_page_cache.rs, crates/perry-runtime/src/gc/tests/mod.rs, changelog.d/7298-dirty-page-mark-cache.md
Adds cache-hit telemetry and tests for repeated stores, new pages, invalidation, minor-collection completeness, and metadata consistency. Documents the implementation and benchmark results.

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

Sequence Diagram(s)

sequenceDiagram
  participant WriteBarrier
  participant DirtyPageCache
  participant RememberedSet
  participant PageMetadata
  WriteBarrier->>DirtyPageCache: Check page
  alt Cache hit
    DirtyPageCache-->>WriteBarrier: Page already recorded
  else Cache miss
    WriteBarrier->>RememberedSet: Record dirty page
    WriteBarrier->>PageMetadata: Mark page dirty
    PageMetadata-->>WriteBarrier: Metadata updated
    WriteBarrier->>DirtyPageCache: Cache page
  end
  PageMetadata->>DirtyPageCache: Invalidate on clear or unregister
Loading

Possibly related PRs

  • PerryTS/perry#6831: Both changes modify GC write-barrier dirty-page tracking in gc/barrier.rs.
  • PerryTS/perry#7041: Both changes modify GC dirty-page and remembered-set tracking in gc/barrier.rs.
  • PerryTS/perry#7250: Both changes modify the dirty-page write-barrier path in gc/barrier.rs.

Suggested labels: performance

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: avoiding repeated dirty-page recording in the GC write barrier.
Description check ✅ Passed The description provides detailed context, implementation changes, benchmark results, limitations, and test coverage for the pull request.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
✨ 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/7187b-dirty-page-cache

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.

🧹 Nitpick comments (1)
crates/perry-runtime/src/gc/barrier.rs (1)

1818-1833: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Redundant cache invalidation.

old_page_clear_dirty(page) already invalidates the Phase B cache internally (see page_meta.rs L904-908), as the comment on Line 1827 acknowledges. The explicit super::dirty_page_cache::invalidate() on Line 1830 repeats that invalidation on the same call path.

This is harmless because invalidate() is idempotent, but it contradicts the module's stated design of one invalidation point per falsifying operation. Remove the explicit call, or update the comment to explain why the extra call is intentional.

♻️ Proposed fix to remove the redundant invalidate call
 fn clear_one_dirty_old_page() -> bool {
     DIRTY_OLD_PAGES.with(|s| {
         let mut pages = s.borrow_mut();
         let Some(page) = pages.iter().next().copied() else {
             return false;
         };
-        // Also invalidates the Phase B cache, via `old_page_clear_dirty`.
+        // Invalidates the Phase B cache, via `old_page_clear_dirty`.
         crate::arena::old_page_clear_dirty(page);
         pages.remove(&page);
-        super::dirty_page_cache::invalidate();
         true
     })
 }
🤖 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/gc/barrier.rs` around lines 1818 - 1833, Remove the
redundant super::dirty_page_cache::invalidate() call from
clear_one_dirty_old_page, since crate::arena::old_page_clear_dirty(page) already
performs the cache invalidation. Update the nearby comment if needed so it
attributes invalidation solely to old_page_clear_dirty while preserving the
page-removal behavior.
🤖 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.

Nitpick comments:
In `@crates/perry-runtime/src/gc/barrier.rs`:
- Around line 1818-1833: Remove the redundant
super::dirty_page_cache::invalidate() call from clear_one_dirty_old_page, since
crate::arena::old_page_clear_dirty(page) already performs the cache
invalidation. Update the nearby comment if needed so it attributes invalidation
solely to old_page_clear_dirty while preserving the page-removal behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: da35951a-8fcf-4a08-bac0-5ad3ce44568d

📥 Commits

Reviewing files that changed from the base of the PR and between 4bfbf52 and 82643fc.

📒 Files selected for processing (8)
  • changelog.d/7298-dirty-page-mark-cache.md
  • crates/perry-runtime/src/arena/page_meta.rs
  • crates/perry-runtime/src/gc/barrier.rs
  • crates/perry-runtime/src/gc/dirty_page_cache.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/telemetry.rs
  • crates/perry-runtime/src/gc/tests/dirty_page_cache.rs
  • crates/perry-runtime/src/gc/tests/mod.rs

@proggeramlug
proggeramlug merged commit 5e6d985 into main Aug 3, 2026
7 of 9 checks passed
@proggeramlug
proggeramlug deleted the perf/7187b-dirty-page-cache branch August 3, 2026 09:10
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