Skip to content

perf(gc): 88% of a real TypeScript transpile is GC — 244 full collections per transpileModule, because #7937's absolute old-reclaim arm re-arms after every collection (a ~42 MiB live set parked just under the 48 MiB threshold) #10928

Description

@proggeramlug

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.

Activity

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