Skip to content

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

Closed as not planned

Description

@proggeramlug

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:

  1. the 16-way thread-local dirty_page_cache
  2. the thread-local DIRTY_OLD_PAGES hash set (the modbuf)
  3. 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.

Activity

  1. proggeramlug commented on Sep 16, 2026

    @proggeramlug
    ContributorAuthor

    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_uncached is ~35 % of all retired instructions and that the 16-way associativity of dirty_page_cache is a structural cliff. On a build with #10352 applied, the same benchmark shows:

    mark_dirty_old_page_uncached   34.6 %  ->  0.00 %
    PtrHasher HashMap::insert      12.3 %  ->  0.01 %
    

    The barrier is simply not hot any more. Post-fix the profile is ordinary tracing work (visit_gc_layout_slot_descriptors 9.4 %, memmove 13.4 %, mark_addr 7.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_uncached does 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 next wrongly 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:

    • there are three representations of "this page is dirty" (the 16-way cache, DIRTY_OLD_PAGES, and page_meta.dirty inside OLD_GEN_PAGE_META) that must be kept in agreement, with a documented consistency hazard;
    • OLD_GEN_PAGE_META is an address-keyed PtrHashMap reached through a TLS RefCell, 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 — gc3 at 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.

  2. proggeramlug commented on Sep 16, 2026

    @proggeramlug
    ContributorAuthor

    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. On fcd108bfb (v0.5.1579, current main) — before #10352, with #10348 fully present (measured dangling=15018) — mark_dirty_old_page_uncached is already absent from the profile and the PtrHasher insert 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. WAYS is 16 in both trees and nothing touched dirty_page_cache.rs or barrier/ 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.

  3. proggeramlug commented on Sep 16, 2026

    @proggeramlug
    ContributorAuthor

    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.1579 neither structure is visible in the profile. mark_dirty_old_page_uncached is absent from the baseline, and the card-table arm has no card_table symbols 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-way dirty_page_cache and OldPageMeta::dirty all replaced by one byte-per-page table with a summary level. Min-of-8 perf stat -e instructions:u, perrybuilder (EPYC 9354P), allocation work held constant and only the live set varied.

    fixture base + card table delta
    l4_alloc1m 320,255,392 320,254,953 −0.000%
    w1000 823,122,845 823,998,940 +0.106%
    w5000 977,988,642 978,856,536 +0.089%
    w20000 1,374,891,848 1,375,524,655 +0.046%
    w40000 2,343,768,944 2,343,936,136 +0.007%
    oldyoung 1,525,503,784 1,526,597,419 +0.072%

    oldyoung is 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. On v0.5.1579 the 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:

    20.31%  perry_fn_w40000_ts__makeChain$spec_i32
    12.48%  perry_runtime::gc::layout_slot_visit::visit_gc_layout_slot_descriptors
     7.63%  __memmove_avx512_unaligned_erms
     6.77%  <CopyingNurseryCollector>::mark_addr
     5.84%  js_string_concat_value_box
     ----    mark_dirty_old_page_uncached        ABSENT
     0.03%  HashMap<usize,(),PtrHasher>::insert  (the issue reported 12.3%)
    

    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=1 walks 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 on v0.5.1579 because #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:

    w40000    base  objects=1280034 words=6440148 missing_rewrites=0 dangling=0
    w40000    fix   objects=1280034 words=6440148 missing_rewrites=0 dangling=0
    oldyoung  base  objects=84121   words=388201  missing_rewrites=0 dangling=0
    oldyoung  fix   objects=84121   words=388201  missing_rewrites=0 dangling=0
    

    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: w40000 12.5726 G vs 12.5745 G (+0.015%), oldyoung 1.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_META as an address-keyed PtrHashMap behind a TLS RefCell. The first is worth restating precisely because it is a correctness argument rather than a performance one — the remembered=no … dirty_snapshot=no shape 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions