Repository navigation
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
Description
Activity
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_layoutis pure_reservedflag copyingheader bits element shape (#7480) NONE RefCellborrow + 1–2 hash removesstatic prototype owner (#9304) OBJECT_PROTOTYPES_NONEMPTYAcquireload (#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+RandomStatein a single-threaded profile").The two records the issue flags
Static prototype owner (#9304): the question is moot. It already costs one
Acquireload 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_lenu32s +epochu64 +generationu32), andGcHeader::_reservedis 16 bits already carrying four flags includingGC_ARRAY_ELEMENT_SHAPE; ArrayHeaderis{ 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_lenis the array's own length, the proof is per-instance, and arrays areGC_TYPE_ARRAYand 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_shapecomputeshad_bitfor free from the header and then takes theRefCelland hashes anyway, including tworemoves on the!had_bitpath — for an array that proved nothing. Its own comment justifies that by an orphan-inheritance hazard, butestablish()already defends against exactly that by drawingepochfromELEMENT_SHAPE_PROOF_SEQand 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_ownersreturns immediately whenper_object_layouts_maybe_nonempty()is false, and the only allocation inside it islayout_addr_filter_rebuild(), which builds aVec<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: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.- 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
ImmortalLayoutScopeis 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 undermark_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
layout_addr_filter_rebuildshould not allocate. Iterate both maps' keys and set the bits directly — theVecexists only to end the borrows early and buys nothing (the filter lives behind its ownUnsafeCell, disjoint from the maps). This is pure waste on every collection, today.transfer_element_shapeshould take the gate its siblings have — return on!had_bitwithout touching the table, per theestablish()reasoning above.- 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
#9782is still OPEN (full mark-sweepgc-stressarms failing since ~2026-09-03), andmain's CI is currently queued, not green. A greengc-stressrun still proves nothing here.- the record is 24 bytes (
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_holdanswers "may hold" for every address
in the process, so the early returns intransfer_per_object_descriptor
andtransfer_per_object_slot_masknever fire, and neither does the one in
layout_forget_objectthat gates every allocation. - 50.6 MB of
Vec<usize>from filter rebuilds alone, which is the ~45 MB
the census attributed toprune_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=0TYPED_LAYOUTSis empty for the entire turn (typed_max=0across 47
prunes). All 162,258 keys are inLAYOUT_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_descriptorhashes 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, andverified_lenis per-instance
while arrays carry no ShapeId; static prototype owner is already behind
#7733/#7737'sAcquirelatch). 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:
- The prune walks its tables once, not three times, and allocates nothing.
retainalready visits exactly the survivor set thatlayout_addr_filter_rebuild
(via itsVec) andrecount_young_layout_recordseach 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 thelentesttransfer_per_object_descriptor's shared
flag cannot express. transfer_element_shapereturns before the side table when neither
address advertises a proof — it already computes that verdict for free
from the header. Gating on!had_bitalone 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 inperry-codegen'semit_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_slotsfor any object with a
non-empty pointer mask and at leastlayout_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.- 162,258 live keys — 2.5x the ~64,000 the 45 MB/turn census figure implied,
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 MB0 outgrown-and-skipped — 46 rebuilds=47becomesoutgrown-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_DIAGon 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_DIAGnow 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 unpredictedtyped=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 intransfer_per_object_descriptorand
transfer_per_object_slot_maskfire again, and it has to move together with
the codegen side, becauseemit_gated_forget_object_layoutmirrors 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 ofVecis the part that is now gone.A second cheap item that fell out of the work: the heap census does not break
outELEMENT_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.
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