Skip to content

perf(gc): per-object layout metadata is address-keyed, so every copying minor rehashes it — 304 MB allocated / 14.9 MB live per 400-char reply #9792

Open

Description

@proggeramlug

Summary

Per-object layout metadata lives in address-keyed side maps, so a moving
collector has to rehash the whole per-object layout world on every copying
minor. Measured on the compiled claude-code TUI with PERRY_ALLOC_CENSUS
(#9771), one 400-character streamed reply:

gc/layout.rs + gc/layout_tables.rs allocate 304 MB and hold 14.9 MB live
per turn.
For scale, the whole process allocates 22.5–23.5 GB per such turn
with a 1.70 GB peak live, and mimalloc's committed set — which is the process
footprint on macOS, every dirty page being Memory Tag 240 — tracks that peak.

Per-callee

site allocated live
layout::layout_transfer (from CopyingNurseryCollector::mark_addr) ~50 MB 0
layout_tables::prune_dead_per_object_layout_owners ~45 MB 0
layout_tables::slot_masks_insert (from layout_rebuild_from_slots, js_closure_alloc_init) ~15 MB 0
HashMap<usize, Vec<(usize, usize)>>::insert — the per-object layout table itself 8.7 MB 8.7 MB
remainder (inlined layout machinery under mark_addr, trace_heap_rewrite_slots) ~185 MB ~6 MB
total 304 MB 14.9 MB

85 % of the process's whole allocation volume is one size class — 19,074 MB in
1.56 M allocations of 8–16 KB — which is what hash-table rehashing and Vec
growth look like from the allocator's side.

Mechanism

layout::layout_transfer(old_user, new_user) runs for every evacuated
object on every copying minor
(CopyingNurseryCollector::mark_addr). It
moves five separate address-keyed records:

Each move is a hash remove plus a hash insert keyed by the object's ADDRESS.
Since the address is exactly what a copying collector changes, the cost is
structural: every surviving object pays several hash operations per minor, and
the tables are regrown as they are rekeyed. prune_dead_per_object_layout_owners
then walks and rebuilds the address filter on top of that.

This is the same defect as A2 in the campaign's ARCHITECTURE.md — metadata
in parallel maps sized and keyed by something the collector moves — in a table
family nobody has looked at yet.

Structural answer

The bits belong in the object header / shape rather than in address-keyed side
maps. _reserved already carries half of them (GC_LAYOUT_STATE_MASK,
GC_LAYOUT_ALL_POINTERS, GC_OBJ_TYPED_LAYOUT_INTACT), and layout_transfer
copies those directly with no hashing at all — that half is already free, and
it is the existence proof that the rest can be. A record carried in the header
moves with the object by construction, and a shape-keyed record needs no move.

What would falsify it — stated before the work

Methodology warning for other lanes

This finding is also a lesson about the instrument. At a 1 MiB sampling
interval the layout bucket read as ~70 MB and an "arena bookkeeping" bucket read
as ~85 MB. At 256 KB the truth was 304 MB and 16 MB respectively —
the arena figure had been inflated by my own bucketing sweeping
arena_alloc_gc_old (which allocates objects) into a bookkeeping bucket, and
the layout figure had been deflated by sampling too coarsely for a long tail of
many small sites. A stated falsifier caught it: the arena hypothesis was
rejected on its own criterion (< 20 MB) and the redirect fell out of the same
run. Sample finely enough that the tail resolves, and check what your buckets
actually match before quoting a total.

Not started

Deliberately not attempted yet: this is more invasive than the evacuation-set
change deferred on #9779, and gc-stress matrix is red on main with #9782 open
(full mark-sweep arms failing), so a green run would prove nothing.

Instrument: PERRY_ALLOC_CENSUS=<path> (#9771), PERRY_ALLOC_CENSUS_INTERVAL=262144.

https://claude.ai/code/session_01YPfnmWZmSpSWpmnoXvH8z2

Activity

  1. proggeramlug commented on Sep 5, 2026

    @proggeramlug
    ContributorAuthor

    Answering the load-bearing question first, as the issue asks — and the answer changes the target

    Read against current main (1d63fa91f). No code written, because the open question this issue names decides the hypothesis and the answer is mostly "no".

    Four of the five "address-keyed records" are already gated. Only one is not.

    record gate today cost per evacuated object with no record
    TYPED_LAYOUTS (transfer_per_object_descriptor) per_object_layouts_maybe_nonempty() + 4096-bit address filter (#7510) flag + filter word
    array numeric layout not a map at all — transfer_array_numeric_layout is pure _reserved flag copying header bits
    element shape (#7480) NONE RefCell borrow + 1–2 hash removes
    static prototype owner (#9304) OBJECT_PROTOTYPES_NONEMPTY Acquire load (#7733/#7737) one atomic load
    slot masks (transfer_per_object_slot_mask) same #7510 guard flag + filter word

    So "each move is a hash remove plus a hash insert keyed by the object's ADDRESS … every surviving object pays several hash operations per minor" is not what the code does for four of the five. #7733/#7737 already made exactly this fix on the prototype-owner record, for exactly this reason, with exactly this measurement ("one lock + one hash per moved object … pthread_mutex_lock + RandomState in a single-threaded profile").

    The two records the issue flags

    Static prototype owner (#9304): the question is moot. It already costs one Acquire load per evacuated object while the residual registry is empty. It also cannot be shape-keyed — it stores a per-object [[Prototype]] JSValue, which is precisely the thing that differs per object, and it is a GC-visible slot with its own rewrite hook (visit_object_static_prototype_slot_mut). It does not need to move and should not.

    Element shape (#7480): no, it cannot move into the header. Three independent reasons:

    • the record is 24 bytes (class_id, ordinary_shape_id, verified_len u32s + epoch u64 + generation u32), and GcHeader::_reserved is 16 bits already carrying four flags including GC_ARRAY_ELEMENT_SHAPE;
    • ArrayHeader is { length: u32, capacity: u32 } and is the payload prefix — elements begin immediately after it, and that offset is baked into every emitted array index computation. Growing it is a codegen-wide ABI change, not a runtime change;
    • it cannot become shape-keyed either: verified_len is the array's own length, the proof is per-instance, and arrays are GC_TYPE_ARRAY and carry no ShapeId.

    But it does not need to. What it is actually paying is the absence of the gate its four siblings have. transfer_element_shape computes had_bit for free from the header and then takes the RefCell and hashes anyway, including two removes on the !had_bit path — for an array that proved nothing. Its own comment justifies that by an orphan-inheritance hazard, but establish() already defends against exactly that by drawing epoch from ELEMENT_SHAPE_PROOF_SEQ and never from the record at the address ("the whole defence against address recycling"), and reads are gated on the header bit, which the fail-closed path clears.

    Conclusion on the filed hypothesis: it does not survive. Moving these records into the header is not the available fix, and for the prototype owner it is not a needed one.

    What the census data actually points at — a different defect in the same file

    prune_dead_per_object_layout_owners returns immediately when per_object_layouts_maybe_nonempty() is false, and the only allocation inside it is layout_addr_filter_rebuild(), which builds a Vec<usize> of every live key in both maps before setting one bit each. So this issue's own measurement — ~45 MB/turn at that site — proves two things:

    1. per_object_layouts_maybe_nonempty() is TRUE on cc. perf(gc): layout side tables are 34% of object construction — the construction/death half of #5094 (allocation is 7.7%) #7510's emptiness proof does not fire on this workload, so every evacuated object falls through to the 4096-bit address filter.
    2. Sizing the Vec: 45 MB ÷ 8 B/key ÷ ~88 collections per 400-char turn (41 copying minors + 47 budgeted full-cycle steps, PERRY_GC_DIAG) ≈ 64,000 live keys.

    Against a 4096-bit filter whose own doc comment says "steady-state occupancy after the ImmortalLayoutScope is one or two entries, so the false-positive rate is ~0.05 % — sizing this up buys nothing". At 64k keys that filter is saturated many times over: essentially every probe answers "may hold", #7510's gate is fully defeated on cc, and every evacuated object pays the full two-map hash path after all. That also accounts for the issue's unattributed ~185 MB "remainder (inlined layout machinery under mark_addr)" — the two maps being rehashed and regrown as ~64k keys are rekeyed across ~88 collections per turn.

    So the defect is not "per-object layout metadata is address-keyed". It is "the emptiness proof and its address filter are sized for a workload holding one or two records, and cc holds tens of thousands" — which is why a table family that looks gated reads as 304 MB/turn.

    Falsifier for that redirect, stated before measuring

    A direct count on cc of hot_typed_layouts().len() + hot_layout_slot_masks().len() and of the filter's set-bit population must show ≥ ~10,000 keys and a filter ≥ 90 % ones. If the maps instead hold a handful of entries, the arithmetic above is wrong, the 45 MB comes from somewhere else, and this redirect collapses — in which case the 185 MB remainder has to be attributed before anything here is touched. That count is one counter and one run; it should be the next thing done.

    Three fixes that follow, none needing a header change

    1. layout_addr_filter_rebuild should not allocate. Iterate both maps' keys and set the bits directly — the Vec exists only to end the borrows early and buys nothing (the filter lives behind its own UnsafeCell, disjoint from the maps). This is pure waste on every collection, today.
    2. transfer_element_shape should take the gate its siblings have — return on !had_bit without touching the table, per the establish() reasoning above.
    3. Then, and only then, the structural item: the per-object layout maps need a key the collector does not move, or a filter that discriminates at their real occupancy. That is where this issue's instinct was right — just about a different table than the two it flagged.

    Gate state, as asked

    #9782 is still OPEN (full mark-sweep gc-stress arms failing since ~2026-09-03), and main's CI is currently queued, not green. A green gc-stress run still proves nothing here.

  2. proggeramlug commented on Sep 5, 2026

    @proggeramlug
    ContributorAuthor

    The falsifier ran, and it confirms — harder than the arithmetic predicted

    Stated before measuring (previous comment): a direct count of
    hot_typed_layouts().len() + hot_layout_slot_masks().len() and the address
    filter's set-bit population must show ≥ ~10,000 live keys and a filter
    ≥ 90 % ones
    , or the arithmetic is wrong and the 185 MB remainder has to be
    attributed before anything is touched.

    New instrument PERRY_LAYOUT_DIAG=<path>, sampled in
    prune_dead_per_object_layout_owners (the one per-collection hook that already
    holds both tables). One 400-character streamed reply, offline rig, compiled cc:

    [layout-diag] prunes=47 keys_now=162258 (typed=0 masks=162258) keys_max=162258
      filter: 4096/4096 bits set (100.0 %), max 4096/4096 (100.0 %)
      filter rebuilds=47 Vec<usize> elements=6637029 = 50.6 MB allocated
    
    • 162,258 live keys — 2.5x the ~64,000 the 45 MB/turn census figure implied,
      and 16x the falsifier's threshold.
    • 4,096 of 4,096 bits set. Not "≥ 90 %" — every bit, at every one of the 47
      prunes. layout_addr_filter_may_hold answers "may hold" for every address
      in the process
      , so the early returns in transfer_per_object_descriptor
      and transfer_per_object_slot_mask never fire, and neither does the one in
      layout_forget_object that gates every allocation.
    • 50.6 MB of Vec<usize> from filter rebuilds alone, which is the ~45 MB
      the census attributed to prune_dead_per_object_layout_owners — the two
      measurements agree, from opposite ends.

    The filter's own doc comment says it is sized for "one or two entries,
    ~0.05 % false positives".

    One number nobody predicted: typed=0

    TYPED_LAYOUTS is empty for the entire turn (typed_max=0 across 47
    prunes). All 162,258 keys are in LAYOUT_SLOT_MASKS. The emptiness flag and
    the address filter are shared between the two maps, so a full mask table
    drags every evacuated object into the typed-layout map as well — where
    transfer_per_object_descriptor hashes both addresses against a map that has
    been empty since startup.

    So the issue's structural answer is right about the disease and wrong about the organ

    Moving the records into the header does not apply (previous comment: element
    shape is a 24-byte record against a 16-bit _reserved, ArrayHeader's size is
    baked into every emitted index computation, and verified_len is per-instance
    while arrays carry no ShapeId; static prototype owner is already behind
    #7733/#7737's Acquire latch). What is actually broken is that an emptiness
    proof and a 4,096-bit sketch are sized for a workload holding one or two
    records, while cc holds 162,258
    — the same defect class as the 512-entry
    regex cache that cleared itself wholesale.

    What follows, and what does not

    Two PRs, both runtime-only:

    1. The prune walks its tables once, not three times, and allocates nothing.
      retain already visits exactly the survivor set that layout_addr_filter_rebuild
      (via its Vec) and recount_young_layout_records each walk again, so both
      fold into its closure. Past one eighth of the filter's bits the rebuild sets
      all ones — the state it would have reached anyway — in O(1) instead of
      O(live keys), and the instrument reports saturation instead of leaving it to
      be inferred. Plus the len test transfer_per_object_descriptor's shared
      flag cannot express.
    2. transfer_element_shape returns before the side table when neither
      address advertises a proof
      — it already computes that verdict for free
      from the header. Gating on !had_bit alone would be wrong (a destination
      still advertising a proof describes storage the move just replaced), and a
      test pins both halves; it fails on its named assertion under that sabotage.

    What is not available from the runtime: widening the sketch. Its geometry
    and hash are mirrored in perry-codegen's emit_gated_forget_object_layout,
    so resizing is a codegen ABI change, and a one-hash bitmap that discriminated
    at 162k keys would need ~1.5 Mbit — ~190 KB of inline thread-local storage on
    every thread — to serve a workload that has already lost the fast path. The
    honest structural question this leaves open, for whoever takes it next, is why
    cc holds 162k per-object slot masks at all: they are minted by
    layout_init_from_slots / layout_rebuild_from_slots for any object with a
    non-empty pointer mask and at least layout_mask_min_slots() slots, and the
    module's own docs describe that table as holding a "residue" that "on a
    monomorphic workload is empty for the entire run". 162,258 is not a residue.

    The remaining O(table) cost after these two PRs is the young-record recount's
    page classification per surviving key — now one walk rather than two, but still
    proportional to the table rather than to what died.

    https://claude.ai/code/session_01YPfnmWZmSpSWpmnoXvH8z2

  3. proggeramlug commented on Sep 5, 2026

    @proggeramlug
    ContributorAuthor

    Measured result, and what is still open

    Both PRs are now out of draft with the rig table in their bodies. Summary of
    what the numbers say, including the part that is unflattering.

    The mechanism is proven

    PERRY_LAYOUT_DIAG (added in #9807) on one 400-character reply, two binaries
    one code change apart, same session:

    before after
    prunes 47 46
    live keys at the last prune 161,934 (typed=0) 161,630 (typed=0)
    address filter 4,096/4,096 bits set (100 %) 4,096/4,096 bits set (100 %)
    filter rebuilds 47 0
    keys walked by those rebuilds 6,607,450, buffered through a Vec<usize> = 50.4 MB 0
    outgrown-and-skipped — 46

    rebuilds=47 becomes outgrown-and-skipped=46, which is exactly the predicted
    counter movement.

    One correction to what I wrote earlier: the prune is not confined to the
    full-cycle steps.
    PERRY_GC_DIAG on the same runs reports 40 copying minors
    and 6 full cycles against the instrument's 46 prunes — it runs once per
    collection of either kind. That makes the work removed larger than I predicted,
    not smaller, which makes the flat CPU result below more informative.

    The end-to-end result is modest, and I am reporting it as modest

    Quiet box (1-min load 4.2–5.9), 4 paired 400-char runs with the arm order
    alternated: turn CPU median 6.37 → 6.22 s (−2 %), and the before arm wins two
    of the four pairs. 3300-char, quiet subset: 75.24 → 71.84 s median (−4 %).
    The one consistent directional result is settled footprint, lower in 4 of 4
    pairs (470 → 442 MB, −6 %)
    . Peak RSS flat. Node in the same session: 0.25 s
    and 171 MB.

    A first reading at load 17–58 showed the candidate 40–58 % slower on the
    3300-char arm; repeating the same pairs quiet inverted it, and one unchanged
    binary spanned 52–79 s across those loads. That reading is withdrawn.

    The durable value here is the diagnosis and the instrument

    Before this, nothing in the system said out loud that an emptiness proof
    documented for "one or two entries, ~0.05 % false positives" was answering
    "may hold" to every probe on a real workload — it had to be inferred from an
    allocation census. PERRY_LAYOUT_DIAG now reports occupancy, filter
    population, and whether the gate is inside or past its useful range, so the
    next person meets a number instead of an inference. The unpredicted typed=0
    came out of the same instrument.

    The structural fix is still open

    #9807 stops paying for a gate that cannot pay back. It does not restore the
    gate. Sizing the filter from live occupancy — with a counter that reports
    saturation rather than a constant that tolerates it — is the change that would
    make the early returns in transfer_per_object_descriptor and
    transfer_per_object_slot_mask fire again
    , and it has to move together with
    the codegen side, because emit_gated_forget_object_layout mirrors the
    geometry and the hash. Until that happens every evacuated object on a cc-sized
    workload still pays the slot-mask hash, which is the larger part of the 304 MB
    this issue attributes here — the 50 MB of Vec is the part that is now gone.

    A second cheap item that fell out of the work: the heap census does not break
    out ELEMENT_SHAPES, so #9808's occupancy claim is answered indirectly through
    RSS and footprint rather than by a count. Adding that census row is worth doing
    regardless of whether #9808 lands.

    https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m

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