Repository navigation
perf(gc): on a retained object graph perry is 8.5× node — 13 minors at ~0.8G instructions each (measured on #10352) #10362
Description
Measured on: PR #10352 head 6c9d74188 (= main fcd108bfb v0.5.1579 + the #10348 fix), release build, Linux x86_64 (perrybuilder). node v26.8.1, bun 1.4.2. Metric is instructions:u, min-of-N (load-insensitive; wall-clock on that box is only indicative).
Every row below is correctness-gated. Output is byte-identical to node, and every perry binary exits cleanly under PERRY_GC_FROMSPACE_SCAN_ABORT=1. On builds without #10352 this workload has ~15 000 dangling references and runs about 5× cheaper because it skips work, so pre-#10352 numbers are not a valid baseline here.
Symptom: cost is superlinear in the retained live set
Allocation volume is constant (300 000 chains of 8 nodes, each with a 4-element array); only the number of retained chains changes. Source is plain JS and runs unchanged on all three runtimes.
| retained chains | perry | node | bun | perry / node |
|---|---|---|---|---|
| 1 000 | 1.10 G | 0.69 G | 0.64 G | 1.6× |
| 5 000 | 2.06 G | 1.12 G | 0.98 G | 1.8× |
| 20 000 | 5.20 G | 1.37 G | 1.11 G | 3.8× |
| 40 000 | 12.82 G | 1.50 G | 1.32 G | 8.5× |
For contrast, short-lived allocation alone (1 M objects, nothing retained) is 325 M vs node 177 M (1.8×), and startup is fine (3.3 M vs bun 9.1 M, node 109 M). The gap only appears once the program retains a graph.
Where it goes (40 000 retained)
PERRY_GC_TRACE=1 shows 13 collections. run_copied_minor_attempt is 55 % inclusive, and the old-gen incremental sweep adds 14.8 %.
Top self time:
13.92% __memmove_avx512_unaligned_erms
9.23% gc::layout_slot_visit::visit_gc_layout_slot_descriptors
7.38% CopyingNurseryCollector::mark_addr
5.74% gc::layout_slot_visit::visit_gc_rewrite_slots<…scan_object_fields…>
3.56% HeapChildSlotIterator::next
3.25% oldgen::sweep_batch::PendingOldUnregister::flush
2.94% trace::ValidPointerSetBuilder::step
2.60% trace::ValidPointerSet::contains
Where the memmove comes from (call graph):
- 6.98 % is a
memcpyinlined insidevisit_gc_layout_slot_descriptors, underrun_copied_minor_attempt - 4.53 % is under
GcCycleState::step - 2.17 % is under
gc::verify::rebuild_evacuated_old_to_young_remembered_set, which is 7.26 % inclusive and runs in a production minor despite being in theverifymodule
The write barrier is not involved: mark_dirty_old_page_uncached does not appear, and the PtrHasher insert is 0.03 %.
Policy knobs: collection count is the dominant lever
Same binary; every configuration still produces the correct output with a clean scan:
| config | instructions | collections |
|---|---|---|
| default (16 MB nursery) | 12.57 G | 13 |
PERRY_GC_TENURING_SURVIVALS=1 |
8.73 G | 10 |
PERRY_GC_TENURING_SURVIVALS=2 |
12.01 G | 13 |
PERRY_GC_TENURING_SURVIVALS=4 |
10.62 G | 13 |
PERRY_GC_SCAVENGE_NURSERY_MB=64 |
6.36 G | 4 |
PERRY_GC_SCAVENGE_NURSERY_MB=256 |
3.19 G | 1 |
PERRY_GC_SCAVENGE=0 |
12.57 G | 13 (no effect; not sure the knob does what its name suggests) |
Mutator work plus one collection is about 3.2 G. Each extra collection costs about 0.8 G on this live set. Two things are wrong at once:
- Too many collections.
tenuring.rsdescribes an influx-driven nursery scale for exactly this live-set-bound shape, but it does not grow enough here. Growing the nursery by hand removes 75 % of the instructions. - Each collection is expensive. ~0.8 G to handle a ~40 000-chain survivor set is the more fundamental cost. A bigger nursery only hides it until the live set grows again.
Hypotheses (not verified — named so the work can start from them)
HeapChildSlotIteratorholdsobject_shape: Option<ShapeDescriptor>by value, andgc_child_slots()builds one for every visited object. IfShapeDescriptoris large, that could be thememcpyinsidevisit_gc_layout_slot_descriptors.size_of::<HeapChildSlotIterator>()would settle it quickly.ValidPointerSetBuilder::step+ValidPointerSet::contains(5.5 %) rebuild a set of valid pointers on every cycle. Per-cycle cost should scale with survivors, not with a rebuilt index.- Rebuilding the remembered set after evacuation (7.3 % inclusive) runs on every minor.
- Possibly related to 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 (address-keyed layout metadata rehashed on every copying minor);
layout_transferis only 1.98 % here, so it is not the main cost on this workload.
Explicitly not claimed
No rewrite is proposed here. The nursery-size result shows where the leverage is (collection count × per-collection cost). It does not show that the fix is a bigger default nursery, which would trade memory for instructions and needs checking against the memory-parity work. Please confirm the hypotheses above with measurements before building on them.
Repro
gc3.js, plain JS, identical on all three runtimes. Expected output: checksum=-606613590 live=-593353216 nodes=320000.
var CHAINS = 40000, CHAIN_LEN = 8, TOTAL = 300000;
function makeChain(seed) {
var head = null;
for (var i = 0; i < CHAIN_LEN; i++) {
head = { id: seed + i, payload: [seed, i, (seed ^ i) | 0, (seed + i * 3) | 0], next: head };
}
return head;
}
var ring = new Array(CHAINS);
for (var i = 0; i < CHAINS; i++) ring[i] = null;
var checksum = 0;
for (var n = 0; n < TOTAL; n++) {
var c = makeChain(n);
var tag = "n" + (n % 1024);
checksum = (checksum + c.payload[n & 3] + tag.length) | 0;
ring[n % CHAINS] = c;
}
var live = 0, nodes = 0;
for (var i = 0; i < CHAINS; i++) { var cur = ring[i]; while (cur !== null) { live = (live + cur.id) | 0; nodes++; cur = cur.next; } }
console.log("checksum=" + checksum + " live=" + live + " nodes=" + nodes);To get the table rows, change CHAINS. Build perry compile gc3.ts --no-cache on #10352 and always run with PERRY_GC_FROMSPACE_SCAN_ABORT=1 to gate.
Context: how this workload got here
gc3 went from 7.26 G (v0.5.1573) to 2.34 G (v0.5.1579) through two commits, found by bisect. Then #10352 raised it to 12.57 G by restoring correct tracing:
57c2c2b2einline canonical-index array stores without a range proof: 7.26 G → 3.54 G. Thering[n % CHAINS] = cstore no longer takes the runtime slot barrier (that was the ~35 % barrier share reported and retracted in 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).2405dcb7bbuild plain-double array literals without slot notes: 3.54 G → 2.34 G. This removesrebuild_array_numeric_raw_f64(33 % of the profile).
Activity
proggeramlug commentedon Sep 16, 2026
Correction: the profile percentages in this issue are misattributed
The profile above was sampled on
instructions:uwith a fixed period. That event skids on this CPU, so samples land on the wrong symbols. Re-profiled with precise sampling (cycles:pp), same binary (#10352 head6c9d74188), gc3 at 40 000:instructions:usampling (in the issue)cycles:pp(precise)__memmove_avx512_unaligned_ermsgc::layout::layout_transfervisit_gc_layout_slot_descriptorsTwo consequences:
The memcpy hypothesis was right about the mechanism but wrong about its size.
HeapChildSlotIteratoris 152 bytes and was copied per visited object (6,181,945 exact 152-bytememcpycalls on gc3, counted with anLD_PRELOADshim). Removing the copy (local branch, not yet a PR) saves 2.36 % of instructions (12.572 G → 12.276 G), not the ~7 % the skewed profile implied. The instruction counts are exact and independently re-measured; gates: output identical to node,PERRY_GC_FROMSPACE_SCAN_ABORT=1clean, and the scan was shown to fail on a pre-fix(hir): a null-typed field is not a proof the GC may skip the slot (#10348) #10352 build.I wrongly dismissed 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. The issue says
layout_transfer"is only 1.98 % here, so it is not the main cost". Under precise sampling it is ~12 %, tied for the largest symbol. 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 (address-keyed layout metadata rehashed on every copying minor) is likely one of the main per-collection costs here. I'd now rank it ahead of the three hypotheses in the body.Unaffected: the instruction totals, the node/bun comparison, the knob sweep (nursery 256 MB → 3.19 G, tenuring=1 → 8.73 G) and the bisect results in the body all come from exact counters, not sampling.
Also:
PERRY_GC_VERIFY_EVACUATION=1exits 0 on the known-bad pre-#10352 gc3, so it is not a usable gate for this class of bug.PERRY_GC_FROMSPACE_SCAN_ABORT=1is.Methodology note for anyone profiling this: use
cycles:pp(or another precise event) for attribution, and exact counters for totals.proggeramlug commentedon Sep 16, 2026
Correction to my previous comment:
layout_transferis ~4.5 %, and #9792's rehash is not what costs hereI re-ranked #9792 above on
layout_transfer's ~12 %cycles:ppshare. A knockout (a throwaway build wherelayout_transferreturns immediately; base main33690c563+ #10371) shows the share doesn't reflect removable cost:layout_transferknocked outMost of its samples were memory-stall time right after the to-space copy, which reappear on the next instructions in
mark_addronce the function is gone.On gc3 it is also not #9792's mechanism: the address-keyed tables hold a single key, and no hash moves run. The cost is 2,560,042 calls (exact, via uprobes) that re-derive header bits the callers already copied, plus a per-object
SHAPE_LAYOUTSlookup whose answer cannot change on move. That is worth fixing (projected ~−3.75 % instructions), but it is not a main cost.The bigger lever in this issue remains the one in the body: survivors are copied again on each of the 13 minors.
PERRY_GC_TENURING_SURVIVALS=1gives −31 %, and a 256 MB nursery gives −75 %. Per-object micro-costs (−2.4 % in #10371, ~−4 % here) are small by comparison.proggeramlug commentedon Sep 16, 2026
The minor cycle is per-OBJECT bound, not per-byte: 122.7 ns/object vs a byte term already at 13.4 GB/s
This reframes the issue, and it argues for continuing in this lane rather than in GC policy. Measured on main
33690c563.The fit
Matched pairs from
[gc-copy-minor]'scopy_evacuation=us/objects/bytes, varying one term at a time (fixtures differ only in payload width):Two-point fit: 122.7 ns/object + 0.0745 ns/byte. The byte term is 13.4 GB/s — at memory bandwidth, i.e. the copy itself is already optimal and there is nothing to win there.
Decomposing gc3's ceiling cycle (640,014 objects, 30,720,672 B, 113,272 us of evacuation):
Confirmed by a minor-only profile
cycles:ppon w5000 — 8 copying minors, zero fulls, so no full-GC contamination. GC is 69.1 % of wall:gc3's whole-program profile agrees:
visit_gc_layout_slot_descriptors13.4 %,layout_transfer9.9 %,HeapChildSlotIterator::next6.6 %,visit_slot_with_parent4.3 %, against memmove 1.48 %. A 24:1 bookkeeping-to-copying ratio.Honest caveat: the wide-payload probes carry pointer-free numeric payloads, so their extra bytes carry no extra pointer slots. The defensible claim is that cost scales with objects and pointer slots, not with bytes — still bookkeeping, not copying.
Why this matters for the issue
The body of this issue leans on collection count (nursery sizing, tenuring). I tested that route and it does not survive a memory budget: raising
NURSERY_CAP_SCALE_MAX4→8 buys gc3 −45.2 % instructions for −2.0 % RSS, but costs +52.3 % peak RSS on w5000 and +19.0 % plus a pause regression on w20000. It is also not a tunable: the growth rule's equilibrium iscap ≈ 25 × retained-per-cycle, which for these workloads wants 96 MB / 384 MB / 768 MB, so every live-set-bound workload is ceiling-bound by construction. Raising the ceiling moves a clamp; it does not tune a policy. An honest version needs a memory-proportional bound, which is a product decision, not a constant.Whereas the per-object constant is a straight win with no memory cost at all, and #10371 has already taken −2.36 % out of it (6,181,945 per-object 152-byte memcpys → 6). The functions above still hold ~34 % of gc3 and ~57 % of a minor-only profile.
So: the lever in this issue is the per-object cost of a traced object, not how often collections happen. I'd treat nursery/tenuring policy as out of scope here unless someone brings a memory-proportional design.
proggeramlug commentedon Sep 16, 2026
Correction and refinement: the byte term is ~0, and the pointer-slot term is the big pool
My previous comment gave a two-term fit (122.7 ns/object + 0.0745 ns/byte) and flagged that the wide-payload probes were pointer-free. Closing that caveat changed both numbers. Retracting the 0.0745 ns/B: it came from comparing fixtures whose extra bytes arrived as 56 extra array element slots per array, not as pure bytes.
Controlled probes: 60,000 retained records of K fields, one shared heap object as every pointer target (object count constant),
ptr= K pointer fields,dbl= K doubles. Per-cycle GC structure verified identical at each K (samefrom_space,survival_permille,copied_bytes,freed_bytes, same minor count).The
dblseries is flat to 0.4 % while bytes/object grow 3.3×. So:Which symbols ride which term
Δ between
rec16_ptrandrec16_dblover 1,920,000 pointer-slot visits:CopyingNurseryCollector::visit_slot_with_parentHeapChildSlotIterator::nextCopyingNurseryCollector::mark_addris_weak_target_trace_slotvisit_gc_rewrite_slots::<scan_object_fields>scan_object_fields::{closure#0}CopyingPointerSet::classify_arenalayout::layout_transferrun_copied_minor_attemptvisit_gc_layout_slot_descriptorsTwo independent instruments agree: the slot deltas sum to 120.5 Mcyc / 1.92 M visits = 20.9 ns/slot, against 19.5 ns from the
copy_evacuationphase timer at K=16 — within 7 %. The object-term symbols are flat across an 8× change in slot count, which is what an object term should do.Pool sizes on
rec16_ptr: slot term ≈ 43 % of user cycles, object term ≈ 8.5 %.Note
visit_gc_layout_slot_descriptors— the largest single symbol on gc3 at 13.4 % — is per-object, not per-slot. That matches #10371's diagnosis (a 152-byte iterator built per traced object) and means the work landed so far is on the object term, i.e. the smaller of the two pools.The 29 % superlinear residual: cache, not TLB
There is a real dTLB capacity cliff (60× step between 3.84 MB and 15.36 MB retained, where a 4 KB-page L2 dTLB runs out). But sizing it: w5000 → gc3 costs +143 cycles/object; charging that to TLB needs 404 cycles per page walk (implausible), to cache needs 42 cycles per miss (plausible). So cache-dominated; huge pages would buy roughly a fifth, not the residual. The rest is evacuation-walk locality — a data-layout question.
Caveats: single-run profiles at 1999 Hz on a contended box (the large Δs are far above noise;
layout_transfer+2.0 andclassify_arena+7.1 are weaker).arena_alloc_gc_survivorstayed under the profile floor in these fixtures, so its term is an argument (one call per surviving object) rather than a measurement.LLC-load-missesis unsupported on this host, so L2 and LLC cannot be separated.What this means for the lane
The work so far (#10371, and the
layout_transferchange in progress) is on the object term, ~8.5 %. The pointer-slot path is ~43 %, and its top symbol alone (visit_slot_with_parent, 24.6 cycles per slot visit) is larger than the whole object pool. Anyone continuing here should start from the slot path.perf(gc): borrow the shape record in the slot visitor, stop moving th…
perf(gc): relocation moves only address-keyed layout records (#10362)
perf(gc): read the copying minor's weak-holder fact once per object (#…
proggeramlug commentedon Sep 17, 2026
Exact per-slot accounting, and two instrument traps that invalidate sampled attribution here
Everything below is callgrind-exact, not sampled, with slot counts read from
PERRY_GC_TRACE'spointer_slots_read. Measured on maine6dcb6274(i.e. with #10371, #10381 and #10388 in).Trap 1:
perf record -e instructions:umisattributes by up to 7× at function granularityOn this path it put
HeapChildSlotIterator::nextat 11.0 instructions/slot where the exact count is 74.6, and the rewrite trampoline at 87.0 where the exact count is 20.0. A fixed-c 20011period was additionally throttled to 12.7 % of events, silently. Precise events (cycles:pp) are better but still a share, not a count. Anyone attributing this path should use exact counts. Valgrind needsPERRY_TARGET_CPU=x86-64-v3— perry's codegen emits AVX-512 and SIGILLs under it otherwise.Trap 2: my earlier
rec*_ptrcontrols UNDERSTATE the real slot path by ~170 instructions/slotIn those fixtures every pointer field targets one shared object, so
mark_addr's memo hits 83.3 %. On gc3, w1000, w5000 and w20000 the memo hit rate is 0.0 %. The controls are still valid for isolating the slot term (they vary only pointer-ness), but their absolute ns/slot and instructions/slot are not the production figure. A distinct-child control is the next thing to build.The exact split, K=16 control on main
302 GC instructions per pointer-slot visit, after subtracting a no-GC control (
PERRY_GC_SCAVENGE_NURSERY_MB=4096, 0 GC events) that accounts for 78.3 mutator instructions of the 380.2 total:visit_slotHeapChildSlotIterator::nextmark_addrclassify_arenaAt IPC ≈ 5.1 this path is long, not stalled. Cutting it means removing instructions; there is no stall to recover.
The page-generation cache is not a problem — counters, not inference
Read before assuming:
arm=table, 93.4–97.3 % hit, and capacity misses are 0.004–0.018 % of lookups. Nearly every miss is an address in no registered block, and that population equals, exactly, the shape record'skeysslot (gc3: 1,400,166 unregistered misses against 1,400,203 such classifications). So the earlier suspicion thatclassify_heap_generation_uncachedindicated cache thrash was wrong twice over — #7469 had already widened that cache, and the misses that remain are a different population entirely.What is left, largest first
HeapChildSlotIterator::next, 74.6 instructions/slot. For anInlinemask theMaskedarm could walkword &= word - 1directly — roughly 10 instructions instead of 76 — and it would serve the full mark and the remembered-set rebuild too.keysword — the same slot, once per instance — at ~300–500 instructions a time, 3.5–5.8 % of gc3. Deduping per cycle is not obviously sound (feat(gc/shape): root and rewrite the keys edge from the ShapeId descriptor, not ObjectHeader.keys_array #8112's ephemeron half and per-parent remembering differ per instance), so this wants a design discussion rather than a patch.#10491 (open) takes the double classification out of the same path: gc3 −1.83 %, and the slot term 379.2 → 349.6 instructions.
perf(gc): decode a visited word once in the copying minor (#10362)
fix(gc): a relocated non-object owner must keep its explicit prototype (
proggeramlug commentedon Sep 18, 2026
A census of the collector's slot visits, and how much of it is my fixture's shape
Everything here is exact counts from an instrumented throwaway build (not merged), reconciled against
pointer_slots_readfromPERRY_GC_TRACEin the same process. Basec8cf45056.Half the visits are unproductive — on this fixture
Copying-minor slot visits on the gc3 family: 27.27% the word was never a heap pointer, 24.24% one single shared word, 48.49% real work. On the old→young fixture the unproductive share is 67.75%. The full trace, independent code, produces the same 27.27/24.24/48.49 split.
The "one shared word" is the shape record's
keysedge: on gc3 the minor visits it 1,400,166 times behind exactly one distinct record, and 1,400,161 of those are duplicates — the difference is precisely the one first visit per cycle. Marks clear per cycle, so at most 9 of 1.4 M could have done anything.But most of that is my fixture's width, stated plainly
The keys-edge share is
1/(slots enumerated), and it holds to three decimals:1/(F+1)At a zod/Effect-sized object (10–20 fields) the keys edge is worth 5.6–8.4% of visits, not 24%. Anyone landing a dedup expecting a quarter of the visits back will get about a third of that. It remains ~100% of duplicate visits at every width.
"Never a pointer" is not a width effect — it is the field-kind mix. Widening gc3's node from 3 to 16 fields, half of them numeric, holds it at 25.78% against gc3's 27.27%; on a number-heavy object it can go up. Worth noting the mechanism is already partly built: the layout mask excludes 5 of the 8 numeric fields in that fixture, so the headroom is below the headline.
Traced objects that yield no slots at all
50.000% of traced objects on gc3 produce zero slots (pointer-free arrays — one per node). Measured directly on a fixture where 100% of traced objects are slot-free: 200.00 instructions per such object (600,000 calls, 120,000,000 Ir), splitting as 61.00 iterator construction + 139.67 descriptor walk. Against the 302 Ir a real slot visit costs, walking an object for nothing costs two thirds of a slot visit.
On gc3 that is 280.0 M instructions, 2.36% of the whole program in the minor alone, plus the rebuild pass re-walking the same objects for a further 6.23%.
The existing leaf skip does not catch them: it is keyed on
obj_typeand fires 12 times out of 2.4 M, because the population is pointer-free arrays, not the strings it was written for.A measurement trap worth publishing on its own
A control whose pointer fields all target one shared object makes
mark_addr's memo hit 78.9%; with distinct targets, same program and width, 0.179%. A 440× difference that silently hidesclassify_arena(20.5 → 97.3 Ir/slot) andmark_addr(28.4 → 44.5). Any fixture used to rank symbols in this path needs distinct children, or it will rank them wrongly — as ours did.Two things deliberately not claimed
A per-object cost model regressed across 12 fixtures (
784 + 325·slots, R² = 0.90) was discarded: residuals ran −1,174 to +1,325 Ir/object because "a slot" is not one thing. The 200.00 figure above comes from a fixture where the quantity is measured directly instead.And the 200 excludes the worklist push and drain loop — both inlined and not separable — so it is "200 plus a small unmeasured remainder".
perf(gc): walk an inline slot mask instead of re-entering the iterator (
proggeramlug commentedon Sep 18, 2026
Skipping "pointer-free" objects in the collector: two ways the obvious predicate drops edges
The census in this thread found that 50.000% of traced objects on gc3 yield zero slots (pointer-free arrays, one per chain node) and cost 200.00 instructions each to walk for nothing — 2.36% of the program in the copying minor, plus a rebuild pass re-walking the same objects for a further 6.23%. The obvious optimisation is to stop walking them.
I specified that skip as
POINTER_FREE && !GC_ARRAY_NAMED_PROPS && !<residual-prototype owner>. That predicate is wrong in two independent ways, both caught before any code was written. Posting them because anyone attempting this will write the same predicate.Defect A —
GC_LAYOUT_POINTER_FREEis not an array propertyIt is a claim about the payload, and other kinds carry it:
symbol/properties.rs, GC: evacuating minor drops an old-to-young field[1] edge, crashing with 'value is not a function' #7154) and leave that state only when a capture store records a pointer. Skipping one drops the dynamic property values, the static.prototypeedge, and the [Next.js/dylib] Warm App Route server retains ~1,100 async box cells per request: 113 KB/req live Rust heap, minor-GC pause grows 59 ms -> 0.5 s #8213 async-box payload — none of which live in the capture payload.gc/layout/typed_shape.rs. Skipping one drops the meta-record edge — which perf: generalize guarded object-write fast paths beyond the #6811 two-field loop #6812 calls "fatal for the spill buffer, reachable through meta alone" — plus the shapekeysedge, the Shape descriptors no live object carries are never pruned (35k of 43.7k on claude-code): retire from a full-trace carrier note #9726 carrier notes, and overflow fields.From
gc_child_slots, only theArrayElementsarm yieldsprefix = Nonewith no meta slots.ObjectFields,RegExpFieldsandObjectMetaall carry prefix or meta edges that no payload bit describes. So the predicate needs an explicit kind term; the other two terms are only sufficient because the kind is an array.Defect B — even for arrays, the full mark is not slot-free when a proxy is live
Six of the seven
GcMutableSlotDescriptorconsumers ignorePointerFreeRange(=> {}/=> 0/=> None). The seventh is the full mark ingc/trace.rs, which — whenproxy_trace_active— callsgc_observe_traced_valueon every word of the pointer-free payload.That is how a full trace keeps a proxy alive: the proxy registry goes weak for the mark phase, and
gc_finish_full_traceprunes every entry that was not observed. A proxy id is aPOINTER_TAGvalue in the proxy-id band, not a heap pointer — which is exactly why the layout mask can legitimately describe a payload holding one as pointer-free.So skipping a pointer-free array during a full trace with a live proxy in it means the entry is never observed, is pruned, and the proxy's target and handler are collected while the proxy is live.
The part worth internalising: that fires only when a proxy is live, so every fixture in this campaign — all six — would have stayed green while shipping a use-after-free, in the consumer I had guessed was the larger half of the win.
The predicate that closes
The last term means this cannot be one unconditional predicate shared by all three consumers: the copying minor and the rebuild pass take it unconditionally, the full mark only when
proxy_trace_activeis false. That asymmetry is the finding, not an implementation detail — and it is nearly free, sincetrace.rsalready has the flag hoisted at the call site.Three further notes for anyone implementing it: the three consumers do not share a mechanism (the rebuild pass has no worklist at all, so "the minor pushes unconditionally" is true of the minor only); only the worklist push may be skipped, never
moved_headers, which drivesclear_marks; and skipping stops the pointer-free/raw-numeric counters quoted earlier in this thread, so those numbers change meaning.Implementation of the narrowed version is in progress, with a new fixture built specifically to make defect B's failure reproducible — none of the existing ones can.
perf(gc): gate the residual prototype registry on an owner header bit (…
perf(gc): don't enumerate child slots for objects that have none (#10362