1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
//! Dead-rank detection, partition redistribution, and liveness queries
//! for [`super::ClusterCoordinator`].
use std::time::{Duration, Instant};
use crate::distributed::ddp_run::AverageBackend;
use crate::distributed::wire::ControlMsgWire;
use crate::tensor::{Result, TensorError};
use super::ClusterCoordinator;
impl ClusterCoordinator {
/// Scan `last_heartbeat` for stale entries. For each rank whose
/// most-recent frame arrival exceeds `heartbeat_timeout_secs` and
/// is not already dead, declare it dead via the shared
/// [`crate::distributed::controller::DeadRanks`] ledger.
///
/// Declaring a rank dead:
/// - Sets the rank's flag (shared with controller).
/// - Shuts down the rank's controller-side stream, waking any
/// in-flight AllReduce so it releases with surviving ranks.
/// - Decrements `active_count` so subsequent `should_average`
/// gates use the smaller quorum.
///
/// No-op when `dead_ranks` is `None` (elastic membership not
/// configured — rank death is permanently blocking).
pub(super) fn check_dead_ranks(&mut self) {
let Some(ledger) = self.dead_ranks.as_ref().cloned() else {
return;
};
// Externally-reported deaths first: the launcher's child
// supervision knows a rank died within milliseconds of the
// process exiting — long before the heartbeat staleness window
// — and reports it through the shared queue. Same side-effect
// chain as staleness detection, just a faster detector.
let mut any_newly_dead = self.drain_reported_deaths(&ledger);
let now = Instant::now();
let threshold = Duration::from_secs(self.heartbeat_timeout_secs);
for r in 0..self.world_size {
if ledger.is_dead(r) {
continue;
}
// A rank that announced a clean exit stops heartbeating by
// design — staleness is not death for it (declaring it dead
// would double-decrement `active_count` and broadcast a bogus
// DeclareDead during teardown).
if self.exited[r] {
continue;
}
if now.duration_since(self.last_heartbeat[r]) > threshold {
crate::verbose!(
" ddp: heartbeat stale on rank {} (>{}s), declaring dead",
r,
self.heartbeat_timeout_secs,
);
self.process_rank_death(
r,
&ledger,
&format!(
"heartbeat stale (>{}s)",
self.heartbeat_timeout_secs,
),
);
any_newly_dead = true;
}
}
// After processing all deaths this tick, decide whether the
// cluster is recoverable. If user-configured max_failure was
// breached, or the backend's hard limit is hit (NCCL needs
// world_size>=2; CPU needs at least 1 survivor), broadcast
// ShutdownWithSave so survivors persist state before exiting.
// This MUST come before initiate_nccl_rendezvous_if_needed —
// the rendezvous path would silently early-exit at <2
// survivors, leaving the lone survivor blocked indefinitely.
if any_newly_dead {
if let Some(reason) = self.unrecoverable_reason() {
if let Err(e) = self.dispatch_shutdown_with_save(reason) {
crate::verbose!(
" ddp: ShutdownWithSave broadcast failed: {}",
e,
);
}
} else if let Err(e) = self.initiate_nccl_rendezvous_if_needed() {
crate::verbose!(
" ddp: NCCL rendezvous initiation failed: {}",
e,
);
} else if self.progressive {
// Put the reclaimed samples (and any survivor parked in
// `wait_for_epoch_plan` because the pool LOOKED empty
// before the forfeit) back into motion. Without this kick
// nothing drives dispatch until the next completion frame,
// which may never come if every survivor is idle.
self.wake_idle_ranks_in_progressive();
}
}
}
/// Full death side-effect chain for rank `r`, shared by every
/// detector (heartbeat staleness, externally-reported child exits).
/// Callers guarantee `r` is not already dead and not cleanly
/// `exited`. Order matters: the remainder is computed BEFORE the
/// ledger flip (the redistribution formula reads the pre-decrement
/// survivor count plus the to-die rank).
///
/// `reason` is the detector's short description, carried as the
/// `rank_lost` alert's `detail`. It rides the shared chain rather than
/// the call sites so a future third detector cannot forget the alert.
pub(super) fn process_rank_death(
&mut self,
r: usize,
ledger: &std::sync::Arc<crate::distributed::controller::DeadRanks>,
reason: &str,
) {
// Alert first: the record stream should carry the loss even if a
// later step of the chain (broadcast, redistribution) errors out.
let path = self.alert_path_for_rank(r);
self.emit_alert(
crate::monitor::event_lane::EventClass::RankLost,
path,
format!("rank {r} declared dead — {reason}"),
);
let remainder_plan = self.compute_dead_rank_remainder(r);
ledger.declare_dead(r);
self.active_count = self.active_count.saturating_sub(1);
self.last_heartbeat[r] = Instant::now();
// Callback-role failover. For `Rank(n)` policy the
// role stays put — a static rank that died will surface
// as a loud send_control error at the next dispatch
// (matches the "controller decides" principle: no
// silent re-routing of a user-pinned rank). For
// `Fastest` policy, re-resolve all three roles
// against the new live set + ElChe smoothed values.
match self.epoch_callback_policy {
crate::distributed::ddp_run::EpochCallbackPolicy::Rank(_) => {
// Checkpoint-role failover: if Rank(n) policy
// and the dead rank happens to be the
// checkpoint role, fall over to lowest live as
// a best-effort. Eval/epoch roles stay pinned.
if r == self.checkpoint_role {
if let Some(next) =
(0..self.world_size).find(|&i| i != r && !ledger.is_dead(i))
{
self.checkpoint_role = next;
crate::verbose!(
" ddp: checkpoint_role failover {} -> {} \
(prior role declared dead)",
r,
next,
);
}
}
}
crate::distributed::ddp_run::EpochCallbackPolicy::Fastest => {
self.re_resolve_callback_roles_on_death(r);
crate::verbose!(
" ddp: Fastest re-resolve after rank {} death \
— checkpoint={}, eval={}, epoch_fn={}",
r,
self.checkpoint_role,
self.eval_role,
self.epoch_callback_role,
);
}
}
// NCCL backend: notify every surviving worker so they
// can update their LOCAL dead-rank ledgers and the
// NCCL watchdog can abort the in-flight collective.
// CPU backend doesn't need this — the controller-side
// stream shutdown via the shared `DeadRanks` ledger
// already releases its blocked AllReduce read.
if matches!(self.backend, AverageBackend::Nccl) {
if let Err(e) = self.broadcast_control(
&ControlMsgWire::DeclareDead { rank: r as u64 },
) {
crate::verbose!(
" ddp: DeclareDead broadcast for rank {} failed: {}",
r,
e,
);
}
}
if let Some((remainder_offset, remainder_size)) = remainder_plan {
if let Err(e) = self.redistribute_dead_rank_partition(
r,
remainder_offset,
remainder_size,
) {
crate::verbose!(
" ddp: ExtendPartition dispatch for dead rank {} \
remainder failed: {} (samples will roll into \
next epoch's reshuffle)",
r,
e,
);
}
}
// PROGRESSIVE-MODE RECLAIM. The `ExtendPartition` path above
// is non-progressive only (`epoch_plan_cache` is populated by
// `plans_for_epoch`, which progressive never calls), so
// without this the dead rank's dispatched-but-never-completed
// chunks stay in-flight forever: `is_epoch_done` never fires,
// the epoch never aggregates, and after the survivors drain
// the pool the reduce gate has no mover left to fire it — the
// production-default Cadence cohort wedges permanently on any
// single rank death. Forfeit returns those samples to the
// pool for survivor re-dispatch and zeroes the rank's
// in-flight books.
if self.progressive {
let reclaimed: usize = self
.chunk_pools
.values_mut()
.map(|p| p.forfeit(r))
.sum();
if reclaimed > 0 {
crate::verbose!(
" ddp: reclaimed {} in-flight samples from dead \
rank {} for survivor re-dispatch",
reclaimed,
r,
);
}
}
}
/// Drain the externally-reported death queue (launcher child
/// supervision) through [`Self::process_rank_death`]. Duplicate or
/// stale reports (already dead, cleanly exited, out of range) are
/// skipped — the ledger is the dedup. Returns whether any death was
/// newly processed.
fn drain_reported_deaths(
&mut self,
ledger: &std::sync::Arc<crate::distributed::controller::DeadRanks>,
) -> bool {
let Some(queue) = self.reported_deaths.as_ref().cloned() else {
return false;
};
let drained: Vec<usize> = {
let mut q = queue.lock().expect("reported-deaths queue poisoned");
q.drain(..).collect()
};
let mut any = false;
for r in drained {
if r >= self.world_size || ledger.is_dead(r) || self.exited[r] {
continue;
}
crate::verbose!(
" ddp: rank {} reported dead by child supervision \
(process exited); declaring dead",
r,
);
self.process_rank_death(r, ledger, "process exited (child supervision)");
any = true;
}
any
}
/// Compute the un-processed `(partition_offset, partition_size)`
/// inside dead rank `r`'s current-epoch partition. Returns `None`
/// when there's nothing to redistribute (rank already finished its
/// partition, partition_size was zero, or `epoch_plan_cache` has
/// no entry for this rank's epoch).
pub(super) fn compute_dead_rank_remainder(&self, r: usize) -> Option<(u64, u64)> {
let epoch = self.rank_epoch[r];
let plans = self.epoch_plan_cache.get(&epoch)?;
let plan = plans.get(r)?;
let processed_batches = self
.last_step_count[r]
.saturating_sub(self.last_step_count_at_epoch_start[r]);
let processed_samples = (processed_batches * self.batch_size) as u64;
if processed_samples >= plan.partition_size {
return None;
}
let remainder_offset = plan.partition_offset + processed_samples;
let remainder_size = plan.partition_size - processed_samples;
Some((remainder_offset, remainder_size))
}
/// Slice the dead rank's un-processed remainder across surviving
/// ranks and emit an [`crate::distributed::wire::ControlMsgWire::ExtendPartition`]
/// frame to each. Currently splits equally; ElChe-weighted
/// distribution (using `partition_ratios` or throughput-derived
/// sizes) is a refinement landing alongside SnapshotReady →
/// ElChe consumer in a future slice. Per-rank slice sizes that
/// don't divide evenly distribute the remainder one sample at a
/// time to the first ranks.
///
/// `dead_rank` itself is skipped. `world_size - active_count` may
/// already include `dead_rank` if the caller decremented
/// `active_count` before calling this method — that's fine
/// because the filter below uses the live `is_dead` ledger which
/// the caller already flipped.
pub(super) fn redistribute_dead_rank_partition(
&mut self,
dead_rank: usize,
remainder_offset: u64,
remainder_size: u64,
) -> Result<()> {
if remainder_size == 0 {
return Ok(());
}
let survivors: Vec<usize> = (0..self.world_size)
.filter(|r| *r != dead_rank && !self.is_dead(*r))
.collect();
if survivors.is_empty() {
return Err(TensorError::new(
"cluster_coordinator: redistribute called with no surviving ranks",
));
}
let n = survivors.len() as u64;
let per_size = remainder_size / n;
let leftover = remainder_size % n;
let mut cursor = remainder_offset;
for (i, rank) in survivors.iter().enumerate() {
let extra = if (i as u64) < leftover { 1 } else { 0 };
let slice_size = per_size + extra;
if slice_size == 0 {
continue;
}
let msg = ControlMsgWire::ExtendPartition {
partition_offset: cursor,
partition_size: slice_size,
};
self.send_control(*rank, &msg)?;
cursor += slice_size;
}
crate::verbose!(
" ddp: redistributed dead rank {}'s {} un-processed samples \
across {} survivors",
dead_rank,
remainder_size,
survivors.len(),
);
Ok(())
}
/// True iff `rank` is known dead via the shared ledger. Returns
/// false when no ledger is configured.
pub(super) fn is_dead(&self, rank: usize) -> bool {
self.dead_ranks
.as_ref()
.map(|d| d.is_dead(rank))
.unwrap_or(false)
}
/// Determine whether the cluster's current state is unrecoverable
/// and what [`crate::distributed::SaveReason`] should be recorded.
///
/// Returns `None` either when the state is fine OR when a save +
/// shutdown has already been dispatched (the flag prevents repeat
/// broadcasts on subsequent ticks).
///
/// Ordering: user-configured `max_failure` is checked first so that
/// a configured threshold takes precedence over the backend's hard
/// limit (a user with `MaxFailureThreshold::Absolute(1)` on an NCCL
/// cluster gets `MaxFailureExceeded`, not `SingleSurvivor`).
pub(super) fn unrecoverable_reason(&self) -> Option<crate::distributed::SaveReason> {
if self.shutdown_with_save_dispatched {
return None;
}
// FAILURES, not absences: `world_size - active_count` also counts
// ranks that exited cleanly (Exiting frame), which must not trip a
// failure threshold. Count the ledger when present (it only ever
// holds declared-dead ranks).
let dead_count = match &self.dead_ranks {
Some(ledger) => (0..self.world_size).filter(|&r| ledger.is_dead(r)).count(),
None => self.world_size.saturating_sub(self.active_count),
};
if let Some(threshold) = self.max_failure {
if dead_count >= threshold.limit_for(self.world_size) {
return Some(crate::distributed::SaveReason::MaxFailureExceeded);
}
}
match self.backend {
AverageBackend::Nccl if self.active_count < 2 => {
// NCCL requires world_size >= 2 to form a comm; the
// lone survivor cannot continue.
Some(crate::distributed::SaveReason::SingleSurvivor)
}
AverageBackend::Cpu if self.active_count == 0 => {
Some(crate::distributed::SaveReason::AllRanksLost)
}
_ => None,
}
}
}