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
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.rsallocate 304 MB and hold 14.9 MB liveper 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
layout::layout_transfer(fromCopyingNurseryCollector::mark_addr)layout_tables::prune_dead_per_object_layout_ownerslayout_tables::slot_masks_insert(fromlayout_rebuild_from_slots,js_closure_alloc_init)HashMap<usize, Vec<(usize, usize)>>::insert— the per-object layout table itselfmark_addr,trace_heap_rewrite_slots)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
Vecgrowth look like from the allocator's side.
Mechanism
layout::layout_transfer(old_user, new_user)runs for every evacuatedobject on every copying minor (
CopyingNurseryCollector::mark_addr). Itmoves five separate address-keyed records:
TYPED_LAYOUTS(per-object typed descriptor)array::transfer_array_numeric_layout)array::transfer_element_shape, repsel: element-shape proofs through arrays — measured 6.2× vs node; route = invariant bit → versioned-loop consumer → element Ptr<Shape> #7480)object::prototype_chain::object_static_prototype_owner_moved, An indexed write that grows an array discards an explicit null [[Prototype]] #9304)layout_tables::slot_masks_insert/ the per-object mask map)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_ownersthen walks and rebuilds the address filter on top of that.
This is the same defect as A2 in the campaign's
ARCHITECTURE.md— metadatain 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.
_reservedalready carries half of them (GC_LAYOUT_STATE_MASK,GC_LAYOUT_ALL_POINTERS,GC_OBJ_TYPED_LAYOUT_INTACT), andlayout_transfercopies 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
layout_transfer+prune_dead_per_object_layout_ownersmust fall by ≥ 200 MB per turn, and per-object-layout resident by
≥ 8 MB, with turn CPU not regressing.
address-keyed today — element shape (repsel: element-shape proofs through arrays — measured 6.2× vs node; route = invariant bit → versioned-loop consumer → element Ptr<Shape> #7480) and static prototype
owner (An indexed write that grows an array discards an explicit null [[Prototype]] #9304). Whether either can move into the header or become
shape-keyed decides whether the hypothesis survives at all. If they cannot,
layout_transferstill pays two hash moves per evacuated object and theremaining saving may not clear the bar above. That question should be
answered before any code is written.
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, andthe 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 matrixis 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