Skip to content

fix(gc): do not start a budgeted cycle for a nursery-cap trigger it cannot discharge (#7909) - #7972

Merged
proggeramlug merged 2 commits into
mainfrom
gc/7909-nursery-cap-deferral
Aug 12, 2026
Merged

fix(gc): do not start a budgeted cycle for a nursery-cap trigger it cannot discharge (#7909)#7972
proggeramlug merged 2 commits into
mainfrom
gc/7909-nursery-cap-deferral

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes #7909.

The defect

gc_budgeted_due_trigger() reported the young-generation scavenge cap as
BudgetedGcTrigger::ArenaBytes, so a host safepoint (and a mutator assist)
started a budgeted cycle for it. A budgeted cycle is low_pause_non_moving
by construction: it sweeps in place and cannot lower
copying_from_space_in_use_bytes()
, which is the exact quantity
young_scavenge_cap_due() tests. Meanwhile gc_safepoint_moving_minor rejects
every precise safepoint at its budgeted entry guard for the cycle's whole
life.

The two compose into a stall that sustains itself and reports nothing:

cap due -> budgeted cycle started -> moving minor locked out
        -> nothing reclaims -> cap still due

The SATB mark barrier stays armed for the duration (every heap store, every
shadow-slot root store, allocate-black on every birth) and the [gc] trace
stays empty, because the trace is written by the completion path.

The fix

Split the cap out as BudgetedGcTrigger::YoungScavengeCap. Every collection
site treats it exactly as ArenaBytes — the split exists only so the budgeted
stepper can tell them apart at the moment it decides whether to start a
cycle. When the only due trigger is the cap and the cycle would be budgeted, no
cycle is started; the pressure is deferred to the precise safepoint (arena
baseline included, so the moving_defer_within_slack valve is not left reading
a stale, already-exceeded baseline — the #7024 shape) exactly as
gc_check_trigger's alloc-point arm has always done. That asymmetry between
the two paths was the bug.

No new knob. PERRY_GC_DIAG=1's [gc-incremental] line gains
nursery_cap_deferred=N, so the refusal is distinguishable from "nothing was
due".

Unreachable by construction in the configuration where it would be wrong:
young_scavenge_cap_due() is false unless nursery_cap_active(), which is
gc_moving_loop_polls_enabled() — so the cap can only be the due trigger in
exactly the configuration where the precise route exists.

The test, and why it is a unit test

a_nursery_cap_only_trigger_is_deferred_to_the_collector_that_can_discharge_it
drives the real host-safepoint path with a per-thread cap override (the shape
support.rs mandates — never a process-wide environment write, #7946) and pairs
the refusal with a control phase on the same fixture: same thread, same
heap, only the due trigger differs, and the control must still start a cycle. So
the pair discriminates "declines this trigger" from "declines everything", which
a bare cycle_starts == 0 cannot.

★ The issue's named end-to-end reproducer is stale and this is why the test
does not use one. PERRY_GC_SCAVENGE_NURSERY_MB=4 on apps/asyncpipe.ts
reproduced the filed signature at ac52a5c38 (0.5.1490). Swept across the whole
dial on a769fafc6, it does not:

cap MB cycle_starts steps completions active_at_exit barrier armed us copying minors
1 0 0 0 false 0 9
2 1 20 1 false 87 939 3
3 0 0 0 false 0 6
4 0 0 0 false 0 4
8 0 0 0 false 0 2
16 (default) 0 0 0 false 0 1
24 / 32 0 0 0 false 0 0

The stall half is no longer reachable on that program at any cap; the
defect half is fully intact one notch lower, at cap 2 — a budgeted non-moving
cycle started for a trigger it cannot discharge, arming the mark barrier for
87.9 ms of a ~127 ms program. A fixture pinned to one knob value on one
benchmark moved twice in five patch versions, so it is not a durable gate.

Validation

Own build, own worktree (wt-hdr), own CARGO_TARGET_DIR, base a769fafc6
(0.5.1495). Both arms built with the identical -p set and
PERRY_RUNTIME_DIR pinned; the two libperry_runtime.a archives differ, so the
A/B is not vacuous.

Correctness. 19/19 corpus programs byte-exact vs m0810/expected/
(node 26.5.1), exit 0 — and again under
PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800 PERRY_GC_VERIFY_EVACUATION=1. iso_miss canary: checksum 437840 misses 0.
cargo test --release -p perry-runtime (RUST_TEST_THREADS=1):
2229 passed, 0 failed, 4 ignored.

Sabotage-proved. With the new branch disabled (if false && …, the fix
committed first), the test fails on the intended assertion —
a cap-only host safepoint must report idle, not an active cycle — and
grep -c "Compiling perry-runtime v" on the sabotage log reads 1, so the red
verdict is about the sabotaged code and not a stale unit. (The pre-existing
an_active_budgeted_cycle_locks_out_… also goes red under the sabotage: the new
test panics before completing the cycle it opened, and these run single-threaded
in one process. It is green in both non-sabotaged runs.)

Cost on the shipped corpus: zero, for a stated reason. Every GC counter is
bit-identical across all 19 programs — same minors, same fulls, same
copied_objects, same promoted_objects — and RSS matches to 0.06 %.
Instructions span −0.48 % … +0.63 %, inside the base arm's own best-of-3 spread.
nursery_cap_deferred=0 on all 19: at default settings no budgeted cycle is
started anywhere in the corpus
(cycle_starts=0, no_trigger=6), so there is
nothing for the new branch to decline.

In the regime where it IS live it is a large win — which refutes the recorded
blocker.
GC7909-NOTES.md §4.2 measured the equivalent unblocking at
c109b089d as +51 % instructions / +57 % RSS and concluded "#7909 cannot be
closed before #7915". Re-measured on a769fafc6 at
PERRY_GC_SCAVENGE_NURSERY_MB=2 (the value that still reaches the defect),
ab_instr.py best of 5, output byte-exact and exit 0 on both arms:

arm instr (M) cycles (M) RSS (MB) Δ instr
base 1573.8 369.5 37.7
fix 1317.0 337.6 24.2 −16.31 %
counter base fix
cycle_starts 1 0
steps 20 0
mark_barrier_armed_us 87 939 0
nursery_cap_deferred 2
copying_minors 3 9

−16 % instructions and −13.5 MB RSS. The state removed (88 ms of armed SATB
barrier plus a locked-out collector) is now more expensive than the state created
(six more copying minors). The blocker was a price, and the price has moved.

Full write-up and the harnesses: gc-handoff/HDR-NOTES.md.

Summary by CodeRabbit

  • Bug Fixes

    • Improved garbage collection behavior when nursery-cap pressure cannot be resolved during a budgeted cycle.
    • Defers collection to precise safepoints instead of starting ineffective cycles.
    • Preserves availability of moving minor collections for later use.
    • Ensures budgeted collection still starts when reclaimable arena pressure is present.
  • Diagnostics

    • Added nursery_cap_deferred reporting to incremental garbage collection diagnostics.

…annot discharge (#7909)

`gc_budgeted_due_trigger()` reported the young-generation scavenge cap as
`ArenaBytes`, so a host safepoint started a budgeted (low-pause NON-MOVING)
cycle for it. Such a cycle sweeps in place and cannot lower
`copying_from_space_in_use_bytes()` — the exact quantity the cap tests — while
`gc_safepoint_moving_minor` rejects every precise safepoint at its `budgeted`
entry guard for the cycle's whole life. When the host's step cadence cannot
finish the cycle the composition is permanent and silent: cap due -> cycle
started -> moving minor locked out -> nothing reclaims -> cap still due, with
the SATB mark barrier armed for the rest of the process and an empty `[gc]`
trace because the trace is written by the completion path.

Split the cap out as `BudgetedGcTrigger::YoungScavengeCap` (every collection
site treats it identically to `ArenaBytes`) and decline to START a budgeted
cycle for it, deferring to the precise safepoint exactly as the alloc-point arm
already does. Counted as `nursery_cap_deferred` in the `PERRY_GC_DIAG=1`
`[gc-incremental]` line.

The regression test drives the real host-safepoint path with a per-thread cap
override and pairs the refusal with a control phase on the same fixture: only
the due trigger differs, so the pair discriminates "declines this trigger" from
"declines everything".
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: dc4c41ef-78fb-4c63-bf3c-ea36bc36b283

📥 Commits

Reviewing files that changed from the base of the PR and between e316ecb and 8d2b078.

📒 Files selected for processing (5)
  • changelog.d/7972-gc-nursery-cap-budgeted-deferral.md
  • crates/perry-runtime/src/gc/instruments.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/tests/host_safepoints.rs

📝 Walkthrough

Walkthrough

Changes

Nursery-cap GC handling

Layer / File(s) Summary
Nursery-cap trigger and deferral flow
crates/perry-runtime/src/gc/policy.rs
The policy adds a distinct YoungScavengeCap trigger, supports a thread-local cap override, routes dischargeable pressure to minor collection, and defers undischargeable budgeted steps to precise safepoints.
Deferral instrumentation and diagnostics
crates/perry-runtime/src/gc/instruments.rs, crates/perry-runtime/src/gc/mod.rs
Instrumentation records nursery-cap deferrals. GC diagnostics report the nursery_cap_deferred counter.
Host-safepoint regression coverage
crates/perry-runtime/src/gc/tests/host_safepoints.rs, changelog.d/7972-gc-nursery-cap-budgeted-deferral.md
Tests cover cap-only deferral and dischargeable arena-pressure cycles. The changelog documents the behavior.Estimated code review effort: 4 (Complex)

Sequence Diagram(s)

sequenceDiagram
  participant HostSafepoint
  participant GcPolicy
  participant Instrumentation
  participant PreciseSafepoint
  HostSafepoint->>GcPolicy: Check nursery-cap pressure
  GcPolicy->>Instrumentation: Record nursery-cap deferral
  GcPolicy->>PreciseSafepoint: Arm precise collection
  PreciseSafepoint->>GcPolicy: Run evacuating minor collection
Loading

Possibly related PRs

  • PerryTS/perry#7020: Both changes add test-only thread-local GC behavior overrides and RAII guards in gc/policy.rs.

Suggested reviewers: jdalton, thehypnoo

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR addresses nursery-cap budgeted-cycle deferral, but [#7909] primarily requires gating incremental old-generation work at back-edge polls. Link this PR to an issue covering the nursery-cap defect, or add the due-check and live-slice regression test required by [#7909].
Out of Scope Changes check ⚠️ Warning The nursery-cap trigger split, deferral counter, and host-safepoint test are outside [#7909]'s stated incremental old-generation polling objective. Move these changes to a dedicated nursery-cap issue, or expand [#7909] to explicitly cover this defect.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the primary GC fix: budgeted cycles no longer start for an undischargeable nursery-cap trigger.
Description check ✅ Passed The description provides detailed defect, fix, test, related issue, and validation information, despite not using the exact template headings or checklist.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gc/7909-nursery-cap-deferral

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gc: incremental old-gen work costs 14% on a program that never collects (asyncpipe, zero GC cycles)

1 participant