Skip to content

perf(gc): on a retained object graph perry is 8.5× node — 13 minors at ~0.8G instructions each (measured on #10352) #10362

Description

@proggeramlug

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 memcpy inlined inside visit_gc_layout_slot_descriptors, under run_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 the verify module

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:

  1. Too many collections. tenuring.rs describes 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.
  2. 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)

  • HeapChildSlotIterator holds object_shape: Option<ShapeDescriptor> by value, and gc_child_slots() builds one for every visited object. If ShapeDescriptor is large, that could be the memcpy inside visit_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_transfer is 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:

Activity

  1. proggeramlug commented on Sep 16, 2026

    @proggeramlug
    ContributorAuthor

    Correction: the profile percentages in this issue are misattributed

    The profile above was sampled on instructions:u with 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 head 6c9d74188), gc3 at 40 000:

    symbol instructions:u sampling (in the issue) cycles:pp (precise)
    __memmove_avx512_unaligned_erms 14.35 % 1.33 %
    gc::layout::layout_transfer 1.96 % 11.12 %
    visit_gc_layout_slot_descriptors 8.75 % 12.41 %

    Two consequences:

    1. The memcpy hypothesis was right about the mechanism but wrong about its size. HeapChildSlotIterator is 152 bytes and was copied per visited object (6,181,945 exact 152-byte memcpy calls on gc3, counted with an LD_PRELOAD shim). 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=1 clean, 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.

    2. 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=1 exits 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=1 is.

    Methodology note for anyone profiling this: use cycles:pp (or another precise event) for attribution, and exact counters for totals.

  2. proggeramlug commented on Sep 16, 2026

    @proggeramlug
    ContributorAuthor

    Correction to my previous comment: layout_transfer is ~4.5 %, and #9792's rehash is not what costs here

    I re-ranked #9792 above on layout_transfer's ~12 % cycles:pp share. A knockout (a throwaway build where layout_transfer returns immediately; base main 33690c563 + #10371) shows the share doesn't reflect removable cost:

    gc3 instructions:u cycles:u
    normal 12.384 G 3.081 G
    layout_transfer knocked out 11.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_addr once 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_LAYOUTS lookup 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=1 gives −31 %, and a 256 MB nursery gives −75 %. Per-object micro-costs (−2.4 % in #10371, ~−4 % here) are small by comparison.

  3. proggeramlug commented on Sep 16, 2026

    @proggeramlug
    ContributorAuthor

    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]'s copy_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:pp on 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_erms
    

    gc3's whole-program profile agrees: visit_gc_layout_slot_descriptors 13.4 %, layout_transfer 9.9 %, HeapChildSlotIterator::next 6.6 %, visit_slot_with_parent 4.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_MAX 4→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 is cap ≈ 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.

  4. proggeramlug commented on Sep 16, 2026

    @proggeramlug
    ContributorAuthor

    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 (same from_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 dbl series 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_ptr and rec16_dbl over 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_evacuation phase 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 and classify_arena +7.1 are weaker). arena_alloc_gc_survivor stayed under the profile floor in these fixtures, so its term is an argument (one call per surviving object) rather than a measurement. LLC-load-misses is 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_transfer change 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.

  5. proggeramlug commented on Sep 17, 2026

    @proggeramlug
    ContributorAuthor

    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's pointer_slots_read. Measured on main e6dcb6274 (i.e. with #10371, #10381 and #10388 in).

    Trap 1: perf record -e instructions:u misattributes by up to 7× at function granularity

    On this path it put HeapChildSlotIterator::next at 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 20011 period 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 needs PERRY_TARGET_CPU=x86-64-v3 — perry's codegen emits AVX-512 and SIGILLs under it otherwise.

    Trap 2: my earlier rec*_ptr controls UNDERSTATE the real slot path by ~170 instructions/slot

    In 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_slot 83.5
    HeapChildSlotIterator::next 74.6
    scan closure 73.0
    mark_addr 24.4
    rewrite trampoline 20.0
    descriptor loop 14.0
    classify_arena 12.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's keys slot (gc3: 1,400,166 unregistered misses against 1,400,203 such classifications). So the earlier suspicion that classify_heap_generation_uncached indicated 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

    1. HeapChildSlotIterator::next, 74.6 instructions/slot. For an Inline mask the Masked arm could walk word &= word - 1 directly — roughly 10 instructions instead of 76 — and it would serve the full mark and the remembered-set rebuild too.
    2. Every traced shaped object re-visits its shared shape record's keys word — 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.

  6. proggeramlug commented on Sep 18, 2026

    @proggeramlug
    ContributorAuthor

    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_read from PERRY_GC_TRACE in the same process. Base c8cf45056.

    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 keys edge: 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_type and 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 hides classify_arena (20.5 → 97.3 Ir/slot) and mark_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".

  7. proggeramlug commented on Sep 18, 2026

    @proggeramlug
    ContributorAuthor

    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_FREE is not an array property

    It is a claim about the payload, and other kinds carry it:

    From gc_child_slots, only the ArrayElements arm yields prefix = None with no meta slots. ObjectFields, RegExpFields and ObjectMeta all 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 GcMutableSlotDescriptor consumers ignore PointerFreeRange (=> {} / => 0 / => None). The seventh is the full mark in gc/trace.rs, which — when proxy_trace_active — calls gc_observe_traced_value on 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_trace prunes every entry that was not observed. A proxy id is a POINTER_TAG value 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_active is false. That asymmetry is the finding, not an implementation detail — and it is nearly free, since trace.rs already 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 drives clear_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.

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