You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
gc: incremental old-gen work costs 14% on a program that never collects (asyncpipe, zero GC cycles) #7909
asyncpipe runs zero GC cycles — no [gc] line is ever printed — and yet ~33% of its leaf profile is collector machinery the mutator pays anyway: write barriers, per-object layout side tables, and RuntimeHandleScope rooting.
PERRY_GC_MOVING_LOOP_POLLS=0 measures −14.1% on asyncpipewhile still running zero GC cycles. So the cost is not collection — it is an incremental old-gen mark/sweep being driven at back-edge polls that never completes a cycle and never reports one.
This is the largest single lever found in the asyncpipe round and it was deliberately left open: turning polls off is not a shippable fix (polls are default-ON since #7721 and removing them has its own history — see #7690, and the note in CLAUDE.md that a poll in a call-free clone deletes the element-shape clone). The question is why a program that never collects pays 14% for incremental collection.
Why this matters beyond one benchmark
It falsifies a premise this campaign has been using: "zero GC cycles" was treated as "GC levers are irrelevant here." That is wrong. A pure-mutator program still pays incremental-collector machinery, and on asyncpipe that is the top item.
PERRY_WRITE_BARRIERS=0 is +0.9% here, so codegen write barriers are genuinely not the cost — the incremental drive is.
Suggested direction
Establish why incremental old-gen work is being scheduled at all on a heap that never triggers a collection. If the answer is "the poll unconditionally does a slice of work", the fix is a cheap due-check before the slice, not disabling polls.
Any fix must assert its subject was live (per CLAUDE.md's four-ways-a-gate-cannot-fail): a gate here must show incremental slices actually ran on the baseline, or it proves nothing.
Evidence
gc-handoff/ASYNC2-NOTES.md — decomposition, counters, and reproduce commands. Measured with instructions retired (dev box was at load 12–165; see gc-handoff/MEASUREMENT-PROTOCOL.md).
Answer: the heap DOES trigger a collection — it just never performs one
Measured on main @ c109b089d, worktree wt-gc7909. Instrument added in #7923.
The premise "zero GC cycles ⇒ no collection is due" is where this went wrong. gc_budgeted_due_trigger() returns Some(ArenaBytes) from the young-generation
scavenge cap (young_scavenge_cap_due, 16 MB) and — because nothing ever
collects — it stays due for the rest of the run.
Every microtask drain runs gc_runtime_safepoint()
(promise/microtasks.rs), which starts a budgeted incremental cycle as soon
as any trigger is due.
The cycle it starts for nursery pressure is low_pause_non_moving by
construction, so it cannot evacuate and cannot lower copying_from_space_in_use_bytes() — the quantity the cap tests.
While it is active, gc_safepoint_moving_minor rejects every precise
safepoint at its budgeted entry guard, so the collector that could clear
the trigger never runs again.
At the pump's cadence (2048 work units per drain, ~17 drains in the whole
program) it cannot finish.
Self-sustaining, and completely silent: the [gc] trace is written by gc_finish_budgeted_cycle, so a cycle that never completes emits nothing.
mark_barrier_armed_us=37214 is the cost: the SATB mark barrier armed for 37 ms of a 127 ms program, taxing every heap store, every shadow-slot root
store and every allocation for a cycle that collects nothing. That is also why PERRY_WRITE_BARRIERS=0 was +0.9 % — that knob disables the generational
codegen store barrier; this is the incremental mark barrier, armed separately
via PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT.
The loop poll is not involved at all
poll_arm_events=0, loop_polls=1 — the back-edge poll is armed zero times
and taken once (the startup seed release) in the whole program. PERRY_GC_MOVING_LOOP_POLLS=0 acts here only through nursery_cap_active(),
which isgc_moving_loop_polls_enabled() (gc/policy.rs). PERRY_GC_SCAVENGE_NURSERY_MB=4096, which moves the cap and nothing else,
reproduces it to three digits (instructions retired, best of 7):
arm
instr (M)
Δ
base (cap 16 MB)
1993.1
—
PERRY_GC_SCAVENGE_NURSERY_MB=4096
1757.7
−11.81 %
PERRY_GC_MOVING_LOOP_POLLS=0
1757.6
−11.81 %
PERRY_GC_SCAVENGE=0
2001.8
+0.44 %
Cap sweep shows a clean cliff between 16 and 24 MB, then flat: 1997 → 1761 →
1760 → 1758 → 1758 M for 16 / 24 / 32 / 48 / 64 MB.
The obvious fix works and is a +51 % regression — blocked on #7915
Giving the precise collector first refusal at the outermost pump boundary (run gc_safepoint_moving_minor() before gc_runtime_safepoint()) removes the stall
exactly as designed — cycle_starts 1 → 0, mark_barrier_armed_us 37 214 → 0, copying_minors 0 → 2, all 19 corpus programs byte-identical — and costs:
arm
instr (M)
Δ instr
RSS (MB)
base
1927.1
—
48.9
reorder
2917.8
+51.4 %
76.7
base, no trigger due
1691.7
−12.2 %
45.4
reorder, no trigger due
1692.0
−12.2 %
45.4
The control rows are inert to three digits, so the +51 % is the price of the two
minors it unblocks, not of the change. One of those is a 134 ms minor that
copied zero objects, spending its time scanning 218 455 pointer roots across
82 registered runtime mutable root scanners — filed as a lead on #7915.
This issue cannot be closed before #7915. They are one chain: #7909 is the
mutator paying for a collection that is scheduled and then neither performed nor
cancelled; #7915 is that performing it costs more than not performing it. The
reorder is written up in gc-handoff/GC7909-NOTES.md §4 so it can be picked up
as-is once the collection is affordable.
#7923 ships the instrument only (no behaviour change): PERRY_GC_DIAG=1 — an
existing knob, no new one — now reports started/stepped/completed cycles and the
mark barrier's armed wall time, so this state is observable at all.
Re-measured on ac52a5c38 (0.5.1490): the filed cost is gone. The defect is not.
Per the instruction to re-establish cost before designing anything. Own build,
own worktree (wt-barrier), PERRY_NO_AUTO_OPTIMIZE=1, output byte-identical
to m0810/expected/asyncpipe.txt, exit 0.
1. The signature this issue was filed on now reads all-zero
PERRY_GC_DIAG=1, the [gc-incremental] instrument #7923 added:
counter
as filed (c109b089d)
today (ac52a5c38)
cycle_starts
1
0
steps
15
0
active_at_exit
true
false
mark_barrier_arms
1
0
mark_barrier_armed_us
42 487
0
safepoints_blocked_by_budgeted
5
0
copying_minors
0
1
host-safepoint no_trigger skips
2
9
The chain was: nursery scavenge cap goes due → the microtask pump starts a
budgeted low-pause non-moving cycle that cannot discharge nursery pressure →
that cycle blocks gc_safepoint_moving_minor at its budgeted entry guard for
the rest of the process → nothing collects → the cap stays due. #7933/#7939 took asyncpipe's young survival to 25‰, so the cap never goes due at all
(no_trigger=9), no cycle is started, and the moving minor runs.
2. The knob delta is 71% smaller in absolute terms
ab_instr.py, instructions retired, best of 7, spread ≤ 0.55%:
arm
instr (M)
Δ
rss (MB)
default
1180.8
—
39.6
PERRY_GC_MOVING_LOOP_POLLS=0
1113.6
−5.69%
40.9
PERRY_GC_SCAVENGE_NURSERY_MB=4096
1112.7
−5.77%
40.9
Filed numbers were 1993.1 → 1757.7 (−11.81%). The program itself is 41%
cheaper and the lever fell from 235.4 M to 68.1 M instructions.
3. What the residual 5.8% now is — a real collection
The two knobs still agree to three digits (nursery_cap_active()is gc_moving_loop_polls_enabled()), so it is still the cap. But what the cap now
buys is one copying minor that does genuine work:
[gc-copy-minor] ran survival_permille=25 copied_objects=6686 copied_bytes=446376
freed_bytes=17379064 trigger=ArenaBytes
pause_us=6619 phase_us.copying_nursery=3919
6.6 ms, 6 686 objects copied, 17.4 MB reclaimed, and RSS 1.3 MB lower
than the arms that skip it. Compare the state this issue was filed in: a 134 ms minor that copied zero objects and zero bytes. That is an
ordinary instructions-for-footprint pacing trade, not "incremental old-gen work
on a program that never collects".
4. ★ But the defect is UNREACHED, not fixed — and it is one knob away
GC7909-NOTES.md §3.5's asymmetry is still in the code. Lower the cap and the
exact signature returns on today's main:
PERRY_GC_SCAVENGE_NURSERY_MB
cycle_starts
steps
completions
active_at_exit
mark_barrier_armed_us
4
1
14
0
true
22 851
8
0
0
0
false
0
12
0
0
0
false
0
16 (default)
0
0
0
false
0
So "asyncpipe no longer pays for it" is a property of this week's allocation
profile, not of the collector. Anything that raises young survival again, or
any workload with a smaller effective nursery, walks straight back into a cycle
that is started, never completed, never cancelled, and reports nothing.
PERRY_GEN_GC=0 still arms the mark barrier for 58.7 ms on the same
program — the arm that reaches the subject on the non-generational
configuration.
Proposed disposition
Close this issue as measured-not-reproducing on its named subject (the
14% is now 5.8%, and what remains is a collection that reclaims 17 MB, i.e. #7915/pacing territory), and carry the latent asymmetry forward as its own item
— a budgeted low-pause non-moving cycle may claim a nursery-pressure trigger it
cannot discharge, and then locks out the collector that can — closed with a test, not a knob. PERRY_GC_SCAVENGE_NURSERY_MB=4 on apps/asyncpipe.ts is
a ready-made deterministic fixture, and [gc-incremental]'s cycle_starts > completions && active_at_exit is the assertion.
Note for anyone re-running this: PERRY_GC_DIAG=1 alone prints the [gc-incremental] line but no per-cycle [gc] lines — those need PERRY_GC_TRACE=1 as well. Both zeros above were positive-controlled.
Re-measured on current main: the filed cost is gone, the defect is not
Measured at ac52a5c38 (see gc-handoff/BARRIER-NOTES.md):
counter
when filed
now
cycle_starts
1
0
steps
15
0
active_at_exit
true
false
mark_barrier_armed_us
42,487
0
copying_minors
0
1
Cause of the change:#7933/#7939 took asyncpipe's young survival to ~25‰, so the scavenge cap never goes due (no_trigger=9) and the budgeted cycle is never started.
The knob delta fell −11.81% → −5.77% (235.4 M → 68.1 M instructions absolute), and what remains is now a real collection: 6.6 ms, 6,686 objects copied, 17.4 MB reclaimed, with RSS 1.3 MB lower than the arms that skip it. That is pacing (#7929/#7915), not "incremental work on a program that never collects".
Why I am NOT closing this
The asymmetry is unreached, not fixed.PERRY_GC_SCAVENGE_NURSERY_MB=4 reproduces the exact original signature on today's main:
So the livelock — a budgeted non-moving cycle that starts because the scavenge cap is due, cannot lower the quantity the cap tests, blocks the moving minor at its budgeted guard, and never completes — is still present in the collector. It is currently masked by an unrelated allocation-rate change on one benchmark. PERRY_GEN_GC=0 still arms the barrier for 58.7 ms.
Re-scoped: this issue is now "close the pacing asymmetry with a deterministic regression test", not "recover 14% on asyncpipe". The =4 reproducer is deterministic, so the test is cheap and does not need a knob to express.
That matters because the alternative — closing on today's numbers — would delete the knowledge and let the next allocation-rate change silently re-expose it, with no test to catch it. This repo has a documented history of exactly that: a mode that still exists is a decision that hasn't been made.
Closed with a test in #7972 — and the recorded blocker is refuted by measurement
Agent hdr, base a769fafc6 (0.5.1495). Write-up: gc-handoff/HDR-NOTES.md §1–2.
1. ★ The named reproducer is stale — checked before using it
Both this issue's last comment and my brief say PERRY_GC_SCAVENGE_NURSERY_MB=4 reproduces the filed signature on main. True at ac52a5c38 (0.5.1490); not true at a769fafc6. Full sweep on apps/asyncpipe.ts, output byte-exact and exit 0 in every cell:
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 (completions=0 active_at_exit=true) is not reachable on this
program at any cap any more. The defect half is fully intact one notch lower, at cap 2: a budgeted low-pause non-moving cycle is still started for a trigger it
cannot discharge, and still arms the SATB mark barrier for 87.9 ms of a ~127 ms
program while locking the moving minor out.
The value that reproduces moved twice in five patch versions. That is why the
regression test drives the policy path directly rather than pinning a knob value
to a benchmark.
Also worth recording: the whole 19-program corpus starts zero budgeted cycles at
default settings (cycle_starts=0, no_trigger=6). The defect is unreached at
defaults everywhere, not only on asyncpipe.
The cap becomes its own trigger variant, YoungScavengeCap. Every collection
site treats it identically to ArenaBytes; the split exists only so the budgeted
stepper can refuse to start a cycle for a trigger a low_pause_non_moving
cycle cannot discharge, deferring instead to the precise safepoint — arena
baseline included — exactly as gc_check_trigger's alloc-point arm already does. That asymmetry between the two paths was the bug. No new knob; PERRY_GC_DIAG=1 gains nursery_cap_deferred=N.
GC7909-NOTES.md §4.2 measured the equivalent unblocking at c109b089d as +51 % instructions / +57 % RSS and concluded this issue could not close before #7915. Re-measured at PERRY_GC_SCAVENGE_NURSERY_MB=2, ab_instr.py best of 5,
byte-exact output 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
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 barrier
plus a locked-out collector) now costs more than the state created (six more
copying minors). The blocker was a price, and the price moved.
4. Cost at defaults: zero, for a stated reason
19/19 byte-exact, exit 0 (also under PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800 PERRY_GC_VERIFY_EVACUATION=1); iso_miss
canary checksum 437840 misses 0. Every GC counter bit-identical across all 19
programs; RSS within 0.06 %; instructions −0.48 % … +0.63 %, inside the base arm's
own spread. nursery_cap_deferred=0 on all 19 — nothing for the new branch to
decline, which is exactly why the arms match.
cargo test --release -p perry-runtime: 2229 passed, 0 failed. Sabotage-proved:
with the branch disabled the new test fails on a cap-only host safepoint must report idle, not an active cycle, and the
sabotage log's Compiling perry-runtime v count is 1, so the red is about the
sabotaged code.
Activity
diag(gc): report incremental cycles that start and never finish (#7909)
proggeramlug commentedon Aug 12, 2026
Answer: the heap DOES trigger a collection — it just never performs one
Measured on
main@c109b089d, worktreewt-gc7909. Instrument added in #7923.The premise "zero GC cycles ⇒ no collection is due" is where this went wrong.
gc_budgeted_due_trigger()returnsSome(ArenaBytes)from the young-generationscavenge cap (
young_scavenge_cap_due, 16 MB) and — because nothing evercollects — it stays due for the rest of the run.
gc_runtime_safepoint()(
promise/microtasks.rs), which starts a budgeted incremental cycle as soonas any trigger is due.
low_pause_non_movingbyconstruction, so it cannot evacuate and cannot lower
copying_from_space_in_use_bytes()— the quantity the cap tests.gc_safepoint_moving_minorrejects every precisesafepoint at its
budgetedentry guard, so the collector that could clearthe trigger never runs again.
program) it cannot finish.
Self-sustaining, and completely silent: the
[gc]trace is written bygc_finish_budgeted_cycle, so a cycle that never completes emits nothing.mark_barrier_armed_us=37214is the cost: the SATB mark barrier armed for37 ms of a 127 ms program, taxing every heap store, every shadow-slot root
store and every allocation for a cycle that collects nothing. That is also why
PERRY_WRITE_BARRIERS=0was +0.9 % — that knob disables the generationalcodegen store barrier; this is the incremental mark barrier, armed separately
via
PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT.The loop poll is not involved at all
poll_arm_events=0,loop_polls=1— the back-edge poll is armed zero timesand taken once (the startup seed release) in the whole program.
PERRY_GC_MOVING_LOOP_POLLS=0acts here only throughnursery_cap_active(),which is
gc_moving_loop_polls_enabled()(gc/policy.rs).PERRY_GC_SCAVENGE_NURSERY_MB=4096, which moves the cap and nothing else,reproduces it to three digits (instructions retired, best of 7):
PERRY_GC_SCAVENGE_NURSERY_MB=4096PERRY_GC_MOVING_LOOP_POLLS=0PERRY_GC_SCAVENGE=0Cap sweep shows a clean cliff between 16 and 24 MB, then flat: 1997 → 1761 →
1760 → 1758 → 1758 M for 16 / 24 / 32 / 48 / 64 MB.
The obvious fix works and is a +51 % regression — blocked on #7915
Giving the precise collector first refusal at the outermost pump boundary (run
gc_safepoint_moving_minor()beforegc_runtime_safepoint()) removes the stallexactly as designed —
cycle_starts1 → 0,mark_barrier_armed_us37 214 → 0,copying_minors0 → 2, all 19 corpus programs byte-identical — and costs:The control rows are inert to three digits, so the +51 % is the price of the two
minors it unblocks, not of the change. One of those is a 134 ms minor that
copied zero objects, spending its time scanning 218 455 pointer roots across
82 registered runtime mutable root scanners — filed as a lead on #7915.
This issue cannot be closed before #7915. They are one chain: #7909 is the
mutator paying for a collection that is scheduled and then neither performed nor
cancelled; #7915 is that performing it costs more than not performing it. The
reorder is written up in
gc-handoff/GC7909-NOTES.md§4 so it can be picked upas-is once the collection is affordable.
#7923 ships the instrument only (no behaviour change):
PERRY_GC_DIAG=1— anexisting knob, no new one — now reports started/stepped/completed cycles and the
mark barrier's armed wall time, so this state is observable at all.
diag(gc): report incremental cycles that start and never finish (#7909)
diag(gc): report incremental cycles that start and never finish (#7909)…
proggeramlug commentedon Aug 12, 2026
Re-measured on
ac52a5c38(0.5.1490): the filed cost is gone. The defect is not.Per the instruction to re-establish cost before designing anything. Own build,
own worktree (
wt-barrier),PERRY_NO_AUTO_OPTIMIZE=1, output byte-identicalto
m0810/expected/asyncpipe.txt, exit 0.1. The signature this issue was filed on now reads all-zero
PERRY_GC_DIAG=1, the[gc-incremental]instrument #7923 added:c109b089d)ac52a5c38)cycle_startsstepsactive_at_exitmark_barrier_armsmark_barrier_armed_ussafepoints_blocked_by_budgetedcopying_minorsno_triggerskipsThe chain was: nursery scavenge cap goes due → the microtask pump starts a
budgeted low-pause non-moving cycle that cannot discharge nursery pressure →
that cycle blocks
gc_safepoint_moving_minorat itsbudgetedentry guard forthe rest of the process → nothing collects → the cap stays due. #7933/#7939 took
asyncpipe's young survival to 25‰, so the cap never goes due at all(
no_trigger=9), no cycle is started, and the moving minor runs.2. The knob delta is 71% smaller in absolute terms
ab_instr.py, instructions retired, best of 7, spread ≤ 0.55%:PERRY_GC_MOVING_LOOP_POLLS=0PERRY_GC_SCAVENGE_NURSERY_MB=4096Filed numbers were 1993.1 → 1757.7 (−11.81%). The program itself is 41%
cheaper and the lever fell from 235.4 M to 68.1 M instructions.
3. What the residual 5.8% now is — a real collection
The two knobs still agree to three digits (
nursery_cap_active()isgc_moving_loop_polls_enabled()), so it is still the cap. But what the cap nowbuys is one copying minor that does genuine work:
6.6 ms, 6 686 objects copied, 17.4 MB reclaimed, and RSS 1.3 MB lower
than the arms that skip it. Compare the state this issue was filed in: a
134 ms minor that copied zero objects and zero bytes. That is an
ordinary instructions-for-footprint pacing trade, not "incremental old-gen work
on a program that never collects".
4. ★ But the defect is UNREACHED, not fixed — and it is one knob away
GC7909-NOTES.md§3.5's asymmetry is still in the code. Lower the cap and theexact signature returns on today's
main:PERRY_GC_SCAVENGE_NURSERY_MBSo "asyncpipe no longer pays for it" is a property of this week's allocation
profile, not of the collector. Anything that raises young survival again, or
any workload with a smaller effective nursery, walks straight back into a cycle
that is started, never completed, never cancelled, and reports nothing.
PERRY_GEN_GC=0still arms the mark barrier for 58.7 ms on the sameprogram — the arm that reaches the subject on the non-generational
configuration.
Proposed disposition
Close this issue as measured-not-reproducing on its named subject (the
14% is now 5.8%, and what remains is a collection that reclaims 17 MB, i.e.
#7915/pacing territory), and carry the latent asymmetry forward as its own item
— a budgeted low-pause non-moving cycle may claim a nursery-pressure trigger it
cannot discharge, and then locks out the collector that can — closed with a
test, not a knob.
PERRY_GC_SCAVENGE_NURSERY_MB=4onapps/asyncpipe.tsisa ready-made deterministic fixture, and
[gc-incremental]'scycle_starts > completions && active_at_exitis the assertion.Note for anyone re-running this:
PERRY_GC_DIAG=1alone prints the[gc-incremental]line but no per-cycle[gc]lines — those needPERRY_GC_TRACE=1as well. Both zeros above were positive-controlled.proggeramlug commentedon Aug 12, 2026
Re-measured on current main: the filed cost is gone, the defect is not
Measured at
ac52a5c38(seegc-handoff/BARRIER-NOTES.md):cycle_startsstepsactive_at_exitmark_barrier_armed_uscopying_minorsCause of the change: #7933/#7939 took
asyncpipe's young survival to ~25‰, so the scavenge cap never goes due (no_trigger=9) and the budgeted cycle is never started.The knob delta fell −11.81% → −5.77% (235.4 M → 68.1 M instructions absolute), and what remains is now a real collection: 6.6 ms, 6,686 objects copied, 17.4 MB reclaimed, with RSS 1.3 MB lower than the arms that skip it. That is pacing (#7929/#7915), not "incremental work on a program that never collects".
Why I am NOT closing this
The asymmetry is unreached, not fixed.
PERRY_GC_SCAVENGE_NURSERY_MB=4reproduces the exact original signature on today's main:So the livelock — a budgeted non-moving cycle that starts because the scavenge cap is due, cannot lower the quantity the cap tests, blocks the moving minor at its
budgetedguard, and never completes — is still present in the collector. It is currently masked by an unrelated allocation-rate change on one benchmark.PERRY_GEN_GC=0still arms the barrier for 58.7 ms.Re-scoped: this issue is now "close the pacing asymmetry with a deterministic regression test", not "recover 14% on
asyncpipe". The=4reproducer is deterministic, so the test is cheap and does not need a knob to express.That matters because the alternative — closing on today's numbers — would delete the knowledge and let the next allocation-rate change silently re-expose it, with no test to catch it. This repo has a documented history of exactly that: a mode that still exists is a decision that hasn't been made.
docs(changelog): #7909 nursery-cap budgeted deferral fragment
proggeramlug commentedon Aug 12, 2026
Closed with a test in #7972 — and the recorded blocker is refuted by measurement
Agent
hdr, basea769fafc6(0.5.1495). Write-up:gc-handoff/HDR-NOTES.md§1–2.1. ★ The named reproducer is stale — checked before using it
Both this issue's last comment and my brief say
PERRY_GC_SCAVENGE_NURSERY_MB=4reproduces the filed signature onmain. True atac52a5c38(0.5.1490); not true ata769fafc6. Full sweep onapps/asyncpipe.ts, output byte-exact and exit 0 in every cell:The stall half (
completions=0 active_at_exit=true) is not reachable on thisprogram at any cap any more. The defect half is fully intact one notch lower, at
cap 2: a budgeted low-pause non-moving cycle is still started for a trigger it
cannot discharge, and still arms the SATB mark barrier for 87.9 ms of a ~127 ms
program while locking the moving minor out.
The value that reproduces moved twice in five patch versions. That is why the
regression test drives the policy path directly rather than pinning a knob value
to a benchmark.
Also worth recording: the whole 19-program corpus starts zero budgeted cycles at
default settings (
cycle_starts=0,no_trigger=6). The defect is unreached atdefaults everywhere, not only on
asyncpipe.2. The fix (#7972)
The cap becomes its own trigger variant,
YoungScavengeCap. Every collectionsite treats it identically to
ArenaBytes; the split exists only so the budgetedstepper can refuse to start a cycle for a trigger a
low_pause_non_movingcycle cannot discharge, deferring instead to the precise safepoint — arena
baseline included — exactly as
gc_check_trigger's alloc-point arm already does.That asymmetry between the two paths was the bug. No new knob;
PERRY_GC_DIAG=1gainsnursery_cap_deferred=N.3. ★ "+51 %, blocked on #7915" no longer holds
GC7909-NOTES.md§4.2 measured the equivalent unblocking atc109b089das+51 % instructions / +57 % RSS and concluded this issue could not close before
#7915. Re-measured at
PERRY_GC_SCAVENGE_NURSERY_MB=2,ab_instr.pybest of 5,byte-exact output and exit 0 on both arms:
cycle_startsmark_barrier_armed_usnursery_cap_deferredcopying_minors−16 % instructions and −13.5 MB RSS. The state removed (88 ms of armed barrier
plus a locked-out collector) now costs more than the state created (six more
copying minors). The blocker was a price, and the price moved.
4. Cost at defaults: zero, for a stated reason
19/19 byte-exact, exit 0 (also under
PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800 PERRY_GC_VERIFY_EVACUATION=1);iso_misscanary
checksum 437840 misses 0. Every GC counter bit-identical across all 19programs; RSS within 0.06 %; instructions −0.48 % … +0.63 %, inside the base arm's
own spread.
nursery_cap_deferred=0on all 19 — nothing for the new branch todecline, which is exactly why the arms match.
cargo test --release -p perry-runtime: 2229 passed, 0 failed. Sabotage-proved:with the branch disabled the new test fails on
a cap-only host safepoint must report idle, not an active cycle, and thesabotage log's
Compiling perry-runtime vcount is 1, so the red is about thesabotaged code.
fix(gc): do not start a budgeted cycle for a nursery-cap trigger it c…