You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 %.
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:
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.
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.
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. 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:
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).