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
Activity
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:symbol instructions:usampling (in the issue)cycles:pp(precise)__memmove_avx512_unaligned_erms14.35 % 1.33 % gc::layout::layout_transfer1.96 % 11.12 % visit_gc_layout_slot_descriptors8.75 % 12.41 % Two 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.-
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:gc3 instructions:u cycles:u normal 12.384 G 3.081 G layout_transferknocked out11.829 G (−4.5 %) 2.987 G (−3.1 %) Most 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.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):holding probe objects bytes B/obj evac_us objects constant w1000 cyc3 16,008 768,384 48 2,021 big1000 cyc3 16,001 4,352,496 272 2,288 ±0 % ×5.67 +13.2 % bytes constant big4000 cyc11 64,003 17,409,040 272 11,762 w20000 cyc7 320,014 15,360,672 48 49,671 ×5.0 −12 % ×4.22 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):
term us share per-object 78,530 69 % per-byte 2,289 2.0 % superlinear residual (cache/TLB) 32,453 29 % 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:8.51% layout_slot_visit::visit_gc_layout_slot_descriptors 7.73% CopyingNurseryCollector::visit_slot_with_parent 7.44% layout::layout_transfer 6.14% CopyingNurseryCollector::scan_object_fields::{closure#0} 5.35% HeapChildSlotIterator::next 4.27% CopyingNurseryCollector::mark_addr 3.46% arena_alloc_gc_survivor 2.75% is_weak_target_trace_slot 2.70% CopyingPointerSet::classify_arena ... ≈57% per-object GC machinery, total 2.35% __memmove_avx512_unaligned_ermsgc3'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.
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).K ptr evac_us dbl evac_us ptr ns/obj dbl ns/obj B/obj Δ per pointer slot 2 8,597 4,750 143.3 79.2 48 32.1 ns 8 15,459 4,769 257.6 79.5 96 22.3 ns 16 23,489 4,749 391.5 79.2 160 19.5 ns The
dblseries is flat to 0.4 % while bytes/object grow 3.3×. So:term cost per object 79.2 ns per pointer slot 19.5–32.1 ns (least squares: 17.7 ns/slot + 31.7 ns one-time) per non-pointer array element slot ~0.6 ns per non-pointer object field slot ~0 (masked out) per byte ~0 (bounded ≤ 0.005 ns/B, measured not fitted) Which symbols ride which term
Δ between
rec16_ptrandrec16_dblover 1,920,000 pointer-slot visits:symbol Δ Mcyc cycles/slot term CopyingNurseryCollector::visit_slot_with_parent+47.2 24.6 slot HeapChildSlotIterator::next+16.0 8.3 slot CopyingNurseryCollector::mark_addr+14.2 7.4 slot is_weak_target_trace_slot+12.9 6.7 slot visit_gc_rewrite_slots::<scan_object_fields>+12.3 6.4 slot scan_object_fields::{closure#0}+10.7 5.6 slot CopyingPointerSet::classify_arena+7.1 3.7 slot layout::layout_transfer+2.0 1.1 object run_copied_minor_attempt+2.0 1.0 object visit_gc_layout_slot_descriptors−3.2 ~0 object Two 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
fixture retained/cycle cache-misses/obj dTLB-load-misses/obj IPC evac ns/obj w1000 0.77 MB 2.34 0.008 4.73 126.2 w5000 3.84 MB 1.88 0.0025 4.46 129.2 w20000 15.36 MB 3.20 0.150 3.59 154.7 gc3 30.7 MB 5.28 0.356 3.67 177.0 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.- added 3 commits that reference this issue
on Sep 16, 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:item instructions/slot visit_slot83.5 HeapChildSlotIterator::next74.6 scan closure 73.0 mark_addr24.4 rewrite trampoline 20.0 descriptor loop 14.0 classify_arena12.5 At 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.- Every traced shaped object re-visits its shared shape record's
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.
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:rec2 (F=3) rec8 (F=9) rec16 (F=17) predicted 1/(F+1)25.000% 10.000% 5.556% measured 25.000% 10.000% 5.556% 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".
- added a commit that references this issue
on 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:
- Closures are allocated pointer-free (
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. - Typed objects with an empty pointer mask acquire it at two sites in
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
array kind && POINTER_FREE && !GC_ARRAY_NAMED_PROPS && !residual_entry_possible_for(header) && !(full trace && proxy_trace_active)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.
- Closures are allocated pointer-free (
Measured on: PR #10352 head
6c9d74188(= mainfcd108bfbv0.5.1579 + the #10348 fix), release build, Linux x86_64 (perrybuilder). node v26.8.1, bun 1.4.2. Metric isinstructions: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.
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=1shows 13 collections.run_copied_minor_attemptis 55 % inclusive, and the old-gen incremental sweep adds 14.8 %.Top self time:
Where the
memmovecomes from (call graph):memcpyinlined insidevisit_gc_layout_slot_descriptors, underrun_copied_minor_attemptGcCycleState::stepgc::verify::rebuild_evacuated_old_to_young_remembered_set, which is 7.26 % inclusive and runs in a production minor despite being in theverifymoduleThe write barrier is not involved:
mark_dirty_old_page_uncacheddoes not appear, and thePtrHasherinsert is 0.03 %.Policy knobs: collection count is the dominant lever
Same binary; every configuration still produces the correct output with a clean scan:
PERRY_GC_TENURING_SURVIVALS=1PERRY_GC_TENURING_SURVIVALS=2PERRY_GC_TENURING_SURVIVALS=4PERRY_GC_SCAVENGE_NURSERY_MB=64PERRY_GC_SCAVENGE_NURSERY_MB=256PERRY_GC_SCAVENGE=0Mutator 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:
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.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.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.To get the table rows, change
CHAINS. Buildperry compile gc3.ts --no-cacheon #10352 and always run withPERRY_GC_FROMSPACE_SCAN_ABORT=1to 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).