Skip to content

gc/perf: classify_heap_generation is 19% of batch.ts (57.4% total GC bookkeeping) with ZERO collections running — write-barrier tower needs its own lever (#5094 evidence) #7187

Closed

Description

@proggeramlug

The batch.ts ranked profile (Pi 5, perf record instructions:u, THROTTLE==0 asserted, 99.85% coverage; full account on #7170) found:

57.4% of the program is GC bookkeeping — and zero collections run (PERRY_GC_DIAG silent, no mark/sweep symbol appears). The largest single symbol is classify_heap_generation at 19.03% (657M instructions) — 2.3× the next symbol — called from the write barrier on the sort's own element stores. The layout side-table machinery adds another 12.91%.

Per comparison: ~2,597 instructions, of which sort scaffolding (dominated by barrier/layout bookkeeping) costs 1,495 — more than the user's comparator logic (1,103).

Why this needs its own lever

The planned levers were mapped against the profile: #6759 (shape tree) squarely hits the 12.91% layout side-table; #7151/#7170-R1 hit generated-code costs. Nothing planned addresses rank 1. A write barrier that spends 19% of the program classifying heap generations for a heap that has never collected is pure overhead by construction.

Candidate directions (to be designed, not prescribed):

Also from the same profile

  • utf16_cmp_bytes (string/compare.rs:15) runs from_utf8 validation + UTF-16 re-encode on BOTH operands per comparison — an ASCII fast path deletes ~3.1% of total program instructions (separate, trivial PR).
  • The vdispatch-tower lever is NOT supported by this workload: comparator dispatch resolves a direct fn pointer once (array/sort.rs:23); total closure dispatch 0.08%.

Activity

  1. proggeramlug commented on Aug 1, 2026

    @proggeramlug
    ContributorAuthor

    Design: where the 19.03% actually goes, and the correction the lazy-arming premise needs

    Read-only session (see §9 — the local disk floor blocked the one build, so nothing
    here is landed). Everything below is derived from the code at d8be5f4a2 plus the
    profile posted on #7170; anything I could not derive is called out in §8.


    1. The premise in the issue statement is wrong, and the fix is the interesting part

    "until the first minor collection, there is no young/old distinction worth recording"

    There is. arena_alloc_gc (arena/allocators.rs:241) routes any allocation whose
    padded total exceeds LARGE_OBJECT_THRESHOLD_BYTES (16 KiB, gc/types.rs:69)
    straight to arena_alloc_gc_old and stamps GC_FLAG_TENURED. Old-gen exists
    from the first large allocation, before any collector has ever run
    — and
    batch.ts is exactly that case: N = 40_000 elements × 8 B = 320 KiB, so copy
    and its merge scratch are both born-old, while every Shaped record they hold is
    nursery. Every one of the sort's element moves is a genuine old→young edge that
    the first minor will need.

    This is not hypothetical: the comment on note_array_slot_layout_only
    (array/header.rs:1556) is the postmortem of the last time this was got wrong —
    "155 edges, all born-old array→young object; slot_page_ever_dirty=false = the
    store never hit any barrier"
    , JSON.parse filling born-old arrays, children swept
    live, value is not a function. A barrier that no-ops behind a naive "nothing has
    collected yet" flag reproduces that bug exactly, and it reproduces it on the
    workload that motivated this issue.

    So lazy arming is still right, but its contract has to be stated on the
    collection side, not the barrier side:

    While unarmed, the barrier records nothing. In exchange, the first collection
    on a thread does not read the remembered set at all
    — it reconstructs the
    old→young edge set by walking old-gen + the malloc registry — and arms the
    barrier on the way out.

    Equivalently: unarmed ⇒ every old page is dirty. That is sound by construction
    rather than by audit, and it is bounded: during the unarmed window old-gen holds
    only born-old objects (nothing has been promoted, because nothing has collected),
    each ≥ 16 KiB, so the walk is at most old_bytes / 16 KiB objects. On batch.ts
    that is 2 objects.

    The primitive already exists and is already used by full cycles:
    OldToYoungRememberedRebuildState (gc/verify.rs:269), reached via
    rebuild_minor_old_to_young_remembered_set(). It is budgeted/steppable, it already
    filters on barrier_parent_needs_remembering, and it already covers both the arena
    cursor and MALLOC_STATE.objects. The unarmed first collection wants a narrower
    variant (old-gen arena only, via old_arena_walk_objects, plus the malloc
    registry the minor already walks for its sweep) rather than the whole-heap
    ArenaObjectCursor.


    2. The two halves, separated at the call site

    The good news: the split is already physical in the source. The never-skippable
    half and the skippable half are adjacent statements, not interleaved logic.

    array/header.rs:1545:

    pub(crate) unsafe fn note_array_slot(arr: *mut ArrayHeader, index: usize, value_bits: u64) {
        let value_bits = canonicalize_array_numeric_store_bits(arr, value_bits);
        std::ptr::write(array_elements_ptr(arr).add(index), value_bits);
        note_array_numeric_index_write(arr, index, value_bits);
        crate::gc::layout_note_slot(arr as usize, index, value_bits);   // <-- NEVER skippable
        let slot = array_elements_ptr(arr).add(index) as usize;
        crate::gc::runtime_write_barrier_slot(arr as usize, slot, value_bits); // <-- the lever
    }

    runtime_store_jsvalue_slot (gc/barrier.rs:1262) has the identical shape.

    Never skippable — the #7179 class, one line per site:

    site why an arming check must not reach it
    layout_note_slot (gc/barrier.rs:1289, array/header.rs:1550) This is #7179. Objects are born POINTER_FREE; heap_payload_slot_selection short-circuits on that state and skips the entire payload. A slot whose pointer was never recorded is invisible to the FIRST minor — not imprecise, invisible. Arming has nothing to do with it: the layout state is consumed by every collection including the first.
    canonicalize_{array_numeric,typed_slot}_store_bits Representation invariant (an INT32 box landing in a raw-f64 masked slot permanently evicts the typed descriptor). Runs before the store, unrelated to generations.
    js_string_addref on STRING_TAG (gc/barrier.rs:1287) Aliasing/refcount contract for in-place js_string_append. Nothing to do with GC generations.
    incremental_mark_barrier_value(child) (gc/barrier.rs:974) SATB/insertion shading for an in-progress incremental cycle. Note this one is inside write_barrier_slot_inner, before the parent decode — the arming check must sit after it, and structurally it can never be a problem (an incremental cycle implies a collection has run implies armed), but the ordering must be written down or a later refactor will hoist the check. runtime_write_barrier_slot's existing !write_barriers_enabled() arm already demonstrates the intended separation: it skips the barrier and still calls incremental_mark_barrier_value.

    Skippable while unarmed — all of it inside write_barrier_slot_inner after the
    child decode and the incremental probe:

    • barrier_parent_needs_remembering(parent_addr, external_slot)
    • remembered_child_needs_tracking(child_addr)
    • remember_old_to_young_slot / remember_old_to_young_external_slot
    • mark_dirty_old_page / mark_dirty_parent_span / mark_dirty_external_slot_page
    • the EVER_DIRTY_OLD_PAGES note inside them (diagnostic-only, env-gated)

    A third site that is neither, and is the reason to be careful: the post-cycle
    remembered-set verifier (gc/verify.rs:155 "#5029 repair", old_young_slot_covered
    / old_young_external_slot_covered, and PERRY_GC_FROMSPACE_SCAN) asserts that
    every old→young slot it finds was covered by the cycle's dirty snapshot. Under
    lazy arming, on the first cycle, none of them are — the coverage comes from the
    reconstruct, not the snapshot. If the verifier is not taught the unarmed
    contract it will either fire spuriously or, worse, be relaxed into uselessness.

    The correct change is to make the reconstruct populate the snapshot the verifier
    reads, so the verifier's predicate is unchanged and stays load-bearing.


    3. What classify_heap_generation costs, and why it is called four times per store

    classify_heap_generation (arena/page_meta.rs:438) is: null check → addr >> 20
    → single-entry thread-local PAGE_GENERATION_CACHE probe (key + range containment)
    → on miss, PAGE_GENERATIONS.with(RefCell::borrow) → HashMap::get(&key) →
    PageGenerationSlot::find (linear over a Vec in the Multiple arm) → write the
    cache.

    Call count per barriered element store on the batch.ts sort path (parent =
    born-old array, child = nursery Shaped, i.e. the slow path all the way through —
    which the #7170 callgraph confirms, since mark_dirty_old_page at 6.73% is only
    reachable when the parent classifies Old):

    # site operand 1 MiB bucket
    1 decode_heap_addr(parent) (gc/barrier.rs:1088) parent old
    2 barrier_parent_needs_remembering (:1040) parent old
    3 remembered_child_needs_tracking (:1016) child nursery
    4 remember_old_to_young_slot (:1106) slot old

    decode_heap_addr(child) costs nothing here — the child is POINTER_TAG-boxed and
    takes the tag fast path.

    Call #1 is pure redundancy. runtime_write_barrier_slot receives parent_addr: usize — already decoded — and hands it to js_write_barrier_slot as a bare
    u64, so decode_heap_addr falls into its "possible raw pointer" arm and pays a
    full classify_heap_generation just to re-derive a value the caller already had.
    The other two runtime entry points do not do this: runtime_write_barrier_external_slot
    (:1293) and runtime_write_barrier_gc_slot (:1310) both POINTER_TAG | the
    address first.

    Call #3 defeats the cache. The cache is one entry, shared with
    classify_heap_space. Parent and slot live in the born-old array's bucket; the
    child lives in a nursery bucket. So the sequence per store is
    old, old(hit), nursery(evict), old(evict) — a deterministic two-address thrash on
    a one-entry cache. Two of four calls take the full hashmap path by construction,
    every store, forever.

    Per-call cost, from the posted numbers. The sort's element moves are
    ~1.06 M (insertion phase: 1250 runs × 32, ~291 k swaps × 2 sets; merge phase:
    11 bottom-up passes × 40 000; plus the 40 000-element copy-back). At 4 calls per
    store that is ~4.2 M calls against 657 M instructions ⇒ ~155 instructions per
    classify_heap_generation call
    , averaged over hits and misses. For a function
    whose fast path is a shift and two compares, that is the miss path dominating.

    Where the miss path's instructions go — one predicted contributor worth naming.
    PageGenerationMap is HashMap<usize, PageGenerationSlot, BuildHasherDefault<IdentityHasher>>
    with a local IdentityHasher (page_meta.rs:112) whose write_usize is
    self.0 = value as u64 — no mixing. std's HashMap is hashbrown: the bucket index
    comes from the low bits of the hash and the control byte h2 from
    hash >> 57. Keys here are addr >> 20 ≈ 2²⁶–2²⁷, so h2 == 0 for every
    entry in the table
    . Every SIMD group probe therefore matches every occupied
    slot in the group, and each match costs a real key comparison — a scattered load
    into a ~40-byte bucket — before the right one is found. With ~70–130 buckets live
    (one per 1 MiB region: a 64 MiB nursery plus old blocks) that is on the order of
    4–8 spurious key compares per lookup.

    The codebase already diagnosed this exact failure mode and shipped the fix
    elsewhere: fast_hash.rs's module doc, and its mix(h) = h ^ (h >> 32) avalanche
    step, whose comment records a 455 ms → 830 ms regression from omitting it.
    PAGE_GENERATIONS never got it because it grew its own hasher.

    This h2 argument is reasoned from hashbrown's construction, not measured — see §8.


    4. The armed check

    • Storage: a process-global static AtomicBool, not a thread-local. Arming is
      monotone, one-way, and conservative-safe in the early direction, so a global is
      sound: a relaxed load of a static is adrp/ldr, whereas a thread_local!
      costs _tlv_get_addr — a call — on macOS. Given this is a per-heap-store path
      on the project's primary dev host, the TLS form would eat a visible share of what
      it saves.
    • Per-thread bookkeeping stays thread-local: a Cell<bool> recording whether
      this thread has collected, which is what decides "reconstruct on this
      collection". The two can disagree safely. If thread B arms globally first,
      thread A's barrier arms early (it stops being free, nothing more) and A's own
      first collection still reconstructs, because A's per-thread flag is still false.
      The two edge cases are a superset and a no-op respectively; there is no window
      where an edge is neither recorded nor reconstructed.
    • Placement: inside write_barrier_slot_inner, immediately after
      incremental_mark_barrier_value(child) and before the parent decode.
    • Residual cost, stated honestly (perf(repsel): refuse canonical i32 when every hot consumer wants a double (#7128) #7132). One relaxed atomic load + cbnz.
      It is not free, and two things bound it:

    Arming trigger: the end of the first collection on the thread — specifically
    after remembered_set_clear(), so the armed window and the cleared-log window
    start together and there is no gap in which a store is neither remembered nor
    covered by the reconstruct.


    5. Verification — the gate must be able to observe its own subject

    Per the doctrine and #7166's [gc-scan-fallback] census, "nothing crashed" is not
    a verdict here. Three assertions, all cheap:

    1. A census line, under the existing PERRY_GC_TRACE/PERRY_GC_DIAG surface:
      barrier_armed=<0|1> reconstructed_edges=<n> rs_pages=<n> per cycle. The
      existing BarrierTraceCounters (gc/barrier.rs:880) is the right home — it
      already counts OldToYoungSlowHits, NewInserts, ParentNotOldSkips.
    2. scripts/gc_repsel_matrix.sh liveness requirement. The harness already has
      the collect/scavenge/move requires= ladder built for exactly this reason
      (gc-matrix: --pressure disables the very path #7019 added — the 'default' arm runs ZERO copying minors on all 22 corpus rows #7024/gc-matrix: the 'moved' liveness counter sums the C4b mark-sweep evacuation with the copying minor, so a move-arm can be green with zero scavenged objects #7025). Lazy arming needs a fourth: requires=arm — the arm is green
      only if some cycle reports barrier_armed=1 and a later cycle reports a
      non-empty remembered set. Without the second clause a workload that collects
      once certifies nothing about the armed steady state. The requires=move arms
      (evac_minor, force_evac, loop_polls, and the six rep_*_off rows) already
      exercise the moving path via PERRY_GC_MOVING_LOOP_POLLS=1 since fix(gc): disable evacuating minor by default pending #7154 (use-after-free on dynamically-added fields) #7161, so the
      post-arm evacuating path has coverage; it just needs the arming assertion added.
    3. A sabotage arm, in the fix(gc): record layout + write barrier on raw pointer publishes into traced payload slots #7179 style: a unit test that suppresses the
      reconstruct and asserts the born-old-array→young-object case fails. A test
      that passes with and without the reconstruct is testing nothing. The natural
      fixture is the one the bug already has a name for — a born-old array filled with
      young children, first minor, copied_objects > 0 kept true by a rooted young
      canary so the liveness gate cannot be satisfied by the edge under test.

    6. Constraints answered

    #7161 / non-moving default — does the classification become useless work?
    No. The non-moving minor consumes the remembered set exactly as the evacuating
    one does. remembered_child_needs_tracking's own doc comment
    (gc/barrier.rs:1015) states the reason: minors sweep both the nursery and the
    malloc registry, and old parents are black leaves in a minor, so an unremembered
    old→nursery edge leaves the child unmarked and it is swept while live. The two
    consumers are mark_remembered_set_roots (gc/barrier.rs:41, via
    RootScanSubphase::RememberedSet in gc/cycle.rs:667) and the copying path's
    snapshot.dirty_old_pages walk (gc/copying.rs:791). Lazy arming helps both
    modes identically, and #7161's default flip does not change the calculus.

    RSS. The remembered set starting empty is a real but second-order win. Its
    size is bounded by pages touched, not objects: DIRTY_OLD_PAGES is a
    PtrHashSet<usize> of 4 KiB page numbers, so batch.ts's 320 KiB array
    contributes ~80 entries. The larger structural win is EXTERNAL_DIRTY_SLOT_PAGES
    (PtrHashMap<usize, Vec<usize>> — a growing Vec of owning headers per external
    page) and, on the test path, REMEMBERED_SET. The genuinely important RSS
    statement is different and simpler: a program that never collects — which is most
    of what Perry compiles, CLIs and short request handlers — currently builds and
    retains a remembered set it will never read. Under lazy arming it allocates none of
    it.

    #7154 boundary. #7154 is now closed, and the residual work jdalton is on has
    moved out of gc/ entirely — #7188 (try_imported_array_methods folding an
    imported binding's .map to Expr::ArrayMap on method name alone), #7189
    (export * as ns), #7190 (defineProperty(Class, "name")), #7191 (spread method
    call on an imported-array receiver). All HIR/lowering. No overlap with barrier or
    policy code.
    The one file worth a same-day check before landing is
    gc/verify.rs, because #7179's from-space scan lives there and §2's third bullet
    touches it.


    7. A and B: composition, and what lands first

    They are complementary, and neither subsumes the other:

    Order — B first, then A. Three reasons:

    1. B's first two items are provably semantics-free (see §8 for what "provably" does
      and does not cover here) and can land, be measured, and be believed on their own.
      A changes when the collector reads its remembered set, which is the highest-blast-radius
      category of change in this repo.
    2. B shrinks A's residual: after B, the classify calls that survive arming are
      cheap, so A's remaining benefit is honestly attributable to the skip rather than
      to accidentally dodging a bad hash function.
    3. A's correct form (§1) needs a reconstruct path and a verifier update; B item 1
      is a signature change and item 2 is a type alias.

    B, in increment order:

    • B1 — stop re-deriving a parent address the caller already decoded.
      runtime_write_barrier_slot should reach write_barrier_slot_inner with
      parent_addr intact instead of round-tripping it through decode_heap_addr's
      raw-pointer arm. Cleanest form is to split write_barrier_slot_inner into a
      ..._decoded(parent_addr: usize, …) core that js_write_barrier_slot calls after
      decoding; the ~18 non-test Rust callers then all skip call Support custom menu bar items #1.
      Behaviour is identical, and the one case worth checking closes: today a
      malloc-GC parent reaching this path classifies Unknown, so decode_heap_addr
      returns 0 and the barrier exits at NonPointerParentSkips. After the change it
      reaches barrier_parent_needs_remembering(parent_addr, external_slot = false),
      which classifies Unknown, is not Old, and external_slot is false — so it
      exits at ParentNotOldSkips. Same outcome, one fewer classify. (The
      external/malloc parents that do need remembering arrive through
      runtime_write_barrier_external_slot/_gc_slot, which already tag their parent
      and are untouched.)
      Removes 1 of 4 classify calls on every runtime-path barriered store: ~25% of a
      19.03% symbol ≈ 4.7% of batch.ts
      , for a signature change.

    • B2 — give PAGE_GENERATIONS the project's own pointer hasher. Delete
      IdentityHasher (used for nothing else) and make
      type PageGenerationMap = crate::fast_hash::PtrHashMap<usize, PageGenerationSlot>;.
      Costs one multiply + xor per lookup; buys back the h2 degeneracy of §3.
      PAGE_GENERATIONS is only ever point-queried (get/get_mut/insert/remove
      at page_meta.rs:367/392/451/483 — the for key in first..=last loops walk key
      ranges, not the map), so iteration order is not observable and there is no
      determinism exposure.

    • B3 — the slot operand is redundant given the parent. By the time
      remember_old_to_young_slot runs, barrier_parent_needs_remembering has already
      established the parent is Old. For the non-external path the slot is interior to
      the parent's own allocation, and an allocation never straddles a generation
      boundary — so classify_heap_generation(slot) can be replaced by an interior
      test against [parent_header, parent_header + size), which the function already
      has the header for. Removes call useEffect + setState panics with RefCell already borrowed on macOS ARM64 #4. Slightly more argument than B1/B2, so it is
      a separate PR.

    • B4 (structural, not next) — a physical generation bit in GcHeader. This is
      the O(1)-loadable answer for the two object operands, and it would collapse
      calls linux compilation and README #2 and Using the fetch API #3 to a byte load from a header already in cache. GC_FLAG_TENURED
      cannot be reused for it
      : gc/oldgen.rs:973/1084/1518 set it on an age bump
      while the object is still physically in the nursery, and gc/trace.rs:731
      explicitly ORs "physical region or GC_FLAG_TENURED" precisely because the
      two differ. A new, physically-maintained bit means touching every promotion and
      evacuation site — real GC-internals work, memory-corruption class, and not a
      first increment.


    8. Composition with #6759, and what does not overlap

    They overlap in diagnosis and barely at all in mechanism.

    Concretely: after B1+B3, one classify per store remains (the child); B2 makes it
    cheap; B4 or #6759-A makes it a load. A removes it entirely until the first
    collection. There is no double-counting between the two campaigns because they own
    different side tables.


    9. Unmeasured / unverified, plainly

    • Nothing was built and nothing was run. The local disk sat at ~9 GB free for
      this session, below the 15 GB floor this work was scoped under (the same
      constraint reported on GC: evacuating minor drops an old-to-young field[1] edge, crashing with 'value is not a function' #7154's thread: "local disk sat between 8 and 13 GB free
      for the whole session"
      ). B1 and B2 are ~20 lines together and are one build away;
      they are deliberately not pushed as an unbuilt, untested GC-internals PR.
    • The h2 degeneracy in §3 is reasoned from hashbrown's construction, not
      profiled.
      The claim "h2 == 0 for every key" follows from IdentityHasher
      returning the key verbatim and hashbrown taking the control byte from hash >> 57;
      the consequence (4–8 spurious key compares per lookup) is an estimate from the
      live-bucket count, not a measurement. perf stat on the Pi, B2 alone, settles it.
    • The ~155 instructions-per-call figure is arithmetic on two derived numbers
      (657 M from repsel: rule-1 provenance for unbound allocations — the 506 are 96% record literals, and Perry's own CJS IIFE is the wall (#7152 follow-up) #7170, ~1.06 M element stores derived from the sort's own structure).
      The store count is not instrumented; a counter on note_array_slot would pin it.
    • The split of the 28.12% barrier subtree between skippable and non-skippable is
      read off the callgraph, not separately measured.
      The incremental_mark_barrier_value
      share attributable to this subtree (vs. the 1.85% total) is not broken out.
    • Whether B1's benefit survives on workloads whose barrier parent is nursery.
      The redundant call Support custom menu bar items #1 is paid by every runtime-path barriered store regardless of
      outcome, so it should — but batch.ts is the only workload with a profile.
    • The first collection's reconstruct cost on a heap with a large born-old
      population
      (many JSON.parse results, large typed arrays). Bounded by
      old_bytes / 16 KiB objects, but not measured, and it is a one-time latency spike
      rather than throughput.
    • aarch64 only, single-threaded only. The global-AtomicBool-vs-thread-local
      argument in §4 is about macOS _tlv_get_addr and is not measured on either host.
  2. proggeramlug commented on Aug 1, 2026

    @proggeramlug
    ContributorAuthor

    Increment 1 landed as a PR: #7193 (direction B, items B1 + B2)

    Per the plan above: B before A. #7193 takes B1 (stop re-deriving the parent address) and B2 (the page-map hasher). Phase A (lazy arming) stays a design here pending its own dispatch. Three things the implementation changed about what is written above.

    1. B1 was not purely a runtime_write_barrier_slot question — the round-trip was FILTERING

    §7's B1 argued the malloc-GC-parent case through runtime_write_barrier_slot (bare u64 → decode_heap_addr's raw-pointer arm → classify → Unknown → 0). That analysis holds. What it missed is the other two entry points, which NaN-box:

    write_barrier_slot_inner(POINTER_TAG | (parent_addr as u64), ...)

    For an address with high bits set, that OR produces something whose tag is no longer POINTER_TAG, so decode_heap_addr returned 0 and the barrier exited early. That accident was load-bearing: closure/dynamic_props.rs parks props under non-address owner keys (its own unit test uses 0xC10C_AB1E_0000_1803) and reaches runtime_write_barrier_external_slot with them. Removing the round-trip SIGSEGVs there — malloc_gc_parent_addr dereferences parent - GC_HEADER_SIZE behind nothing but a < GC_HEADER_SIZE + 0x1000 floor.

    So B1 ships with the filter made explicit (barrier_parent_addr_is_dereferenceable = addr_class::is_plausible_heap_addr + 8-alignment), applied in the entry point and inside malloc_gc_parent_addr itself so the function that dereferences carries its own guard. That is a strict improvement on the prior state, which left the NaN-boxed callers able to hand a plausible-but-unaligned parent to a header read.

    Worth recording as a general lesson for the rest of this campaign: §2's "the split is already physical in the source" is true for layout-vs-barrier, and false for decode-vs-filter. decode_heap_addr is named as a decoder and doubles as the barrier's only address validator. Phase A's arming check sits after it and so is unaffected, but any future increment that reorders or skips that call inherits this.

    2. The h2 degeneracy is no longer a prediction

    §3 flagged it as "reasoned from hashbrown's construction, not profiled", and §9 listed it as unmeasured. It is now measured, by a unit test with the old hasher reinstated:

    hashbrown control byte must vary across generation class keys,
    got 1 distinct values from 64 consecutive buckets (an identity hasher yields 1)
    

    Every generation-class key in the table shared control byte 0, exactly as predicted. The consequence (4–8 spurious key comparisons per lookup) remains an estimate — that needs a profile.

    3. Matrix + ratchet state, since both bear on Phase A's verification design

    Both gates ran on #7193 (--arms all --pressure 8; full log on the PR). Two findings that Phase A's §5 will have to plan around:

    The good news for Phase A's §5: the requires=move arms bit on 28/29 corpus files, so the machinery to prove an arming flip actually goes live does work. The proposed requires=arm ladder rung still stands.

    Unchanged

    §1 (born-old breaks the "no young/old distinction pre-collection" premise; the contract belongs on the collection side), §2's layout/barrier split, §4's global-AtomicBool-not-thread-local argument, §6's #7161 answer (the non-moving minor consumes the remembered set identically), and §8's #6759 relationship all stand as written. B3 (the slot operand is redundant given the parent) and B4 (a physical generation bit — not GC_FLAG_TENURED) remain unstarted.

  3. proggeramlug commented on Aug 2, 2026

    @proggeramlug
    ContributorAuthor

    Phase A is in #7250, and the "self-inflicted preamble" lead is a false lead — the split is 0% / 100%

    Two things to record here: the preamble question, answered; and the counts the
    lever actually removes, measured rather than estimated.

    1. The CJS preamble contributes 0% of rank 1. Two different things share the word "barrier"

    The brief for this work opened with cjs_wrap/wrap.rs's
    Object.defineProperty(require, 'name', …) preamble as the first thing to check,
    on the reading that it "armed the write barrier in 100% of modules" — carried
    forward from #7139/#7152/#7170.

    It does arm a barrier in 100% of CommonJS modules. It is not this one.

    • The preamble arms the compile-time Ptr<Shape> promotion barrier:
      ptr_shape_report.rs's MODULE_BARRIER / rule 5, "the module contains a
      shape barrier (Object.defineProperty, delete, setPrototypeOf/__proto__
      write, new Proxy, or a mutating Reflect.*). The first-increment rule
      disables ALL Ptr promotion in the module regardless of the barrier's
      target."
      Raised at collectors/ptr_shape.rs:306; exempted for the wrap
      scaffolding by collectors/cjs_scaffolding.rs (perf(repsel): exempt CommonJS module scaffolding from the Ptr<Shape> rule-5 barrier #7139), present on current
      main.
    • Rank 1 is the GC write barrier's remembered-set maintenance —
      classify_heap_generation reached from runtime_write_barrier_slot.
      wrap.rs contains zero write-barrier references, in either direction.
    • And batch.ts is a plain .ts entry that is never CJS-wrapped at all: zero
      __perry_cjs_require strings in its compiled binary.

    Split: 0% preamble, 100% intrinsic runtime barrier. Phase A is therefore a
    runtime fix, and no compiler-side work is on this path. Worth flagging for the
    rest of the campaign: three separate issues have now spent effort on "the CJS
    wrapper is arming a barrier", and the word is overloaded between a repsel
    promotion hazard and a GC remembered-set log.

    2. The counts, measured on the base binary

    batch.ts, macOS arm64 (M1 Pro), release build of origin/main @ 8b024958f.
    Counts read out of the existing PERRY_GC_TRACE write-barrier counters via a
    trailing collect() — they accumulate from process start and are dumped by the
    first cycle, so one trailing collection reports the whole program. Counts, not
    timings:
    this host sat at load average 40–55 for the entire session (other
    agents building), so nothing here is a wall-clock claim.

    Collections on unmodified batch.ts: zero, PERRY_GC_DIAG silent —
    replicating #7170 on a second host and a second architecture.

    counter count
    calls 2,265,777
    non_pointer_child_skips (the #6011 fast path, untouched) 280,536
    reaching the remembered-set half 1,985,241 (87.6%)
    parent_not_old_skips 211,497
    old_to_young_slow_hits 1,773,744
    dirty_page_mark_attempts 1,773,744
    new_dirty_pages 517

    That pins two of §9's "unmeasured" items on the design comment above:

    3. What #7250 does, and the correction §1 of the design demanded

    The design comment's §1 is right and #7250 implements it: the naive
    "nothing has collected, so there is no old generation" premise is false, and the
    contract is stated on the reading side.

    While unarmed the barrier records nothing. In exchange, the first read of the
    remembered set on a thread does not trust the log at all — it reconstructs the
    complete old→young edge set from the heap and arms the barrier on the way past.

    The placement that makes this enforceable rather than auditable:
    remembered_dirty_snapshot() is the sole read path. Every collector —
    the budgeted/full cycle's RememberedSetRootMarkState, the copying nursery fast
    path and its preflight, the cycle's pre-clear coverage snapshot, the
    evacuation verifier — obtains the dirty set there; the only other touches of
    DIRTY_OLD_PAGES / EXTERNAL_DIRTY_SLOT_PAGES / REMEMBERED_SET are the
    barrier's own writes, the clear, and diagnostics, and the arena's per-page
    dirty bit feeds old_page_summary telemetry only. Arming inside that one
    function means no collector can observe an unarmed — therefore empty — log.

    This also disposes of §2's third bullet (the #5029 verifier). No verifier change
    was needed: it reaches its snapshot through the same function, so by the time it
    runs, the reconstruct has. Its predicate stays load-bearing and untouched.

    Two things the design left open that the implementation had to decide:

    4. Verified end to end, and what is NOT verified

    Branch census on batch.ts: unarmed_skips = 1,985,241 — matching the base's
    calls − non_pointer_child_skips to the unit — with old_to_young_slow_hits,
    parent_not_old_skips and dirty_page_mark_attempts all 0, and
    reconstructs = 1 recovering 393 old pages (fewer than the 517 the barrier
    logged: the reconstruct records only edges live at collection time, so it is
    strictly more precise). A probe that collects before the sort and again at the
    end reports the armed steady state counter-for-counter identical across arms.
    Compiled binary −32,936 bytes. Max RSS unchanged.

    Not verified, plainly:

    • No wall-clock number. The host never went quiet (load 40–55 throughout).
      The 19.03% / 6.73% / 2.19% instruction shares remain repsel: rule-1 provenance for unbound allocations — the 506 are 96% record literals, and Perry's own CJS IIFE is the wall (#7152 follow-up) #7170's Pi 5 profile,
      which I did not reproduce; what I reproduced is the zero-collection fact and
      the call counts underneath those shares.
    • The RSS argument in §6 does not show up here. 517 page numbers is a few
      KB against a 24 MB RSS. It may matter on a workload that dirties far more
      old pages, unmeasured.
    • The first collection's reconstruct cost is not measured as latency. It is
      one synchronous whole-heap header walk per thread — 220,318 objects on
      batch.ts — and full cycles already perform the identical walk every cycle
      (AtomicFinalizeSubphase::RememberedSetRebuild). Narrowing it to old-gen +
      the malloc registry is sound (barrier_parent_needs_remembering admits
      nothing else) and is the obvious follow-up; not done in perf(gc): stop maintaining a remembered set nothing has read yet (#7187 Phase A) #7250.
    • aarch64/macOS, single-threaded, one workload. The multi-thread argument —
      a worker armed early by another thread's collection still reconstructs on its
      own first read, because that flag is thread-local — is implemented and
      reasoned, not exercised by a test.

    B3 (slot-operand elision) and B4 (a physical generation bit in GcHeader, which
    GC_FLAG_TENURED cannot serve) remain unstarted.

  4. proggeramlug commented on Aug 3, 2026

    @proggeramlug
    ContributorAuthor

    Phase B is in #7298: mark_dirty_old_page collapses 1,773,744 → 3,243, page set unchanged

    Recording the numbers and, more usefully, the one measurement that changed the
    design.

    The batch.ts measurement has to be re-armed after Phase A

    The 1,773,744 / 517 figures in the comment above were taken at 8b024958f,
    before #7250. On current main batch.ts reaches mark_dirty_old_page
    zero times — the barrier is never armed, because the program never collects.
    Phase B's subject does not exist on that workload as shipped, which is worth
    saying plainly: this lever pays on programs that have collected, and
    batch.ts has to be armed (a leading perry/gc collect()) to see it at all.
    Armed, it reproduces the pre-Phase-A traffic counter-for-counter.

    The mechanism was picked from the page sequence, not from intuition

    Instrumenting the exact sequence of pages the barrier hands to
    mark_dirty_old_page and simulating cache shapes over it:

    shape hit rate calls left
    1-entry 99.7817% 3,873
    2-entry LRU 99.8730% 2,253
    4-entry LRU 99.8730% 2,253
    8 / 32-entry direct-mapped 99.8730% 2,253
    512-entry direct-mapped 99.9423% 1,023
    2048-entry direct-mapped 99.9531% 832

    The redundancy is consecutive: 3,872 same-page runs over 1,774,374 calls,
    mean 458, longest 13,803. One entry captures essentially all of it, and every
    larger shape buys ≤0.17 further percentage points for more state on a path that
    runs on every heap store. A one-usize thread-local last-page cache it is.

    Before / after, same workload, one target dir per arm

    Every pre-existing write_barrier counter is identical across the arms
    (calls 2,265,777; non_pointer_child_skips 280,536; parent_not_old_skips
    211,497; old_to_young_slow_hits and dirty_page_mark_attempts 1,773,744;
    new_dirty_pages and new_inserts 517). The only difference is the new
    dirty_page_cache_hits: 0 → 1,770,501, i.e. calls reaching the modbuf and
    the arena metadata go 1,773,744 → 3,243.

    Page set proven unchanged in-process rather than by diffing two ASLR'd runs:
    an instrumented build recorded both the set of pages the barrier asked to mark
    and the set that actually reached the recording body, and asserted equality —
    517 in both arms, PAGE_SET_EQUAL=true in both.

    GC matrix (--arms pr, 52-file corpus): FAIL=0, exit 0, liveness gate green
    with movement asserted — safepoint_minor copy-minor 35/52, evac_minor 51/52,
    force_verify 51/52.

    A new B-family candidate, deliberately NOT taken

    remember_old_to_young_slot runs classify_heap_generation(slot_addr)
    immediately before the mark. Hoisting the cache guard above it would remove a
    further 1.77M calls to the rank-1 symbol — but only under the extra premise
    "page P is in the cache ⇒ a slot in P still classifies Old", which a block
    freed mid-cycle can falsify. Skipping the classify when that premise is false
    skips mark_dirty_parent_span, i.e. loses an edge. That is heap corruption for
    a perf win, so #7298 stops at the airtight boundary: the guard only elides the
    exact call whose effect it caches. Worth doing properly (a generation epoch, or
    B4's physical generation bit, would make the premise checkable in O(1)) — filing
    it here rather than sneaking it in.

    B3 (slot-operand elision) and B4 (a physical generation bit in GcHeader)
    remain unstarted.

  5. proggeramlug commented on Aug 9, 2026

    @proggeramlug
    ContributorAuthor

    Closing — Phase A and Phase B both shipped; the two follow-ups this issue named were never started and are refiled so they are visible as work rather than as the tail of a finished ticket.

    Shipped: Phase A, lazy barrier arming (#7250). Phase B, the dirty-page mark cache (#7298). Both live on main.

    Refiled: #7715 carries B3 (slot elision) and B4 (generation bit in the object header), together with this issue's headline evidence — classify_heap_generation at 19% of batch.ts, 57.4% total GC bookkeeping, with zero collections running — and the two traps that go with it: #7511's finding that the barrier cost is 98.5% the loop's keep.push(ptr) and that the "parent array is young" shortcut is unsound, and the fact that PERRY_WRITE_BARRIERS=0 cannot bound barrier cost because it changes the GC regime.

    Anyone picking it up should re-profile first. Every percentage in both issues is days old, and figures in this repo have gone stale under far less.

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