Summary
On a real TypeScript workload, ~88% of perry's instructions are the garbage collector, and
the cause is a pacing arm: 244 full collections in one ts.transpileModule call, each
tracing ~182 MiB of live old-gen to reclaim ~7.9 MiB. The arm that fires is the #7937
absolute first-crossing arm of old_reclaim_pressure_due. It is meant to fire once, when old-gen
first crosses 48 MiB, but it re-arms after every collection, because the post-reclaim
baseline (~42 MiB) never reaches the threshold. The result is the fixed-bytes cadence that
#7592's own comment says makes total major-GC work quadratic in the live set.
The pacing code is byte-identical between the measured build (v0.5.1631, f88acdaa1) and current
main (0fa391529).
The workload and the gap
/root/fr/work/tscwork.ts on perrymaster (lane 14's): builds a 1,200-line TypeScript source and
calls ts.transpileModule on it 3 times (typescript 5.8.2). Complete runs, both output
typescript 5.8.2 290280, identical to node.
|
instructions:u |
wall |
| node v26.5.1, min of 3 (2% spread) |
7.13 G |
~1.3 s user |
perry, lane 14's complete run (perf stat, stripped binary) |
1,599.70 G |
418 s |
| perry, this attribution run (826,329 samples × period 2,000,003) |
1,652.66 G |
498 s under perf record |
perry / node ≈ 224–232×. The two perry counts come from independent binaries and methods and
agree within 3%. This is a different workload from the 9.7× reported in September, which was
tsc --noEmit type-checking (scanner/parser over lib.*.d.ts), timed by wall clock on macOS
arm64.
Where the instructions go
perf record -e instructions:u -c 2000003 on a PERRY_KEEP_SYMBOLS=1 +
PERRY_NO_AUTO_OPTIMIZE=1 build, self time, bucketed by the demangled symbol's module path.
Relative standard error per bucket ≈ 1/√samples.
| subsystem |
samples |
≈ instr |
share |
±SE |
GC / allocation (perry_runtime::gc::*, arena, mimalloc) |
706,719 |
1,413.4 G |
85.53% |
0.1% |
+ weakref::is_weak_target_trace_slot (GC tracing, placed under "other" by the first pass) |
23,860 |
47.7 G |
2.89% |
0.6% |
object model (perry_runtime::object::*, closures, proxy, symbols) |
60,285 |
120.6 G |
7.30% |
0.4% |
| other runtime (excluding the weakref row) |
10,900 |
21.8 G |
1.32% |
1.0% |
unclassified (quicksort<usize> 0.62%, SipHash ~0.2% — no call graph was recorded) |
7,882 |
15.8 G |
0.95% |
1.1% |
| arrays |
7,011 |
14.0 G |
0.85% |
1.2% |
| strings |
6,066 |
12.1 G |
0.73% |
1.3% |
compiled code (perry_fn_*, perry_method_*, closures) |
2,542 |
5.1 G |
0.31% |
2.0% |
| maps / sets, regex, numbers, exceptions |
1,064 |
2.1 G |
0.13% |
— |
GC ≈ 88.4% of the run. perry's own compiled code — 5.1 G — is less than node's entire
run (7.13 G). A zero-cost object model would leave perry at ~215× node.
Top 15 symbols: 14 are GC and the 15th is the weakref trace slot. IncrementalSweepState::step
19.7%, ValidPointerSet::contains 11.6%, trace_heap_rewrite_slots 8.1% + 4.0%,
visit_gc_rewrite_slots 6.1%, visit_gc_layout_slot_descriptors 5.9%,
remember_evacuated_old_to_young_slot 4.8%, remembered_child_needs_tracking 3.2%,
ValidPointerSetBuilder::step 3.2%, OldToYoungRememberedRebuildState::step 2.9%,
per_object_slot_mask 2.6%, … Two notes so nobody chases the wrong thing: gc::verify is the
module that holds the production forwarding rewrite and remembered-set rebuild, not a debug
verifier left on; and ValidPointerSet is already a per-block object-start bitmap with a fence
binary search. It is hot because it is called for every traced pointer, not because it is slow.
Why: the collection count
PERRY_GC_DIAG=1 (diagnostic only, no behaviour change) on one iteration:
| event |
count |
[gc-full] full collections |
244 |
[gc] collections, all kinds |
256 |
triggers site=alloc_point kind=OldReclaim |
242 |
triggers safepoint kind=ArenaBytes |
14 |
triggers budgeted_start due |
12 |
[gc-copy-minor] |
26 |
Every one of the 242 OldReclaim triggers reports the same old_threshold=50331648 (48 MiB). At the
trigger:
| per full collection (median of 242) |
|
watched quantity (old_reclaimable) at trigger |
47.6 MiB |
old_baseline (post-reclaim value) |
~42 MiB |
| watched quantity reclaimed |
7.9 MiB |
live old gen the collection has to trace (old_in_use − old_reclaimable) |
182.2 MiB |
old_free — free old-gen space at the trigger |
182.2 MiB |
| traced : reclaimed |
23 : 1 |
| GC instructions per full collection (≈ 88.4% × 550.9 G per iteration ÷ 244) |
~2.0 G |
It is not memory pressure: 182 MiB of old-gen is free every time.
The arm, from source (gc/policy.rs:1881, unchanged on main)
pub(super) fn old_reclaim_pressure_due(old_in_use: usize, baseline: usize) -> bool {
let threshold = gc_old_gen_reclaim_threshold_dyn_bytes(); // 48 MiB
let crossed_absolute_threshold = old_in_use >= threshold
&& baseline < threshold
&& !GC_MAJOR_PACING_RETAINING.with(|c| c.get());
crossed_absolute_threshold
|| old_in_use.saturating_sub(baseline) >= gc_old_reclaim_growth_band_bytes(baseline)
}
With the measured values — old_in_use ≈ 47.6–50 MiB ≥ 48 MiB, baseline ≈ 42 MiB < 48 MiB, and
RETAINING false because tsc's young generation is dying — the absolute arm is true every
time, so the proportional arm is never consulted. The #7937 comment on this very arm says "it is
the arm that actually fires", and exempts it only while the heap is RETAINING. After each full
collection the baseline resets to the post-reclaim value (~42 MiB). That is still below 48 MiB, so
the "first crossing" is re-armed, and the next ~7.9 MiB of watched growth crosses it again.
That is the shape #7592's comment on gc_old_reclaim_growth_band_bytes describes and was written
to remove: "each full reclaim costs O(live), so a fixed-bytes cadence makes total major-GC work
quadratic in the live set." Here the fixed cadence arrives through the absolute arm instead of the
band.
One step further, and marked as inference rather than measurement. Removing the absolute arm
alone would not make the pacing proportional to this heap. The proportional arm's baseline is the
same ~42 MiB watched quantity, so its band is max(32 MiB floor, 42/2) = the 32 MiB floor: a full
collection per ~32 MiB of watched growth instead of per ~7.9 MiB, roughly 4× fewer. The ~182 MiB
live set never enters either side of the comparison. Whether the watched quantity
(old_gen_reclaimable_pressure_bytes) is the right thing to pace on is the deeper question, and it
is for the GC lane.
Relation to existing issues
Diagnosis only; nothing built or proposed as a patch. Artefacts on perrymaster:
/root/matrix/tsc_attr.data (perf record), /root/matrix/tsc_gcdiag.err (GC diag),
/root/matrix/attribute.py.
Summary
On a real TypeScript workload, ~88% of perry's instructions are the garbage collector, and
the cause is a pacing arm: 244 full collections in one
ts.transpileModulecall, eachtracing ~182 MiB of live old-gen to reclaim ~7.9 MiB. The arm that fires is the #7937
absolute first-crossing arm of
old_reclaim_pressure_due. It is meant to fire once, when old-genfirst crosses 48 MiB, but it re-arms after every collection, because the post-reclaim
baseline (~42 MiB) never reaches the threshold. The result is the fixed-bytes cadence that
#7592's own comment says makes total major-GC work quadratic in the live set.
The pacing code is byte-identical between the measured build (v0.5.1631,
f88acdaa1) and currentmain (
0fa391529).The workload and the gap
/root/fr/work/tscwork.tson perrymaster (lane 14's): builds a 1,200-line TypeScript source andcalls
ts.transpileModuleon it 3 times (typescript 5.8.2). Complete runs, both outputtypescript 5.8.2 290280, identical to node.perf stat, stripped binary)perf recordperry / node ≈ 224–232×. The two perry counts come from independent binaries and methods and
agree within 3%. This is a different workload from the 9.7× reported in September, which was
tsc --noEmittype-checking (scanner/parser overlib.*.d.ts), timed by wall clock on macOSarm64.
Where the instructions go
perf record -e instructions:u -c 2000003on aPERRY_KEEP_SYMBOLS=1+PERRY_NO_AUTO_OPTIMIZE=1build, self time, bucketed by the demangled symbol's module path.Relative standard error per bucket ≈ 1/√samples.
perry_runtime::gc::*, arena, mimalloc)weakref::is_weak_target_trace_slot(GC tracing, placed under "other" by the first pass)perry_runtime::object::*, closures, proxy, symbols)quicksort<usize>0.62%, SipHash ~0.2% — no call graph was recorded)perry_fn_*,perry_method_*, closures)GC ≈ 88.4% of the run. perry's own compiled code — 5.1 G — is less than node's entire
run (7.13 G). A zero-cost object model would leave perry at ~215× node.
Top 15 symbols: 14 are GC and the 15th is the weakref trace slot.
IncrementalSweepState::step19.7%,
ValidPointerSet::contains11.6%,trace_heap_rewrite_slots8.1% + 4.0%,visit_gc_rewrite_slots6.1%,visit_gc_layout_slot_descriptors5.9%,remember_evacuated_old_to_young_slot4.8%,remembered_child_needs_tracking3.2%,ValidPointerSetBuilder::step3.2%,OldToYoungRememberedRebuildState::step2.9%,per_object_slot_mask2.6%, … Two notes so nobody chases the wrong thing:gc::verifyis themodule that holds the production forwarding rewrite and remembered-set rebuild, not a debug
verifier left on; and
ValidPointerSetis already a per-block object-start bitmap with a fencebinary search. It is hot because it is called for every traced pointer, not because it is slow.
Why: the collection count
PERRY_GC_DIAG=1(diagnostic only, no behaviour change) on one iteration:[gc-full]full collections[gc]collections, all kindssite=alloc_point kind=OldReclaimsafepoint kind=ArenaBytesbudgeted_start due[gc-copy-minor]Every one of the 242 OldReclaim triggers reports the same
old_threshold=50331648(48 MiB). At thetrigger:
old_reclaimable) at triggerold_baseline(post-reclaim value)old_in_use − old_reclaimable)old_free— free old-gen space at the triggerIt is not memory pressure: 182 MiB of old-gen is free every time.
The arm, from source (
gc/policy.rs:1881, unchanged on main)With the measured values —
old_in_use≈ 47.6–50 MiB ≥ 48 MiB,baseline≈ 42 MiB < 48 MiB, andRETAININGfalse because tsc's young generation is dying — the absolute arm is true everytime, so the proportional arm is never consulted. The #7937 comment on this very arm says "it is
the arm that actually fires", and exempts it only while the heap is RETAINING. After each full
collection the baseline resets to the post-reclaim value (~42 MiB). That is still below 48 MiB, so
the "first crossing" is re-armed, and the next ~7.9 MiB of watched growth crosses it again.
That is the shape #7592's comment on
gc_old_reclaim_growth_band_bytesdescribes and was writtento remove: "each full reclaim costs O(live), so a fixed-bytes cadence makes total major-GC work
quadratic in the live set." Here the fixed cadence arrives through the absolute arm instead of the
band.
One step further, and marked as inference rather than measurement. Removing the absolute arm
alone would not make the pacing proportional to this heap. The proportional arm's baseline is the
same ~42 MiB watched quantity, so its band is
max(32 MiB floor, 42/2)= the 32 MiB floor: a fullcollection per ~32 MiB of watched growth instead of per ~7.9 MiB, roughly 4× fewer. The ~182 MiB
live set never enters either side of the comparison. Whether the watched quantity
(
old_gen_reclaimable_pressure_bytes) is the right thing to pace on is the deeper question, and itis for the GC lane.
Relation to existing issues
minor collections (13 minors at ~0.8 G each,
run_copied_minor_attempt55% inclusive). Hereminors are 26 of 256 collections; the cost is full collections paced by OldReclaim. Same
family (O(live) work repeated too often), different trigger.
+diamond executions in this same run. Against 1,600 G thatis ~0.002–0.003% of the run. The attribution above is why: the program is almost entirely GC.
mint/transition, at 2.4% of the run.
Diagnosis only; nothing built or proposed as a patch. Artefacts on perrymaster:
/root/matrix/tsc_attr.data(perf record),/root/matrix/tsc_gcdiag.err(GC diag),/root/matrix/attribute.py.