Repository navigation
GC: the old→young write barrier is ~47% of all instructions and fails as a cliff — the dirty-page set is the wrong data structure (needs a card table, not a wider cache) #10350
Description
Summary
The old→young write barrier is ~47% of all retired instructions on an ordinary allocation workload, and it fails as a cliff, not a gradient: 0.00% at a small live set, 34.6% at a large one, with the allocation work held constant.
The cause is structural, not a tuning miss. The barrier maintains an exact set of dirty pages in address-keyed hash maps, and dirtiness does not need a set — it is idempotent. Everything expensive here exists to support a data structure the problem does not call for. This issue asks for the structural fix (a card table), not a wider cache; see "Why not just widen the cache" below.
- Version:
0c0e850e98c30c57f12a869fd7f7d3a510c782b3(v0.5.1573), release build, Linux x86_64 (perrybuilder, EPYC 9354P) - Measured with
perf stat -e instructions:u, min-of-N (instruction counts are load-insensitive; this box is shared)
Measurement
Benchmark: 300 000 allocated 8-node chains, each node carrying a small array, into a rolling retained ring. Plain JS, identical source on all three runtimes, verified to produce byte-identical output.
| instructions | cycles | IPC | |
|---|---|---|---|
| perry | 7.28 G | 1.68 G | 4.32 |
| node v26.8.1 | 1.46 G | 1.14 G | 1.28 |
| bun 1.4.2 | 1.31 G | 0.90 G | 1.46 |
perry retires 5.0× node / 5.5× bun instructions for the same work. Note the IPC: at 4.32 perry is not memory-stalled — it is executing too many instructions. Pure allocation (1 M short-lived objects) is 546 instr/object vs node 178, bun 102.
Profile (perf record -e instructions:u, self time, symbols via PERRY_KEEP_SYMBOLS=1):
34.6–36.2% perry_runtime::gc::barrier::mark_dirty_old_page_uncached
12.3% <hashbrown::HashMap<usize,(),PtrHasher>>::insert (DIRTY_OLD_PAGES)
16.7% perry_runtime::array::header::rebuild_array_numeric_raw_f64
4.1% perry_runtime::gc::layout_slot_visit::visit_gc_layout_slot_descriptors
Caller chain, confirmed by --call-graph dwarf:
main → write_barrier_decoded_parent → remember_old_to_young_slot
→ mark_dirty_old_page → (cache miss) → mark_dirty_old_page_uncached
It is a cliff
Allocation work held constant (300 000 chains); only the retained live set varies:
| live set | mark_dirty_old_page_uncached |
ptr-hash insert | non-GC | total instructions |
|---|---|---|---|---|
| 1 000 chains | 0.00 % | 1.08 % | 82.8 % | 2.35 G |
| 5 000 chains | 2.66 G | |||
| 20 000 chains | 4.62 G | |||
| 40 000 chains | 34.64 % | 12.25 % | 33.8 % | 7.39 G |
3.15× the instructions for identical allocation work. Over the same sweep node grows 1.8× and bun 2.0×, so perry pays roughly 8× more per unit of live-set growth.
The transition is the associativity of dirty_page_cache. WAYS = 16, indexed page & 15. While the hot old-page set fits in 16 ways the uncached path is entirely absent from the profile; once it does not, the hit rate collapses toward zero and every barrier store pays the full miss path.
dirty_page_cache.rs records its own tuning basis: "Armed on batch.ts this call fires 1 774 374 times for 517 distinct pages". The design was validated where the dirty-page set is tiny. A ~30 MB live set at 4 KB granularity is several thousand pages.
What the miss path actually costs
fn mark_dirty_old_page_uncached(page: usize) -> bool {
bump_write_barrier_trace_counter(...);
ever_dirty_note(page);
let inserted = DIRTY_OLD_PAGES.with(|s| s.borrow_mut().insert(page)); // TLS + RefCell + hash insert
if crate::arena::old_page_mark_dirty(page) { // TLS + RefCell + hash lookup
dirty_page_cache::note_dirty_old_page_marked(page);
}
inserted
}old_page_mark_dirty resolves OLD_GEN_PAGE_META, which is itself a RefCell<PtrHashMap<usize, PageMeta>>. So one barrier miss = two thread-local resolutions, two RefCell borrows, and two address-keyed hash operations — to record one bit.
There is no card table anywhere in the runtime (grep -riE 'card_table|CARD_SHIFT' → no hits).
Why this is structural
Three separate representations of "this page is dirty" must be kept in agreement:
- the 16-way thread-local
dirty_page_cache - the thread-local
DIRTY_OLD_PAGEShash set (the modbuf) page_meta.dirtyinside theOLD_GEN_PAGE_METAhash map
The code already documents that these can diverge — the cache may only be written when the arena stamp also landed ("Half a recording is not a recording"). Three structures, one fact, a documented consistency hazard, and a cache that exists only to avoid touching the other two.
The root assumption is the mistake: a dirty page set is maintained as a set, so insertion must deduplicate, so it hashes, so it needs a front cache, so it has associativity, so it has a cliff. Marking a page dirty is idempotent — it needs no dedup and therefore no set.
Proposed fix: a real card table
Standard practice in HotSpot, V8, .NET and JSC, and it removes every layer above:
1. Mark by arithmetic, not lookup. A flat card table covering the old generation, one byte (or bit) per card:
card_table[(addr - heap_base) >> CARD_SHIFT] = DIRTY; // unconditional storeIdempotent ⇒ no dedup ⇒ no hash ⇒ no cache ⇒ no associativity and no cliff. The barrier becomes a shift, an add and a store, with no branch and no TLS resolution. Cost is flat in the number of dirty pages, which is exactly the property the current design lacks.
2. Put page metadata in the page, not in a side map. OLD_GEN_PAGE_META should be reachable from the address by masking to the arena/page header rather than hashing the page number. This is the same address-keyed-side-table pattern already retired elsewhere in the codebase (cf. #9792 layout metadata, and the native-instance keying work) and it removes the second hash on the miss path.
3. One source of truth. The card table subsumes the cache, the modbuf and page_meta.dirty. The consistency hazard above disappears with the structures that create it.
4. Scanning improves too. A minor collection currently iterates a hash set in arbitrary order, pointer-chasing per entry. A card table scan is a dense linear sweep — cache-friendly and vectorizable (a SIMD compare clears 32–64 cards per step).
Design points that need deciding, not hand-waving:
- Discontiguous old gen. If old pages come from scattered mimalloc arenas, a single flat table needs either a reserved contiguous virtual range for the old generation, or a per-arena card table in the arena header reached by
addr & !(ARENA_SIZE-1). Both are standard; the second keeps the allocator untouched and is still pure arithmetic. - Scan cost is proportional to heap size, not dirty count. For a large heap with few dirty cards a naive linear scan can lose to iterating a small set. The usual answer is a two-level table (a summary byte per N cards), which is what HotSpot and .NET do. Worth building in from the start rather than discovering later.
- Card granularity. 512 B cards cost 0.2 % of heap; 4 KB (matching today's page granularity) costs 0.024 %. Finer cards mean less re-scanning of clean objects but a bigger table.
- Threading. The current modbuf is per-thread. A process-global card table needs no atomics for marking (a plain byte store suffices) and removes TLS resolution from the barrier entirely —
js_tls_hot_claim_slotis visible even in startup profiles.
Why not just widen the cache
Raising WAYS from 16 to 256 or 1024 would move the cliff to a larger live set and would measure well on this benchmark. It should not be done as the fix:
- the cliff still exists, just at a heap size nobody tested;
- both hash operations remain on the miss path;
- all three representations remain, and so does the divergence hazard;
- it is one more tuning constant chosen against one workload, which is exactly how
WAYS = 1(tuned onbatch.ts) becameWAYS = 16(tuned on ECS rows) and now fails here.
The cache is compensating for the wrong data structure. Replacing the structure deletes the cache.
Correctness payoff
#10348 is a silent old-gen corruption whose verifier output reports remembered=no ... dirty_snapshot=no — precisely a disagreement between these representations, on this barrier. Collapsing them into a single card table removes that entire failure class, so this is one piece of work with both a ~47 % instruction win and a data-loss fix.
Repro
Benchmark sources, the live-set sweep and the perf harness are self-contained plain JS/TS (same file runs on perry, node and bun); happy to attach or upstream them into a bench directory on request.
Activity
proggeramlug commentedon Sep 16, 2026
Closing this — the measurement behind it was invalid
I filed this after profiling an allocation benchmark on
0c0e850e9. PR #10352 (fixing #10348) shows the profile I built it on was a symptom of that bug, not an independent architectural problem. Retracting rather than leaving it open, because the proposal here is expensive and someone could reasonably act on it.What I got wrong
The central claim was that
mark_dirty_old_page_uncachedis ~35 % of all retired instructions and that the 16-way associativity ofdirty_page_cacheis a structural cliff. On a build with #10352 applied, the same benchmark shows:The barrier is simply not hot any more. Post-fix the profile is ordinary tracing work (
visit_gc_layout_slot_descriptors9.4 %,memmove13.4 %,mark_addr7.1 %).The check that settles it
The strongest test is a workload with genuine old→young edges and no involvement of #10348's pointer mask: 40 000 long-lived holders, each repeatedly overwritten with a fresh young object, with the pointed-at field object-typed from birth so its mask is correct on both builds.
mark_dirty_old_page_uncacheddoes not appear in the top symbols of that workload on either build, and the two builds are within noise of each other (1.514 G vs 1.534 G instructions, +1.3 %).So the barrier only became hot in the presence of the mask bug. With
nextwrongly excluded from the pointer mask the chains were never evacuated as units, the object graph stayed fragmented across generations, and that manufactured the old→young edge volume across thousands of pages that saturated the cache. The cache was responding correctly to a pathological input.What this means for the proposal
The card-table argument in the issue body is not supported by evidence any more, and it should not be implemented on the strength of it. My "why not just widen the cache" section argued against treating a symptom — and the issue itself was doing exactly that one level up, treating the barrier as the disease when it was downstream of a type-lowering defect. The real fix was two edits in
perry-hir/src/lower/.Two things in the body are still true as plain code observations, independent of any performance claim, if anyone wants to pick them up separately:
DIRTY_OLD_PAGES, andpage_meta.dirtyinsideOLD_GEN_PAGE_META) that must be kept in agreement, with a documented consistency hazard;OLD_GEN_PAGE_METAis an address-keyedPtrHashMapreached through a TLSRefCell, where masking to a page/arena header would be arithmetic.Neither is currently a measured bottleneck. They would need their own evidence.
What does survive
perry still retires far more instructions than node/bun on GC-heavy work, on the fixed build —
gc3at a 40 k live set is 12.82 G vs node 1.25 G and bun 1.38 G. That gap is real and is now attributable to tracing and layout side-table work rather than the barrier. If it deserves an issue it deserves a fresh one, profiled on a correct build, and I would rather open that than repurpose this one.Apologies for the noise — filing an architecture change off a single contaminated profile was the mistake.
proggeramlug commentedon Sep 16, 2026
Correction: right conclusion, wrong mechanism
Staying closed — but the reason I gave in the retraction above is wrong, and the corrected version matters for anyone deciding what to do with the card-table branch.
What I said: the barrier was ~35% of instructions because of #10348's pointer-mask bug, and PR #10352 removes it.
What is actually true: the barrier was hot only on
0c0e850e9(v0.5.1573), the six-release-stale reference tree I profiled. Onfcd108bfb(v0.5.1579, current main) — before #10352, with #10348 fully present (measureddangling=15018) —mark_dirty_old_page_uncachedis already absent from the profile and thePtrHasherinsert is 0.02%.Since the mask bug is present in both builds and the barrier is hot in only one, the mask bug cannot be what made it hot.
WAYSis 16 in both trees and nothing toucheddirty_page_cache.rsorbarrier/between them, so the cause is elsewhere in v0.5.1574–1579 — plausibly #10295 changing which instances take the typed-layout path, but I have not bisected it and am not claiming it.Net effect on this issue: the closure is more justified, not less. The premise doesn't describe current main at all, and it never needed #10352 to stop being true. The card table has no perf case on today's tree.
What this changes for anyone holding that branch: re-baselining on #10352 is no longer the decisive experiment I suggested, because the barrier is already cold on the base without it. The experiment that matters is simply is the barrier measurably expensive on current main — and on gc3 at a 40k live set the answer is no.
My original error was profiling a stale build, and my correction of it then misattributed the difference to the one change I happened to be looking at. Both are on me; the measurements are in the thread on #10352.
proggeramlug commentedon Sep 16, 2026
Implemented the card table and measured it on current main — it buys nothing
I built the proposal in this issue before the retraction landed, so the branch exists and is measured. Posting the numbers because they close the question from the implementation side rather than the profiling side, and the retraction above deserves a confirmation that isn't just a second profile.
Summary: on
v0.5.1579neither structure is visible in the profile.mark_dirty_old_page_uncachedis absent from the baseline, and the card-table arm has nocard_tablesymbols either — the mark inlines and never gets hot. There is nothing left for the rewrite to remove.A/B, both arms on v0.5.1579
base=fcd108bfb(v0.5.1579).fix= the same commit plus the card table:DIRTY_OLD_PAGES, the 16-waydirty_page_cacheandOldPageMeta::dirtyall replaced by one byte-per-page table with a summary level. Min-of-8perf stat -e instructions:u, perrybuilder (EPYC 9354P), allocation work held constant and only the live set varied.oldyoungis the control from the retraction: 40 000 long-lived holders each repeatedly overwritten with a fresh young object, with the pointed-at field object-typed from birth so its pointer mask is correct either way. Genuine old→young edges, no #10348 involvement.The card table is consistently a hair slower, not faster. The ordering is stable across all 8 runs of each fixture, so it is not noise — it is small but real. The mechanism is straightforward: the barrier's cheapest exit now pays a bounds check plus a byte load where the 16-way probe was a compare against a thread-local cell, and on these workloads the old cache was hitting. The structure I replaced was doing its job here.
The cliff is not there to remove
The issue body reported the baseline rising 2.35 G → 7.39 G across this sweep (3.15×), with 34.6% of instructions in
mark_dirty_old_page_uncached. Onv0.5.1579the same sweep on the same fixtures rises 0.82 G → 2.34 G (2.85×), and the card table reproduces that same 2.85×.Symbolized profile of the baseline arm at
w40000,perf record -e instructions:u:Two independent harnesses agree to four significant figures on the same binaries (w1000: 823,122,845 here vs 823,146,144 from @proggeramlug's separate run; w40000: 2,343,768,944 vs 2,343,711,534), so this is not a harness artifact on either side.
Correctness, for the record
PERRY_GC_FROMSPACE_SCAN=1walks every live word after the rewrite pass and counts references to non-forwarded from-space objects, which is how a missing old→young edge would surface. It has a nonzero dangling floor onv0.5.1579because #10348 is present there, so it can only gate a barrier rewrite on a base where the floor is 0. Re-baselined both arms onto #10352 and ran it there:That means no missing edge on the fixtures we ran — it is a strong negative for these shapes and not a proof of soundness in general. Output was byte-identical across base, card table and node v26.8.1 on all six fixtures, which is a no-regression signal and likewise not a soundness one.
Re-baselined A/B on #10352 agrees with the v0.5.1579 result:
w4000012.5726 G vs 12.5745 G (+0.015%),oldyoung1.5255 G vs 1.5266 G (+0.070%).Not pursuing it
I am not opening a PR. There is no performance case, and I declined to write a scattered-store fixture that would have produced one — it would only demonstrate that 16 ways can be made to miss, which was never in doubt and was never the question.
The two code observations in the retraction survive and are unaffected by any of this: three representations of "this page is dirty" that must be kept in agreement, and
OLD_GEN_PAGE_METAas an address-keyedPtrHashMapbehind a TLSRefCell. The first is worth restating precisely because it is a correctness argument rather than a performance one — theremembered=no … dirty_snapshot=noshape in #10348's verifier output is two of those three representations disagreeing, and a single representation cannot disagree with itself. But neither is a measured bottleneck, and neither justifies a rewrite of this size on its own. If someone picks them up later, they need their own evidence.One process note, since it cost a day's work and the fix is trivial: a perf issue proposing a rewrite should name the commit it was measured on in the body. The 34.6% here was a true measurement of
0c0e850e9(v0.5.1573). It stopped describing main six releases later, and nothing in the issue made that checkable. The mirror of that rule belongs on the implementing side too — re-check that a cited number still reproduces before building on it. Both would have caught this independently.