Repository navigation
GC nursery pacing (#11645): accepted regressions to recover #11699
Description
The owner accepted these regressions on 2026-09-30 when landing #11645 ("land 11645 now, but let's have an issue for the small regression with as many details as we can right now"). This issue tracks getting them back. Part of #11549.
#11645 (survival-aware nursery pacing) starts the scavenge nursery ladder at 4 MB instead of 16 MB. That wins big on package workloads: dotenv −30% instructions, and validator, date-fns, jwt and uuid −20–30% peak RSS. It costs small or fully-live programs one or more extra minors. The rows below are the ones that got worse.
Numbers (main vs PR)
| row | Δ instructions | Δ peak RSS | minors/fulls, main → PR |
|---|---|---|---|
| dotenv_parse | −30.5% | −20.3% | 0/7 → 61/0 |
| moment_parse_format | −20.6% | +3.1% (21 reps; +5.4% at 11 reps) | 1/33 → 43/0 |
| validator_batch | −3.7% | −26.0% | 46/0 → 211/0 |
| date-fns_format_add | −1.0% | −30.0% | 17/0 → 71/0 |
| qs_parse_nested | +0.36% | +1.2% (ranges overlap) | 11/0 → 14/0 |
| qs_stringify_nested | −0.3% | +0.6% | 24/10 → 24/10 |
| jsonwebtoken_decode | −3.6% | −22.0% | 2/0 → 17/0 |
| uuid_v4 | −0.1% | −21.0% | 1/0 → 6/0 |
| alloc loop | +0.57% | −20.0% | 67/0 → 203/0 |
| binary-trees n=3 | +746% (24.95 M → 211.09 M) | +63.8% (15.5 → 25.3 MB) | 0/0 → 1/0 |
| binary-trees n=6 | +649% (28.69 M → 214.83 M) | +59.0% | 0/0 → 1/0 |
| binary-trees n=10 | +553% (33.68 M → 219.81 M) | +54.7% | 0/0 → 1/0 |
| binary-trees n=20 | −4.0% | −8.9% | 1/0 → 2/0 |
| gc_ratchet 01_nursery_churn | −4.5% | −25.5% | 1/1 → 4/1 |
| gc_ratchet 02_survivor_promotion | +17.3% (276.8 M → 324.6 M) | −11.6% | 1/1 → 2/1 |
| gc_ratchet 12_large_live_set | −5.7% | +2.6% (105.6 → 108.4 MB) | 5/1 → 6/1 |
The rows in bold are the accepted regressions.
Method
- Host: perrymaster (Linux x86_64, Zen 4, 16 cores, shared). Both arms were built with
--release(-p perry -p perry-runtime-static -p perry-stdlib-static), compiled withPERRY_NO_AUTO_OPTIMIZE=1, and run withTZ=UTC. - Main was
7fa094cb4. The PR was0cce8784f, i.e. perf(gc): survival-aware nursery pacing, 4 MB floor on the existing influx ladder (includes #11612) #11645's five commits (HOLD (RSS trade): perf(regex): stop charging operation scratch to GC pressure; grow lent registers (bounded) #11612 ×2, pacing, root-holders classify, changelog) on that main. perf(gc): survival-aware nursery pacing, 4 MB floor on the existing influx ladder (includes #11612) #11645 was later rebased onto6a5090751as427728113; the numbers were not re-taken on that base. - THP was off (
MIMALLOC_ALLOW_THP=0, i.e. 4 KiB accounting). The host has THP inmadvisemode, and THP-on RSS changes from run to run by several %; see perf(gc): survival-aware nursery pacing, 4 MB floor on the existing influx ladder (includes #11612) #11645's body. - Instructions:
perf stat -e instructions:u. Package and loop rows are per iteration, from a two-N differential (median of 3 at n1, median of 11 at n2). binary-trees and gc_ratchet rows are totals for a fixed size. - Peak RSS:
/usr/bin/time %Mon the binary, median of 11, arms interleaved. moment, 12 and 02 were re-run at 21 reps. - Minor and full counts come from
PERRY_GC_DIAG=1. - Everything ran under the shared bench lock
/tmp/perry-bench-lock.d. - Raw data on perrymaster:
/root/claude-gc-nursery-pacing-bench/m6.txt: instructions and RSS, all rows./root/claude-gc-nursery-pacing-bench/m7.txt: the 21-rep RSS re-run of moment, 12 and 02.- Binaries:
/root/claude-gc-nursery-pacing-bench/main2/and/root/claude-gc-nursery-pacing-bench/pr2/. - Scripts:
/root/claude-gc-nursery-pacing-{meas2,meas3,compile}.sh, tabulated bybench/tab.pyandbench/tab3.py.
- Fixtures:
benchmarks/packages/*(dotenv 18.0.1, qs 6.16.0, moment 2.31.0, …) andbenchmarks/gc_ratchet/probes/{01,02,12}. binary-trees, alloc and retain are the.tsfiles in the bench dir.
Per-row mechanisms
- binary-trees n=3/6/10. The program builds a ~5 MB tree that stays fully live and exits after 7–9 MB of allocation. Main never collects it. The PR runs one minor at the 4 MB floor, and that minor promotes the whole tree in place: about +186 M instructions. Earlier profiling put this minor's cost at ~120 M of trace, which is the collector's general per-object visit/classify cost (
visit_gc_layout_slot_descriptors,scan_object_fields,classify_arena,visit_slot_with_weak_fact), plus ~33 M of non-trace fixed setup. Main pays the same per-object cost from n≥20 on, where n=20 is −4.0% here. The extra RSS comes from the minor'sworklistandmoved_headersdoubling from a zero estimate. A never-reallocating header list was tried onwip/11549-header-list-experiment: it cut about 3 MB, but traded RSS elsewhere under THP (binary-trees 10→40 +7%, gc_ratchet 01 +3.6%), so it was not landed. - gc_ratchet 02 (+17.3%). The PR runs 2 minors where main runs 1. Minor 2 re-copies minor 1's surviving cohort, and the final
gc()full then sweeps a young generation still full of garbage. The old perf(gc): survival-aware nursery pacing, 4 MB floor on the existing influx ladder (includes #11612) #11645 table showed +4.3%, but that compared main without perf(gc): the first collection's barrier-arming walk skips wholly-nursery blocks #11668 against the PR with it. With perf(gc): the first collection's barrier-arming walk skips wholly-nursery blocks #11668 on main (d7df6e7), this is the second minor's true cost. - gc_ratchet 12 (+2.6% RSS). One extra early minor (6 vs 5) promotes part of the large live set earlier than main does.
- alloc loop (+0.57%). 203 minors against 67. Each minor has a fixed cost of ~100 k instructions (perf(gc): cut a copying minor's fixed cost (intern young log, skip empty array-tail tables, young-only prunes) #11634 line of work). The 136 extra minors × ~106 k account for the whole delta. To break even, the fixed cost would have to fall to ~35 k per minor.
- qs_parse_nested (+0.36%). 14 minors against 11. See the qs prunes below.
Unexplained: moment's RSS shift
On 10ece9958 (the previous #11645 measurement), moment's THP-off RSS was main 40.8 MB against PR 35.3 MB (−13.5%). On 7fa094cb4, main is 39.6 MB (range 37.7–45.2) and the PR is 40.8 MB (range 40.0–41.2). Main barely moved; the PR's median rose ~5.5 MB. Minor and full counts are unchanged (1/33 against 43/0), and the −20.6% instruction win still holds.
Nothing has been bisected. The candidates are the GC- or rooting-touching commits in 10ece9958..7fa094cb4:
- d7df6e7 perf(gc): the first collection's barrier-arming walk skips wholly-nursery blocks #11668 (first-minor arming walk skips wholly-nursery blocks). It was in both PR measurements, but main only gained it now.
- d2dcafe fix(codegen): root the function across Func.prototype.x = <call> (#11635) #11639: roots the function across
Func.prototype.x = <call>. moment does this pervasively at startup, and its peak is set in the first ~0.2 s by the startup cohort's copy. - 329bc71 fix(gc): pinned objects are marked, traced and scanned as roots (use-after-free in cross-thread promises) #11664: pinned objects are marked, traced and scanned as roots.
- d4ea6be perf: function objects point to a static JsFunctionInfo; delete the closure-body registry #11654: function objects point to a static JsFunctionInfo; the closure-body registry is deleted.
- 0abf224 shapes: link-time static ShapeIds by content — runtime adoption + driver pass; REGISTERED_TYPED_SHAPES deleted (charter step 4) #11653 and e8ae819 perf(runtime): store facts are shape identity; a store is one shape compare (charter step 3) #11646: static ShapeIds and store facts.
- 767a791 arrays: exact allocation size, a hole-filled tail, and a one-word element read guard (S0, S1) #11659: exact array allocation size.
The first step is to bisect the PR arm over this range with MIMALLOC_ALLOW_THP=0, looking at moment's peak RSS.
Expected help from #11676 (per-object trace cost)
#11676 cuts the per-object trace ~35%. With #11645 stacked on it earlier, binary-trees came out at +474/+412/+351% (n=3/6/10) instead of +746/+649/+553%, and 02 at −2.8% instead of +17.3%. So once #11676 lands, 02 should drop off this list, and binary-trees should roughly halve.
Candidate next steps
-
Prune the fixed per-minor work that qs's minors spend time in. Shares of qs minor time: closure box-capture prune 16%, layout-owner prune/sort 12%, remembered-set rebuild 12%. Making these incremental or skipping them when nothing changed helps qs, the alloc loop and every high-minor-count row.
-
Cut the first minor's fixed setup (the ~33 M of non-trace work in binary-trees' single minor).
-
A survival-aware first-minor policy. At the first minor, survival alone does not separate binary-trees' fully-live tree from a package's startup cohort, which is 10–31% alive (0.4–1.3 MB). Measured alternatives:
- Powering on at the 16 MB base fixes binary-trees and 02 but costs dotenv +22.6% and moment +25.1% RSS.
- An 8 MB start is a size-specific fit that costs 02 +20.6%.
A policy would need a signal beyond first-minor survival, e.g. live bytes relative to allocated bytes, or deferring the promotion decision.
Activity
proggeramlug commentedon Sep 30, 2026
Re-measured after #11676 merged. Main
034b1ea38against #11645 at1412ce8f2, with the same method as the issue body: THP off, the bench lock, and median of 11. Raw data is in/root/claude-gc-nursery-pacing-bench/m8.txton perrymaster.10ece9958..034b1ea38, including perf(gc): cut the copying minor's per-live-object trace cost (straight-line object scan, hoisted per-object facts, exact memos) #11676.Correctness on the rebased head is clean:
cargo test -p perry-runtimepasses.PROTECT_FROMSPACEsweeps pass on dotenv, moment, qs and binary-trees: 200/200 on the previous base and 50/50 on the final head.#11645 is now ready at
356e189a3, on main5fbc2c3b0. The numbers above were not re-taken after that last rebase: main's P4 shape-only GC flip is in between and could move them.proggeramlug commentedon Sep 30, 2026
Owner decision 2026-09-30: #11645 lands with moment peak RSS at +13.8% (37.4 → 42.5 MB, main 034b1ea vs PR, THP off, median of 11). That is above the +3.1% accepted earlier. The change comes from main itself: main now runs 0 minors on moment, where it used to run 1. Recovering it is tracked here, together with gc_ratchet 02 at +19.8% instructions. Next step: bisect main between 7fa094c and 034b1ea for moment's minor-count change.
proggeramlug commentedon Oct 2, 2026
Additional nursery/headroom tradeoff on a cyclic graph workload, measured after #11645 landed:
Measured 2026-10-01 on macOS arm64, Perry main
d40ed1a47019bcae547819ebb5756bcd96ba1057(v0.5.1655), with matching compiler and runtime archives. This snapshot already includes #11645. These are diagnostic probes; no compiler/runtime fix was applied and the original comparison remains unchanged.PERRY_GC_SCAVENGE_NURSERY_MB=8PERRY_GC_SCAVENGE_NURSERY_MB=4Three sequential repeats per arm; CPU = child user + system time from wait4, RSS = peak resident memory. Every run matched the original full stdout and exit status, including parent identity and closure/cache checks. Default, nursery-8, nursery-4 and typed-local arms rotated order across three rounds; the combined arm ran afterward. Same constants, node count and 1 GiB limit.
Reducing the base alone costs approximately 12% CPU while reducing RSS approximately 40%. Separate GC diagnostic captures report 981 copying minors at base 4 versus 274 at default, and exit arena capacity 36 MiB versus 72 MiB. Diagnostic timing/counts are not substituted for uninstrumented CPU/RSS. The combined arm is 43.3% lower CPU and 39.5% lower RSS versus fresh default; its CPU win comes from a separate compiler-call-lowering probe.
The explicit base override is not equivalent to #11645's 4 MB starting influx-ladder floor: the base can be dynamically scaled and is not the total/effective nursery size. This is residual workload evidence after the adaptation landed, not a pre-#11645 comparison or a recommendation to lower defaults globally. It does not establish which additional policy change would recover the CPU cost.
Compiler-side opportunity and complete unchanged reproducer: #11743.
Evidence in the project-comparison workspace:
demo/results/stress/perry-profile/summary.md,profile-manifest.json,cpu-summary.json,experiment-summary.json,experiments.json,gc-diagnostics.stderr,nursery-4-gc-diagnostics.stderr,census.jsonl,vmmap-*.txt, and the original/typed-receivermakeTreedisassemblies. These paths are local artifacts, not publicly hosted links.