Skip to content

Commit f25829f

Browse files
author
Ralph Küpper
committed
diag(gc): report incremental cycles that start and never finish (#7909)
A budgeted incremental cycle emits nothing until it COMPLETES — the `[gc]` trace is written by `gc_finish_budgeted_cycle`. A cycle that is started and then starved is therefore completely invisible: no trace line, no counter, nothing, while the mutator pays the SATB mark barrier on every heap store, every shadow-slot root store and every allocation for as long as it stays open. `gc-handoff/apps/asyncpipe.ts` is in exactly that state, which is why it reads as "zero GC cycles, but a third of the leaf profile is collector machinery". `PERRY_GC_DIAG=1` (existing knob, no new knob) now prints at the process-exit boundary: [gc-incremental] cycle_starts=1 steps=15 completions=0 active_at_exit=true mark_barrier_arms=1 mark_barrier_armed_us=37214 skips(reentrant=0 no_trigger=2 start_blocked=0 resume_blocked=0) safepoints_blocked_by_budgeted=0 copying_minors=0 loop_polls=1 poll_arm_events=0 poll_armed_at_exit=0 One cycle started, fifteen steps, zero completions, still active at exit, the mark barrier armed for 37 ms of a 127 ms program, and not one collection. No behaviour change. The mechanism, and the measured negative on the obvious fix (+51.4 % instructions, +57 % RSS, because the minors it unblocks are priced by #7915), are written up in `gc-handoff/GC7909-NOTES.md`. Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2
1 parent 31e0dee commit f25829f

7 files changed

Lines changed: 423 additions & 1 deletion

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
### `gc`: report incremental cycles that start and never finish (#7909)
2+
3+
A budgeted incremental cycle emits nothing until it *completes* — the `[gc]`
4+
trace is written by `gc_finish_budgeted_cycle`. So a cycle that is started and
5+
then starved is completely invisible: no trace line, no counter, no diagnostic,
6+
while the mutator pays the SATB mark barrier on every heap store, every
7+
shadow-slot root store and every allocation (allocate-black) for as long as the
8+
cycle stays open.
9+
10+
`gc-handoff/apps/asyncpipe.ts` is in exactly that state on `main`, and it is
11+
why the program reads as "zero GC cycles, but a third of the leaf profile is
12+
collector machinery". `PERRY_GC_DIAG=1` now prints, at the process-exit
13+
boundary:
14+
15+
```
16+
[gc-incremental] cycle_starts=1 steps=15 completions=0 active_at_exit=true
17+
mark_barrier_arms=1 mark_barrier_armed_us=37214
18+
skips(reentrant=0 no_trigger=2 start_blocked=0 resume_blocked=0)
19+
safepoints_blocked_by_budgeted=0 copying_minors=0
20+
loop_polls=1 poll_arm_events=0 poll_armed_at_exit=0
21+
```
22+
23+
One cycle started, fifteen steps, zero completions, still active at exit, the
24+
mark barrier armed for **37 ms of a 127 ms program**, and not one collection
25+
performed. No new env knob — this is the existing `PERRY_GC_DIAG`.
26+
27+
#### The mechanism the numbers describe
28+
29+
* `nursery_cap_active()` **is** `gc_moving_loop_polls_enabled()`, so the
30+
young-generation scavenge cap (16 MB, `PERRY_GC_SCAVENGE_NURSERY_MB`) goes due
31+
and — because nothing collects — stays due.
32+
* every microtask drain runs `gc_runtime_safepoint()`, which starts a budgeted
33+
cycle as soon as *any* trigger is due, including that one.
34+
* the cycle it starts is `low_pause_non_moving` by construction, so it cannot
35+
evacuate and cannot lower `copying_from_space_in_use_bytes()` — the quantity
36+
the cap tests.
37+
* while it is active, `gc_safepoint_moving_minor` rejects every precise
38+
safepoint at its `budgeted` entry guard, so the collector that *could* clear
39+
the trigger never runs again.
40+
* and at the pump's cadence (2048 work units per drain, ~17 drains in the whole
41+
program) it cannot finish.
42+
43+
The alloc-point path already routes nursery pressure away from the budgeted
44+
stepper for exactly this reason; the host-safepoint path does not.
45+
46+
#### What the loop poll had to do with it: nothing
47+
48+
`PERRY_GC_MOVING_LOOP_POLLS=0` measures −14 % on that program, which read as
49+
"incremental work driven at back-edge polls". The new counters retire that:
50+
`poll_arm_events=0`, `loop_polls=1` — the back-edge poll is armed zero times and
51+
taken once (the startup seed release) in the whole run. The knob acts only
52+
through `nursery_cap_active()`. `PERRY_GC_SCAVENGE_NURSERY_MB=4096`, which moves
53+
the cap and nothing else, reproduces it to three digits: −11.81 % vs −11.81 %
54+
(instructions retired, best of 7).
55+
56+
#### Fix deliberately NOT shipped here
57+
58+
Giving the precise collector first refusal at the pump boundary removes the
59+
stall completely (`cycle_starts` 1 → 0, `mark_barrier_armed_us` 37 214 → 0,
60+
`copying_minors` 0 → 2, all 19 corpus programs byte-identical) and costs
61+
**+51.4 % instructions and +57 % RSS** on `asyncpipe`, because the two minors it
62+
unblocks are priced by #7915: one of them is a **134 ms minor that copied zero
63+
objects**, spending its time scanning 82 registered runtime mutable root
64+
scanners over 218 455 pointer roots. With no nursery trigger due the reorder is
65+
inert to three digits (1691.7 M vs 1692.0 M), so that is the price of
66+
collecting, not of the change. It is recorded in
67+
`gc-handoff/GC7909-NOTES.md` §4 and belongs after #7915, not before it.
68+
69+
Tests: `an_active_budgeted_cycle_locks_out_the_moving_minor_and_keeps_the_barrier_armed`
70+
pins the composition and asserts its own subject was live (the trigger is due,
71+
the cycle really was started by the call under test, the block is attributed to
72+
the `budgeted` guard specifically, and the completion counter moves too — so
73+
`starts > completions` can be read as a stall rather than as a dead fixture);
74+
`arm_events_count_arms_and_the_word_is_reported` pins the poll-arming pair.

crates/perry-runtime/src/gc/barrier/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,7 @@ pub(super) fn incremental_mark_barrier_enable(valid_ptrs: &ValidPointerSet, mino
761761
// its insertion barrier — a lost mark, i.e. a live object swept.
762762
let newly_active = INCREMENTAL_MARK_BARRIER_VALID_PTRS.with(|cell| cell.get().is_null());
763763
if newly_active {
764+
super::instruments::note_mark_barrier_armed();
764765
PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT.fetch_add(1, Ordering::SeqCst);
765766
}
766767
INCREMENTAL_MARK_BARRIER_VALID_PTRS.with(|cell| cell.set(valid_ptrs as *const ValidPointerSet));
@@ -773,6 +774,7 @@ pub(super) fn incremental_mark_barrier_disable() {
773774
was_active
774775
});
775776
if was_active {
777+
super::instruments::note_mark_barrier_disarmed();
776778
let _ = PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT.fetch_update(
777779
Ordering::SeqCst,
778780
Ordering::SeqCst,

crates/perry-runtime/src/gc/instruments.rs

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,153 @@ pub(crate) fn note_loop_poll_reached() {
5858
pub fn loop_polls_reached() -> u64 {
5959
LOOP_POLLS.load(Ordering::Relaxed)
6060
}
61+
62+
static INCREMENTAL_CYCLE_STARTS: AtomicU64 = AtomicU64::new(0);
63+
static INCREMENTAL_STEPS: AtomicU64 = AtomicU64::new(0);
64+
static INCREMENTAL_COMPLETIONS: AtomicU64 = AtomicU64::new(0);
65+
66+
/// A budgeted (incremental) cycle was STARTED.
67+
#[inline]
68+
pub(crate) fn note_incremental_cycle_start() {
69+
INCREMENTAL_CYCLE_STARTS.fetch_add(1, Ordering::Relaxed);
70+
}
71+
72+
/// One budgeted step advanced an active incremental cycle. This counts the
73+
/// mutator-assist slices an allocating program pays *between* collections, so a
74+
/// run whose `[gc]` output is empty can still show what the incremental
75+
/// collector charged it.
76+
#[inline]
77+
pub(crate) fn note_incremental_step() {
78+
INCREMENTAL_STEPS.fetch_add(1, Ordering::Relaxed);
79+
}
80+
81+
/// A budgeted cycle reached its finisher.
82+
#[inline]
83+
pub(crate) fn note_incremental_completion() {
84+
INCREMENTAL_COMPLETIONS.fetch_add(1, Ordering::Relaxed);
85+
}
86+
87+
/// Budgeted incremental cycles started in this process.
88+
pub fn incremental_cycle_starts() -> u64 {
89+
INCREMENTAL_CYCLE_STARTS.load(Ordering::Relaxed)
90+
}
91+
92+
/// Budgeted incremental steps executed in this process.
93+
pub fn incremental_steps() -> u64 {
94+
INCREMENTAL_STEPS.load(Ordering::Relaxed)
95+
}
96+
97+
/// Budgeted incremental cycles completed in this process.
98+
pub fn incremental_completions() -> u64 {
99+
INCREMENTAL_COMPLETIONS.load(Ordering::Relaxed)
100+
}
101+
102+
static MARK_BARRIER_ARM_EVENTS: AtomicU64 = AtomicU64::new(0);
103+
static MARK_BARRIER_ARMED_US: AtomicU64 = AtomicU64::new(0);
104+
/// Microseconds-since-`process_epoch` at which the currently-open armed window
105+
/// started, `0` when no window is open. Lock-free on purpose: this runs inside
106+
/// the collector, where taking a lock is a hazard nobody should have to reason
107+
/// about for a counter.
108+
static MARK_BARRIER_ARMED_SINCE_US: AtomicU64 = AtomicU64::new(0);
109+
110+
fn process_epoch_us() -> u64 {
111+
static EPOCH: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
112+
EPOCH
113+
.get_or_init(std::time::Instant::now)
114+
.elapsed()
115+
.as_micros() as u64
116+
}
117+
118+
/// The incremental (SATB) mark barrier became armed on some thread.
119+
///
120+
/// The barrier is not a per-cycle cost — it is a *per-microsecond* one. While
121+
/// it is armed, every heap-pointer store, every shadow-slot root store and
122+
/// every allocation (allocate-black) in the program pays for the cycle, so what
123+
/// a cost investigation needs is the wall time it stays armed, not how many
124+
/// cycles armed it. On `gc-handoff/apps/asyncpipe.ts` that is 37 ms of a 127 ms
125+
/// program spent shading for a cycle that never completes and never collects
126+
/// anything (#7909) — a number that was previously not observable at all.
127+
pub(crate) fn note_mark_barrier_armed() {
128+
MARK_BARRIER_ARM_EVENTS.fetch_add(1, Ordering::Relaxed);
129+
// `+1` so an arm at epoch microsecond 0 stays distinguishable from "closed".
130+
let now = process_epoch_us().saturating_add(1);
131+
let _ =
132+
MARK_BARRIER_ARMED_SINCE_US.compare_exchange(0, now, Ordering::Relaxed, Ordering::Relaxed);
133+
}
134+
135+
/// The incremental mark barrier was disarmed.
136+
pub(crate) fn note_mark_barrier_disarmed() {
137+
let started = MARK_BARRIER_ARMED_SINCE_US.swap(0, Ordering::Relaxed);
138+
if started != 0 {
139+
let now = process_epoch_us().saturating_add(1);
140+
MARK_BARRIER_ARMED_US.fetch_add(now.saturating_sub(started), Ordering::Relaxed);
141+
}
142+
}
143+
144+
/// How many times the incremental mark barrier was armed.
145+
pub fn mark_barrier_arm_events() -> u64 {
146+
MARK_BARRIER_ARM_EVENTS.load(Ordering::Relaxed)
147+
}
148+
149+
/// Microseconds the incremental mark barrier has been armed, including a window
150+
/// still open at the time of the call — a starved cycle's window never closes,
151+
/// and that is exactly the case this number exists to report.
152+
pub fn mark_barrier_armed_us() -> u64 {
153+
let closed = MARK_BARRIER_ARMED_US.load(Ordering::Relaxed);
154+
let started = MARK_BARRIER_ARMED_SINCE_US.load(Ordering::Relaxed);
155+
let open = if started == 0 {
156+
0
157+
} else {
158+
process_epoch_us().saturating_add(1).saturating_sub(started)
159+
};
160+
closed + open
161+
}
162+
163+
static MOVING_SAFEPOINT_BLOCKED_BY_BUDGETED: AtomicU64 = AtomicU64::new(0);
164+
165+
/// A precise safepoint was rejected because a budgeted cycle was active.
166+
#[inline]
167+
pub(crate) fn note_moving_safepoint_blocked_by_budgeted() {
168+
MOVING_SAFEPOINT_BLOCKED_BY_BUDGETED.fetch_add(1, Ordering::Relaxed);
169+
}
170+
171+
/// How many precise safepoints an active budgeted cycle rejected.
172+
pub fn moving_safepoints_blocked_by_budgeted() -> u64 {
173+
MOVING_SAFEPOINT_BLOCKED_BY_BUDGETED.load(Ordering::Relaxed)
174+
}
175+
176+
/// Why a budgeted step did no work. Every arm is a distinct reason a cycle can
177+
/// stall, and a stalled-but-active cycle keeps the mark barrier armed.
178+
#[derive(Clone, Copy)]
179+
pub(crate) enum BudgetedStepSkip {
180+
Reentrant,
181+
NoTrigger,
182+
StartBlocked,
183+
ResumeBlocked,
184+
}
185+
186+
static SKIP_REENTRANT: AtomicU64 = AtomicU64::new(0);
187+
static SKIP_NO_TRIGGER: AtomicU64 = AtomicU64::new(0);
188+
static SKIP_START_BLOCKED: AtomicU64 = AtomicU64::new(0);
189+
static SKIP_RESUME_BLOCKED: AtomicU64 = AtomicU64::new(0);
190+
191+
#[inline]
192+
pub(crate) fn note_budgeted_step_skip(reason: BudgetedStepSkip) {
193+
match reason {
194+
BudgetedStepSkip::Reentrant => &SKIP_REENTRANT,
195+
BudgetedStepSkip::NoTrigger => &SKIP_NO_TRIGGER,
196+
BudgetedStepSkip::StartBlocked => &SKIP_START_BLOCKED,
197+
BudgetedStepSkip::ResumeBlocked => &SKIP_RESUME_BLOCKED,
198+
}
199+
.fetch_add(1, Ordering::Relaxed);
200+
}
201+
202+
/// `(reentrant, no_trigger, start_blocked, resume_blocked)`.
203+
pub fn budgeted_step_skips() -> (u64, u64, u64, u64) {
204+
(
205+
SKIP_REENTRANT.load(Ordering::Relaxed),
206+
SKIP_NO_TRIGGER.load(Ordering::Relaxed),
207+
SKIP_START_BLOCKED.load(Ordering::Relaxed),
208+
SKIP_RESUME_BLOCKED.load(Ordering::Relaxed),
209+
)
210+
}

crates/perry-runtime/src/gc/mod.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,9 +1036,51 @@ pub extern "C" fn js_gc_release_current_thread_collection_side_allocations() {
10361036
// safepoints the schedule actually saw. Inert (one cached-`Option` load) and
10371037
// once-only when the mode is off.
10381038
schedule::report_exit_summary();
1039+
emit_incremental_liveness_diag();
10391040
emit_schedule_liveness_verdict();
10401041
}
10411042

1043+
/// `PERRY_GC_DIAG=1`: what the INCREMENTAL collector charged this run, whether
1044+
/// or not any cycle completed (#7909).
1045+
///
1046+
/// Every other GC diagnostic is emitted per completed cycle, so a run that
1047+
/// starts a budgeted cycle and never finishes it prints nothing at all — the
1048+
/// `asyncpipe` shape, where the collector's own output is empty while a third
1049+
/// of the leaf profile is collector machinery. `cycle_starts > completions`
1050+
/// with a large `steps` is exactly that state, and it is only visible here.
1051+
fn emit_incremental_liveness_diag() {
1052+
if !telemetry::gc_diag_enabled() {
1053+
return;
1054+
}
1055+
if !crate::native_handle::is_main_thread_or_unrecorded() {
1056+
return;
1057+
}
1058+
static EMITTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1059+
if EMITTED.swap(true, std::sync::atomic::Ordering::SeqCst) {
1060+
return;
1061+
}
1062+
let (reentrant, no_trigger, start_blocked, resume_blocked) = instruments::budgeted_step_skips();
1063+
eprintln!(
1064+
"[gc-incremental] cycle_starts={} steps={} completions={} active_at_exit={} \
1065+
mark_barrier_arms={} mark_barrier_armed_us={} \
1066+
skips(reentrant={reentrant} no_trigger={no_trigger} start_blocked={start_blocked} \
1067+
resume_blocked={resume_blocked}) safepoints_blocked_by_budgeted={} \
1068+
copying_minors={} loop_polls={} poll_arm_events={} \
1069+
poll_armed_at_exit={}",
1070+
instruments::incremental_cycle_starts(),
1071+
instruments::incremental_steps(),
1072+
instruments::incremental_completions(),
1073+
policy::gc_budgeted_cycle_active(),
1074+
instruments::mark_barrier_arm_events(),
1075+
instruments::mark_barrier_armed_us(),
1076+
instruments::moving_safepoints_blocked_by_budgeted(),
1077+
instruments::copying_minor_cycles(),
1078+
instruments::loop_polls_reached(),
1079+
poll_arm::poll_arm_events(),
1080+
poll_arm::poll_armed_count(),
1081+
);
1082+
}
1083+
10421084
/// Print what the rate-1 schedule endpoint actually did, and **fail the
10431085
/// process** when the answer is "nothing" (#7604).
10441086
///

crates/perry-runtime/src/gc/policy.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2573,6 +2573,14 @@ pub(crate) fn gc_safepoint_moving_minor() -> bool {
25732573
if in_alloc || unsafe_zone || root_lock || budgeted {
25742574
// Blocked right now — leave GC_SAFEPOINT_PENDING set so the next poll
25752575
// retries; do not clear it here.
2576+
//
2577+
// #7909: `budgeted` is the arm that can be PERMANENT. A budgeted cycle
2578+
// started for nursery pressure that this pump's cadence cannot finish
2579+
// rejects every later safepoint here, forever, so it is counted apart
2580+
// from the transient arms.
2581+
if budgeted {
2582+
super::instruments::note_moving_safepoint_blocked_by_budgeted();
2583+
}
25762584
return false;
25772585
}
25782586
// We are handling this safepoint (collect or find nothing due): clear the
@@ -3220,14 +3228,23 @@ fn gc_budgeted_step_work_units_inner_with_progress(
32203228
}
32213229

32223230
let Some(_guard) = BudgetedGcStepGuard::enter() else {
3231+
super::instruments::note_budgeted_step_skip(
3232+
super::instruments::BudgetedStepSkip::Reentrant,
3233+
);
32233234
return gc_budgeted_skipped_result();
32243235
};
32253236

32263237
if !gc_budgeted_cycle_active() {
32273238
if gc_budgeted_due_trigger().is_none() {
3239+
super::instruments::note_budgeted_step_skip(
3240+
super::instruments::BudgetedStepSkip::NoTrigger,
3241+
);
32283242
return gc_idle_step_result();
32293243
}
32303244
if gc_budgeted_start_blocked() {
3245+
super::instruments::note_budgeted_step_skip(
3246+
super::instruments::BudgetedStepSkip::StartBlocked,
3247+
);
32313248
return gc_budgeted_skipped_result();
32323249
}
32333250
let cycle = gc_start_budgeted_cycle_for_pressure(start_progress_kind)
@@ -3236,9 +3253,13 @@ fn gc_budgeted_step_work_units_inner_with_progress(
32363253
*slot.borrow_mut() = Some(cycle);
32373254
});
32383255
GC_BUDGETED_CYCLE_ACTIVE.with(|active| active.set(true));
3256+
super::instruments::note_incremental_cycle_start();
32393257
}
32403258

32413259
if gc_budgeted_resume_blocked() {
3260+
super::instruments::note_budgeted_step_skip(
3261+
super::instruments::BudgetedStepSkip::ResumeBlocked,
3262+
);
32423263
return gc_budgeted_skipped_result();
32433264
}
32443265

@@ -3250,7 +3271,9 @@ fn gc_budgeted_step_work_units_inner_with_progress(
32503271
};
32513272

32523273
let step = cycle.state.step(GcWorkBudget::bounded(work_units));
3274+
super::instruments::note_incremental_step();
32533275
if step.completed {
3276+
super::instruments::note_incremental_completion();
32543277
BudgetedStepOutcome::Completed(slot.take().expect("active budgeted GC cycle exists"))
32553278
} else {
32563279
BudgetedStepOutcome::Result(gc_cycle_step_result(

0 commit comments

Comments
 (0)