gc: adaptive tenuring + young-scoped scavenge cap (fixes the large-live-set scavenge regression) - #7432
Conversation
The copying minor promoted nursery survivors at a fixed age of 4, so a program whose survivors never die in the survivor space re-copied the same cohort on every collection (tree.ts: 4.39 GB / 61 M object-copies across 1427 cycles for a ~35 MB live set). Re-derive the threshold each cycle from the Eden survivor influx (HotSpot's adaptive TenuringThreshold restated for elastic semispaces): S = min(4, 1 + desired/influx) with desired = nursery cap / 16. Instant drop, debounced one-step rise, plus a per-cycle to-survivor overflow valve at 4x desired.
Every existing probe holds a small live set, which is why the survivor-saturation regression shipped unseen. This probe holds a ~100 MB linked structure across many nursery collections with transient churn, then releases it and pins the retained heap.
…on first copy The occupancy rule optimises survivor space, not copy work: tree.ts's influx sits 40 bytes under the desired size, settling at S=2 and still copying every surviving byte once for nothing (100% of each cohort survives its round). When last cycle's survivor intake was substantial and >=90% of it comes back out alive, lock promote-on-first-copy until the influx goes quiet; the exit signal (influx < desired/4, debounced) stays measurable at S=1, unlike occupancy.
A fixed scavenge cap sets collection frequency independently of survivorship: with the re-copying eliminated, tree.ts still paid 1427 collections' fixed root-scan/remembered-set cost and promoted objects a larger Eden would have let die young. Grow the effective cap one ×2 step (to at most 64 MB) while survivor influx exceeds 4% of it, shrink below 1%; the tenuring dials (desired survivor size, overflow valve) track the effective cap. Small-live-set workloads never leave 16 MB.
The cap compared arena_total_bytes() — all generations — against 16 MB, so once old-gen in-use crossed the cap every fresh 1 MB Eden block re-crossed the trigger and the scavenge cadence degenerated to once-per-block. That cadence is the actual mechanism behind the survivor-saturation regression: objects were scavenged ~1 MB of allocation after birth (measured 1.05 MB survivors per block — near-zero infant mortality), so the survivor space pinned at saturation and tree.ts ran 1427 collections for ~1.4 GB of allocation. The adaptive base trigger keeps its historical total-arena basis; the cap arm now fires on Eden + active-survivor occupancy.
📝 WalkthroughWalkthroughThe copying nursery gains adaptive tenuring and young-generation scavenge-cap scaling. GC telemetry reports policy and live-byte metrics. Tests cover threshold changes and recovery. The GC ratchet suite adds a large live-set probe and documents twelve workloads. ChangesAdaptive copying nursery
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant GCPolicy
participant CopyingNurseryCollector
participant TenuringPolicy
participant CopyingNurseryTraceStats
GCPolicy->>CopyingNurseryCollector: trigger nursery scavenge
CopyingNurseryCollector->>TenuringPolicy: snapshot tenuring_survivals()
CopyingNurseryCollector->>CopyingNurseryTraceStats: record threshold and live-byte metrics
CopyingNurseryCollector->>TenuringPolicy: retune_after_scavenge(eden_live_bytes, copied_bytes, survivor_live_bytes)
TenuringPolicy-->>GCPolicy: effective nursery cap and tenuring threshold
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…terminism The valve stopped copying once a cycle had 4x-desired bytes in to-space, which made the copied/promoted split depend on root traversal order (address-dependent): the gc-ratchet's bit-identical-counters contract caught it as a +/-2-object jitter on 12_large_live_set's first heavy cycle. The adaptive threshold + survival-rate lock make it redundant after cycle 1; its only benefit was bounding one cycle's copy burst (~15 MB, ~10 ms). The promotion decision is now purely per-object.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@benchmarks/gc_ratchet/probes/12_large_live_set.ts`:
- Around line 77-82: Update the stdout contract documentation for probe 12,
including benchmarks/gc_ratchet/README.md and any related probe contract
comments, to list the deterministic walked, sum, kept, and keptSum lines emitted
by 12_large_live_set.ts alongside probe and checksum; remove outdated
two-line-only statements while preserving the existing output.
- Around line 35-41: Update the transient allocation loop around t1, t2, and t3
to retain each batch in a bounded heap container, preventing scalar replacement
while keeping container growth bounded. Clear or otherwise release the container
after the batch is no longer needed, and preserve the existing checksum
calculation and live-set growth behavior.
In `@changelog.d/7432-adaptive-tenuring-young-cap.md`:
- Line 1: Reconcile the peak-RSS claim in the changelog entry with the
measurements cited later: update the line 1 multiplier to match 221 MB versus
103 MB (approximately 2.15×, or 114.6% higher), or replace it with the exact
measured values. Ensure all RSS figures and percentages remain consistent before
publishing.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: df034e46-12fb-4a73-988c-0a7727722fb2
📒 Files selected for processing (11)
benchmarks/gc_ratchet/README.mdbenchmarks/gc_ratchet/probes/12_large_live_set.tschangelog.d/7432-adaptive-tenuring-young-cap.mdcrates/perry-runtime/src/gc/copying.rscrates/perry-runtime/src/gc/mod.rscrates/perry-runtime/src/gc/policy.rscrates/perry-runtime/src/gc/telemetry.rscrates/perry-runtime/src/gc/tenuring.rscrates/perry-runtime/src/gc/tests/copying.rscrates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rscrates/perry-runtime/src/gc/tests/support.rs
| // Transient garbage between live allocations so nursery collections keep | ||
| // firing while the live set grows — the survivor influx stays heavy for | ||
| // the whole build phase instead of arriving in one burst. | ||
| const t1 = { x: i, y: i * 2 }; | ||
| const t2 = { x: i + 1, y: i * 3 }; | ||
| const t3 = { x: i + 2, y: i * 5 }; | ||
| checksum = (checksum + t1.x + t2.y + t3.y) | 0; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== files =="
git ls-files | rg '(^benchmarks/gc_ratchet/probes/12_large_live_set\.ts$|benchmarks/gc_ratchet/README\.md$|^\.node-version$)'
echo "== node version =="
if [ -f .node-version ]; then cat .node-version; fi
echo "== probe file outline/contents =="
wc -l benchmarks/gc_ratchet/probes/12_large_live_set.ts benchmarks/gc_ratchet/README.md 2>/dev/null || true
cat -n benchmarks/gc_ratchet/probes/12_large_live_set.ts
echo "== readme relevant lines =="
sed -n '35,55p' benchmarks/gc_ratchet/README.md | cat -n
echo "== related gc_ratchet files =="
git ls-files benchmarks/gc_ratchet | sed -n '1,120p'Repository: PerryTS/perry
Length of output: 5953
Root transient allocations in a bounded heap container.
t1, t2, and t3 are local objects reduced only to scalar fields, so they can be optimized away instead of creating transient nursery garbage. This matches the probe README warning: allocations parked in locals can be scalar-replaced and make the benchmark report no collections.
Store each batch in a bounded heap container and clear it after the batch becomes dead.
Proposed fix
const all: LNode[] = [];
+const transient: Array<{ x: number; y: number }> = [];
let head: LNode | null = null;
...
const t3 = { x: i + 2, y: i * 5 };
+ transient.push(t1, t2, t3);
checksum = (checksum + t1.x + t2.y + t3.y) | 0;
+ if (transient.length >= 3 * 256) {
+ transient.length = 0;
+ }
}
+transient.length = 0;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Transient garbage between live allocations so nursery collections keep | |
| // firing while the live set grows — the survivor influx stays heavy for | |
| // the whole build phase instead of arriving in one burst. | |
| const t1 = { x: i, y: i * 2 }; | |
| const t2 = { x: i + 1, y: i * 3 }; | |
| const t3 = { x: i + 2, y: i * 5 }; | |
| checksum = (checksum + t1.x + t2.y + t3.y) | 0; | |
| // Transient garbage between live allocations so nursery collections keep | |
| // firing while the live set grows — the survivor influx stays heavy for the | |
| // whole build phase instead of arriving in one burst. | |
| const t1 = { x: i, y: i * 2 }; | |
| const t2 = { x: i + 1, y: i * 3 }; | |
| const t3 = { x: i + 2, y: i * 5 }; | |
| transient.push(t1, t2, t3); | |
| checksum = (checksum + t1.x + t2.y + t3.y) | 0; | |
| if (transient.length >= 3 * 256) { | |
| transient.length = 0; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@benchmarks/gc_ratchet/probes/12_large_live_set.ts` around lines 35 - 41,
Update the transient allocation loop around t1, t2, and t3 to retain each batch
in a bounded heap container, preventing scalar replacement while keeping
container growth bounded. Clear or otherwise release the container after the
batch is no longer needed, and preserve the existing checksum calculation and
live-set growth behavior.
| console.log("probe:12_large_live_set"); | ||
| console.log("checksum:" + checksum); | ||
| console.log("walked:" + walked); | ||
| console.log("sum:" + sum); | ||
| console.log("kept:" + kept.length); | ||
| console.log("keptSum:" + keptSum); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== candidate files =="
git ls-files | rg '(^|/)(12_large_live_set\.ts|README\.md)$' | sed -n '1,120p'
echo
echo "== target file excerpt =="
sed -n '1,120p' benchmarks/gc_ratchet/probes/12_large_live_set.ts 2>/dev/null || true
echo
echo "== gc_ratchet readme around stdout docs =="
sed -n '1,120p' benchmarks/gc_ratchet/README.md 2>/dev/null || true
echo
echo "== console.log consumers in benchmarks/gc_ratchet =="
rg -n 'console\.log|compare|readFileSync|stdout|checksum|probe:|walked|sum|kept|keptSum' benchmarks/gc_ratchet -S || trueRepository: PerryTS/perry
Length of output: 28376
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== git status/stat =="
git diff --stat || true
git status --short || true
echo
echo "== node version =="
if [ -f .node-version ]; then cat .node-version; else echo "no .node-version"; fi
node --version 2>/dev/null || true
echo
echo "== parser/read-only behavioral probe for exported deterministic constants and console logs in target =="
python3 - <<'PY'
from pathlib import Path
p = Path('benchmarks/gc_ratchet/probes/12_large_live_set.ts')
text = p.read_text()
print("target exists:", p.exists())
print("console.log count:", text.count("console.log"))
for i, line in enumerate(text.splitlines(), 1):
if "console.log" in line:
print(f"{i}: {line}")
pyRepository: PerryTS/perry
Length of output: 784
🌐 Web query:
PerryTS perry benchmarks/gc_ratchet gc_ratchet.py stdout diff checksum stdout lines
💡 Result:
In the PerryTS/perry project, benchmarks/gc_ratchet/gc_ratchet.py is a specialized test harness used to enforce a GC (Garbage Collection) memory regression "ratchet" [1]. The gc_ratchet.py script manages a series of benchmarks that monitor Perry's memory usage and GC behavior by comparing performance against a pinned baseline [1][2]. Its core functionality includes: Measure and Check: The tool operates in two main modes: measure (to record current metrics, often against a Node.js oracle) and check (to compare those results against the benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json artifact) [1][3]. Stdout Diff and Checksum: During a measurement, the tool executes benchmarks that produce output streams. The stdout stream must match the expected output (the "Node oracle") on a byte-for-byte basis [1]. The tool explicitly looks for probe: and checksum: lines in the stdout to verify that the workload is performing correctly [1]. Verification Gate: The ratchet enforces correctness; if the benchmark's stdout fails to match the pinned baseline or the expected oracle, or if a probe silently stops allocating, the check process will fail the CI gate [1][2]. This prevents regressions where memory usage appears artificially "improved" simply because the benchmark stopped running correctly [1][2]. The system relies on tolerances.json to define acceptable variance bands for metrics, as wall-time and RSS can drift across different host machines, while evacuation counters and retention metrics are intended to be stable across environments [1][2][3].
Citations:
- 1: https://cephalochromoscope.net/174e009e-8868-4a88-bc99-98feea1717bc
- 2: https://cephalochromoscope.net/44406bf0-062d-4fd4-a51f-3c6957a27f40
- 3: https://cephalochromoscope.net/2aa754c4-69e8-4758-9c27-2674ac12055e
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== precise stdout handling in gc_ratchet.py =="
sed -n '48,54p' benchmarks/gc_ratchet/gc_ratchet.py
sed -n '395,425p' benchmarks/gc_ratchet/gc_ratchet.py
sed -n '750,768p' benchmarks/gc_ratchet/gc_ratchet.py
echo
echo "== probe doc mentions of stdout metric contract =="
rg -n 'stdout|probe:/`checksum:/`probe:' benchmarks/gc_ratchet/probes/*.ts benchmarks/gc_ratchet/README.md -S || trueRepository: PerryTS/perry
Length of output: 3043
Keep the probe stdout contract in sync.
12_large_live_set.ts now emits deterministic walked, sum, kept, and keptSum stdout lines. Update benchmarks/gc_ratchet/README.md and any probe contract comments that say stdout contains only probe: and checksum: lines, or remove these lines to keep the documented two-line contract.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@benchmarks/gc_ratchet/probes/12_large_live_set.ts` around lines 77 - 82,
Update the stdout contract documentation for probe 12, including
benchmarks/gc_ratchet/README.md and any related probe contract comments, to list
the deterministic walked, sum, kept, and keptSum lines emitted by
12_large_live_set.ts alongside probe and checksum; remove outdated two-line-only
statements while preserving the existing output.
Under the corrected young-scoped cadence these probes never allocate 16 MB between their explicit gc()s, so no automatic minor ever fires — and the pin validator refuses a probe that runs no minor collection (they previously got minors only from the degenerate once-per-block cadence, and were added to probes/ without re-pinning the baseline, which is why gc-ratchet has been red on main). Core phases unchanged; verified stdout-identical to the pinned Node oracle and bit-identical counters across runs.
Fixes the large-live-set scavenge regression from #7377 (see
gc-handoff/PROMPT-scavenge-large-live-set.md):tree.tswas Pareto-worse thanPERRY_GC_SCAVENGE=0— 4.6× slower and 1.65× more RSS — because the survivor semispace saturated and the same 3.15 MB / 43,689-object cohort was re-copied on every one of 1427 collections (4.39 GB / 61 M object-copies).Root causes found (two, stacked)
1. The scavenge cap fired on
arena_total_bytes()— all generations. Once old-gen in-use crossed 16 MB, every fresh 1 MB Eden block re-crossed the trigger, degenerating the cadence to once-per-block. Objects were scavenged ~1 MB of allocation after birth, so almost nothing had time to die: measured 1.05 MB of survivors per 1 MB block (near-zero infant mortality) — which is what saturated the survivor space in the first place. Capacity never shrinks below the cap once mapped, so after warmup this hit every workload:churn.tswas also running 1672 collections.2. A fixed tenuring age (4) re-copies cohorts that aging cannot filter. With influx heavy and survivor-space death ~zero, every surviving byte was copied 3× and then promoted anyway.
Fixes
gc/policy.rs: young-generation-scoped cap. The adaptive base trigger keeps its historical total-arena basis; the cap arm now fires on Eden + active-survivor occupancy.gc/tenuring.rs(new): adaptive tenuring threshold — HotSpot'sTenuringThresholdrestated for elastic semispaces.S = min(4, 1 + desired/influx)recomputed after every copying minor from the Eden survivor influx (threshold-invariant signal ⇒ stable fixed point), desired = effective cap/16, instant drop / debounced one-step rise. The promotion decision is purely per-object (flags + age): an earlier draft's mid-cycle overflow valve made the copied/promoted counter split depend on root traversal order, which the gc-ratchet's bit-identical-counters contract caught as a ±2-object jitter — deterministic counters won.No env knob added (GC knob kill-policy): the loop's neutral state — influx below desired, cohorts that die in the survivor space — is bit-for-bit the previous fixed behaviour.
PERRY_GC_SCAVENGE_NURSERY_MBstill tunes the base. The promotion-handoff pacing estimate mirrors the dynamic predicate; the per-cycle trace gainstenuring_survivals/eden_live_bytes/survivor_live_bytes.Measurements
Same binary, arms back-to-back, macOS arm64. GC traces (load-independent):
Wall/peak-RSS, best-of-3, captured on the quiet host (cpu_active ≤18%, same window as the baseline re-pin):
Acceptance scorecard vs the handoff:
5–6. New
12_large_live_set.tsratchet probe (~100 MB live across many collections — the shape every existing probe missed); baseline re-pinned deliberately on the quiet host with the accepted counter shifts, and probes 09–11 pinned for the first time (they were added without a re-pin, which is why gc-ratchet has been red on main — this PR turns that job green again).perry-runtime --librun are the pre-existing interference flakes that also fail 3-5/run on pristine main (pass serialized).