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

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

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