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_PAGES hash set (the modbuf)
page_meta.dirty inside the OLD_GEN_PAGE_META hash 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 store
Idempotent ⇒ 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_slot is 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 on batch.ts) became WAYS = 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.
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.
0c0e850e98c30c57f12a869fd7f7d3a510c782b3(v0.5.1573), release build, Linux x86_64 (perrybuilder, EPYC 9354P)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.
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 viaPERRY_KEEP_SYMBOLS=1):Caller chain, confirmed by
--call-graph dwarf:It is a cliff
Allocation work held constant (300 000 chains); only the retained live set varies:
mark_dirty_old_page_uncached3.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, indexedpage & 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.rsrecords its own tuning basis: "Armed onbatch.tsthis 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
old_page_mark_dirtyresolvesOLD_GEN_PAGE_META, which is itself aRefCell<PtrHashMap<usize, PageMeta>>. So one barrier miss = two thread-local resolutions, twoRefCellborrows, 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:
dirty_page_cacheDIRTY_OLD_PAGEShash set (the modbuf)page_meta.dirtyinside theOLD_GEN_PAGE_METAhash mapThe 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:
Idempotent ⇒ 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_METAshould 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:
addr & !(ARENA_SIZE-1). Both are standard; the second keeps the allocator untouched and is still pure arithmetic.js_tls_hot_claim_slotis visible even in startup profiles.Why not just widen the cache
Raising
WAYSfrom 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: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
perfharness 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.