Skip to content

perf(gc): direct-mapped dirty-page cache — sixteen ways instead of one - #9030

Merged
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:perf/dirty-page-ways
Aug 29, 2026
Merged

perf(gc): direct-mapped dirty-page cache — sixteen ways instead of one#9030
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:perf/dirty-page-ways

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

First profiling of the two ECS component-update rows (10k single: 2.35x node; 10k two-component: 3.47x node) found their dominant cost in one place: the write barrier's remembered-set miss path at 35-40% of both framesmark_dirty_old_page_uncached 18-19% self plus _tlv_get_addr 16-18% self (the DIRTY_OLD_PAGES thread-local resolution), plus the hash insert.

Why the one-entry cache misses here

The cache held ONE page, justified by a batch.ts simulation whose stores arrive in long same-page runs (the module doc's table). These rows falsify that shape: each entity's update sweep stores into every component column in turn, so the store pages alternate — the exact pattern a single entry can never hold. Every store then takes the uncached path.

(The distinction from the round-4 'barrier family closed' result: that was the hit path on the command-buffer row, measured memory-bound three ways. This is the miss path on different rows, where the work is avoidable rather than shaveable.)

The change

Sixteen direct-mapped ways in the same hot-TLS home, indexed by the page number's low bits — page numbers are addr >> 12, so neighbouring columns' pages land in distinct ways. Hits bypass the whole uncached path, so the thread-local resolution and the insert stop executing rather than getting cheaper.

  • The per-way invariant is unchanged: a cached page is recorded in DIRTY_OLD_PAGES and stamped in the arena metadata; invalidate clears every way on the same removal paths as before, so the cache can still only suppress a repeat recording, never a first one.
  • The perf(gc): mirror the write barrier's dirty-page cache in a TSD-tagged process global (ECS round 4) #8949 process-global mirror is retired rather than widened — it shaved the single cell's dependent-load chain for +0.17%, and the multi-way map supersedes its mechanism and its rationale.
  • The Phase B test that pinned the one-entry eviction ("returning to the first page misses") now pins the stronger contract: both alternating pages stay cached, completeness asserted unchanged. A new way_tests case pins the alternating-pages regression directly.

Suites

Runtime serial 2784/0; dirty-page suite 22/22, barrier 74/74, tls_hot 10/10. Gate + paired measurements on both update rows (plus a migration-row guard against hit-path regressions) follow in comments; per the usual bar, if the rows do not move this gets closed rather than merged.

Claude-Session: https://claude.ai/code/session_01FUvFrRNZyc5qknBiJbYbby

Summary by CodeRabbit

  • Performance Improvements

    • Improved garbage collection write-barrier tracking for updates that alternate between memory pages.
    • Reduced unnecessary repeat marking and uncached processing during alternating-page workloads.
  • Bug Fixes

    • Preserved accurate dirty-page and remembered-set tracking while handling repeated page access.
  • Tests

    • Added coverage for alternating-page update patterns.
    • Updated expectations to verify cache hits when returning to a previously recorded page.

The write barrier's dirty-page cache held ONE page, on the strength of a
batch.ts simulation whose store pattern was long same-page runs. The ECS
component-update rows falsified that shape: each entity's sweep stores into
every component column in turn, so the store pages ALTERNATE and the single
entry misses almost every time. The uncached path then pays a thread-local
resolution (`_tlv_get_addr`) plus a hash-set insert per store —
`mark_dirty_old_page_uncached` and that resolution together measured 35-40%
of BOTH update rows' frames, the dominant remaining cost on each.

The cache becomes sixteen direct-mapped ways in the same hot-TLS home,
indexed by the page number's low bits — page numbers are `addr >> 12`, so
neighbouring columns' pages land in distinct ways (the one access pattern
low-bit indexing is exactly right for). Hits bypass the whole uncached path,
so the thread-local and the insert stop executing rather than getting
cheaper. The invariant is unchanged and per-way: a cached page is recorded
in `DIRTY_OLD_PAGES` and stamped in the arena metadata; `invalidate` clears
every way on the same removal paths as before.

The PerryTS#8949 process-global mirror is retired rather than widened: it existed
to shave the single cell's dependent-load chain, bought +0.17% then, and the
multi-way map supersedes both its mechanism and its rationale.

The Phase B test that pinned the one-entry eviction ("returning to the first
page misses") now pins the stronger contract: both alternating pages stay
cached, and the completeness property — the cache only ever suppresses a
repeat recording, never a first one — is asserted unchanged.

Claude-Session: https://claude.ai/code/session_01FUvFrRNZyc5qknBiJbYbby
@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bde5b995-ea3b-41fe-bf3d-ddd143b1b4c0

📥 Commits

Reviewing files that changed from the base of the PR and between 2e82716 and 08ad95a.

📒 Files selected for processing (2)
  • changelog.d/9030-dirty-page-cache-ways.md
  • crates/perry-runtime/src/tls_hot.rs

📝 Walkthrough

Walkthrough

The dirty-page cache changes from one entry to a 16-way direct-mapped cache. HotTls stores all ways, cache operations use page-indexed entries, Apple-specific mirroring is removed, and tests cover alternating pages and invalidation.

Changes

Dirty-page cache widening

Layer / File(s) Summary
Cache storage and initialization
crates/perry-runtime/src/gc/dirty_page_cache.rs, crates/perry-runtime/src/tls_hot.rs
The runtime defines 16 cache ways indexed by page low bits. HotTls initializes each way to usize::MAX. Apple-specific TSD mirror support is removed.
Cache operations and coverage
crates/perry-runtime/src/gc/dirty_page_cache.rs, crates/perry-runtime/src/gc/tests/dirty_page_cache.rs, changelog.d/9030-dirty-page-cache-ways.md
Cache probes, updates, and invalidation use all 16 ways. Tests verify alternating pages remain cached and that the updated hit count is one. The changelog documents the cache design and behavior.

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

Merge Risk: ⚪ Minimal · up to 2e827

The PR expands the internal dirty-page cache so alternating component pages can avoid repeated remembered-set work while preserving existing GC recording and invalidation behavior. No actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description provides detailed context, rationale, implementation details, and test results, but it does not follow the required template. It omits the required section headings and does not provid… Rewrite the description using the repository template. Add Summary, Changes, Related issue (or "n/a"), Test plan with completed checks, Screenshots / output if applicable, and Checklist with completed items.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: replacing the single-entry dirty-page cache with sixteen direct-mapped ways.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 3 files.
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.
Full details: Description check

Explanation

The description provides detailed context, rationale, implementation details, and test results, but it does not follow the required template. It omits the required section headings and does not provide a Related issue entry or checklist status.

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Measured on the quiet host — 9 alternating pairs per row, control = merge base f13e1dd75:

row control candidate improvement wins
10k two component updates 11.592 ms 11.634 ms +0.31% 6/9
10k single component update 6.346 ms 6.262 ms +1.07% 8/9
4k migration (guard) 44.459 ms 44.195 ms +0.37% 8/9

The two-component row — where alternating column pages made the strongest mechanistic case — moved 0.3%. That is a null against a 35-40% attribution, and this PR set its own bar: closing rather than merging.

What this establishes

This is now the fifth instance in this campaign of a profile self-time attribution on a runtime/GC leaf failing to convert to frame time (barrier-entry mrs 46% → 0.00% twice, rekey scanner 9.4% → −0.29%, and now mark_dirty_old_page_uncached + _tlv_get_addr at 35-40% → +0.3-1.1%). The attributions that have converted were algorithmic fixes proven by operation-level microbenchmarks first (Map.delete: 405x on the operation → +15% on the row) or user-visible protocol costs.

Two possible readings of this null, both actionable: either the one-entry cache was already hitting (the alternating-pages theory is wrong about the actual store order after command-buffer grouping — plausible, since component values here are numbers, whose stores skip the remembered set entirely at the child prologue), or the sampled time is attribution skid across the barrier's call boundary. Distinguishing them needs the diagnostics-feature rebuild for real hit/miss counters — which is what should have preceded this build, per the counters-first lesson already in this campaign's notes.

The change itself is behaviorally sound (serial 2784/0, the strengthened Phase B contract passes), so if counters later show genuine thrash on some other workload, this branch is recoverable from the PR. Not merged on a null.

@proggeramlug
proggeramlug deleted the perf/dirty-page-ways branch August 29, 2026 10:31
Retiring the PerryTS#8949 process-global mirror left `darwin_tsd::base()` with no
callers, so `-D warnings` failed the build on dead_code. Removed it, along with
a doc paragraph that described the mirror and is now false.

Its doc block had also absorbed `get()`'s: the two `///` runs were contiguous,
so "Read thread-specific-data slot `slot`" plus its `# Safety` clause were
attached to `base()` while `get()` -- the `unsafe fn` that clause is ABOUT --
carried none. Deleting `base()` would have taken that safety documentation with
it, so it is moved back onto `get()`.

Also adds the missing changelog.d fragment.
@proggeramlug
proggeramlug restored the perf/dirty-page-ways branch August 29, 2026 10:45
@proggeramlug proggeramlug reopened this Aug 29, 2026
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merged. First, an apology and a correction: I briefly closed this PR. My initial push reported * [new branch], which contradicted the PR tracking that same branch — and instead of investigating that contradiction I "cleaned up" the branch I thought I had created by mistake, which deleted the PR's head and auto-closed it. The head commit 2e8271679e was never lost; I restored the branch at exactly that SHA, reopened, and then pushed on top, so the review history is intact. Entirely my error, and the lesson is that [new branch] on a push to an existing PR's branch is a signal to stop, not to tidy up.

On the change itself — the property that matters is that the cache can only ever suppress a repeat recording, never a first one, and it survives the widening:

  • a way answers "already marked" only on an exact page compare, so a stale way answers not marked and the store takes the recording path — the conservative direction;
  • invalidate() clears every way, which is what keeps a stale way from suppressing a first recording after a page leaves DIRTY_OLD_PAGES.

I sabotage-checked the second one specifically, since it is the whole safety argument: making invalidate() clear only way 0 fails a dirty-page test. So that property is pinned, not just true.

The HotTls growth is safe for codegen. dirty_old_pages sits after both offsets generated code hardcodes (inline_state at 8, implicit_this at 128), so nothing emitted moved — confirmed by hot_tls_layout_is_what_codegen_assumes, which binds those two literals to the runtime source.

Also good that the Phase B test was strengthened rather than relaxed: dirty_page_cache_hits going 0 → 1 pins the new contract (both alternating pages stay cached) while keeping the completeness assertions (new_dirty_pages == 0, count still 2) exactly as they were.

Fixed on the branch

-D warnings was failing: retiring the #8949 mirror left darwin_tsd::base() with no callers. Removed it and the doc paragraph describing the mirror, which is now false.

While doing that I found a pre-existing doc bug worth mentioning: base()'s doc block had absorbed get()'s — the two /// runs were contiguous, so "Read thread-specific-data slot slot" and its # Safety clause were attached to base(), while get(), the unsafe fn that clause is actually about, carried none. Deleting base() would have silently taken that safety documentation with it, so I moved it back onto get().

Added the missing changelog.d/ fragment.

Validation: perry-runtime --lib 2805/0 (22 dirty-page tests), perry-codegen 1343/0, fmt --check, run_lint_gates.sh all 60 gates passed; 2 CI-only skipped.

@proggeramlug
proggeramlug merged commit 9616c01 into PerryTS:main Aug 29, 2026
16 of 19 checks passed
proggeramlug pushed a commit that referenced this pull request Aug 29, 2026
Inserting `pub(crate) use match_all::dispatch_regexp_string_iterator_method_builtin`
between the existing `#[cfg(feature = "regex-engine")]` and the `pub use` below
it moved the attribute onto the NEW line, leaving the original export ungated.
With the feature off, `perry-runtime` then names a module that does not exist:

    error[E0432]: unresolved import `match_all`

It passes `cargo test -p perry-runtime --lib` (default features on) and fails
`cargo check -p perry`, which is why it was invisible to the crate-level run.

Same attribute-stealing shape as the doc comments repaired in #9013 and #9030 —
an inserted line silently inherits the attribute or doc block above it.
proggeramlug added a commit that referenced this pull request Aug 29, 2026
…top corrupting state (#9019) (#9066)

* fix(runtime): reserve iterator raw-field floor so own next patches stop corrupting state (#9019)

A by-name property write on a builtin collection iterator object derived
its field index from the (empty) keys array, so the first user property
landed at field 0 and overwrote the backing-collection pointer. it.foo = 1
made iteration report done immediately; it.next = fn made the next builtin
advance dereference the closure as a SetHeader and SIGSEGV under for...of.

Storage: the first by-name append to a reserved-layout receiver (array/
map/set/string/buffer/regexp iterators, iterator helpers) now seeds the
keys array with floor leading tombstones (the #9038 hole marker every
lookup/enumeration/delete path already skips), so user keys append past
the raw internal fields; the hole-squeeze compaction preserves the
reserved prefix.

Dispatch: the class-id iterator dispatchers honor an own next before the
builtin advance (non-callable own values throw per IteratorNext), while
the canonical prototype thunks keep running the builtin algorithm so a
patch delegating to its bound original cannot re-enter itself. The fused
for...of arms validate the iterator result, and the stored-closure drain
paths bind this to the iterator per Call(next, iterator).

* docs: changelog fragment for #9066

* refactor(runtime): keep the reserved-floor seed out of the raw-handle ledger

NaN-boxed handles in ensure_reserved_floor_keys and the existing
refresh_roots_after_alloc macro (moved above the seed hook) in the by-name
tail, so scripts/raw_handle_debt.py stays within its ceilings.

* fix(runtime): close the defineProperty and entry-lane append surfaces for reserved floors (#9019)

ensure_key_in_keys_array (the accessor-define keys claim) seeds the
reserved floor before its keys-null create arm, and the entry-lane
transition cache declines reserved-layout class ids so an unseeded
iterator can never receive a foreign sub-floor slot from an edge minted
by another keyless family sharing its birth ShapeId.

* fix(runtime): restore the regex-engine cfg the new export took

Inserting `pub(crate) use match_all::dispatch_regexp_string_iterator_method_builtin`
between the existing `#[cfg(feature = "regex-engine")]` and the `pub use` below
it moved the attribute onto the NEW line, leaving the original export ungated.
With the feature off, `perry-runtime` then names a module that does not exist:

    error[E0432]: unresolved import `match_all`

It passes `cargo test -p perry-runtime --lib` (default features on) and fails
`cargo check -p perry`, which is why it was invisible to the crate-level run.

Same attribute-stealing shape as the doc comments repaired in #9013 and #9030 —
an inserted line silently inherits the attribute or doc block above it.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
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