nemo_rl.distributed.refit_watchdog#
Break a refit collective that a dead peer has left hanging.
WHY THIS HAS TO RUN INSIDE THE WORKER. A generation rank that dies mid-refit leaves the
surviving ranks blocked in NCCL with no timeout and no error – observed directly, both
policy workers stuck in packed_broadcast_producer -> cuda stream synchronize while the
run sat wedged for 1801s. The controller cannot rescue them by RPC: the collective blocks
the worker actor’s event loop, and the worker actors carry no max_concurrency, so an
incoming abort call would queue behind the very operation it is meant to interrupt.
The abort must therefore come from a thread already inside the process, which is exactly
the arrangement the design’s NCCL spike validated (a survivor released 0.15s after another
thread called abort()).
TWO SEMANTICS THAT SHAPE THE API, both established by that spike:
An aborted collective returns without raising. So the caller cannot detect this with
try/except; it has to ask whether the abort fired. Hence :attr:fired.The destination buffers hold partial data afterwards. A generation shard caught mid-refit holds a mix of old and new weights and must not serve until a later refit completes. Callers are responsible for propagating that – see
RefitAborted.
Inert unless armed with a positive timeout, so a run that does not configure one behaves exactly as before, down to not starting a thread.
Module Contents#
Classes#
Abort the given group(s) if the guarded block outlives |
Functions#
True when the abort orphaned GPU work and no rebuild on that device can succeed. |
|
True for a RefitAborted, or for one flattened to a plain Exception in transit. |
|
Run a blocking call on a daemon thread so this actor’s event loop stays free. |
|
Wait for |
|
Run a teardown that may never return, without letting it wedge the caller. |
|
Disarm every refit deadline armed in this process, and say how many. |
|
Block a refit receive while a test holds it open. Inert unless asked. |
Data#
API#
- nemo_rl.distributed.refit_watchdog.REFIT_ABORTED_TOKEN#
‘[refit-aborted]’
- nemo_rl.distributed.refit_watchdog.REFIT_CONTEXT_LOST_TOKEN#
‘[refit-context-lost]’
- exception nemo_rl.distributed.refit_watchdog.RefitAborted(*args: object)[source]#
Bases:
RuntimeErrorA refit was cut short because a peer stopped participating.
Raised by the worker that armed the watchdog, not by NCCL – the aborted call itself returns cleanly, so this is the only signal the caller gets.
The constructor prefixes :data:
REFIT_ABORTED_TOKENto the message. Idempotent, so a re-raise or an unpickle (RuntimeError.__reduce__replaysargs) does not stack prefixes.Initialization
Initialize self. See help(type(self)) for accurate signature.
- nemo_rl.distributed.refit_watchdog.is_refit_context_lost(error: BaseException) bool[source]#
True when the abort orphaned GPU work and no rebuild on that device can succeed.
Recovery after this is not merely unlikely, it is impossible in-process, and four runs established that by elimination rather than argument. Job 6521181’s py-spy dump caught both trainers in
init_nccl_communicatorwith frame-lessncclCommAbortthreads 25 minutes on. Job 6523731 killed the frozen victim before the rebuild, closing its sockets, and they wedged identically. Job 6582457 pinned the release to the caller’s CUDA device, with a SIGKILLed peer, and it still did not return. Job 6584636 stood the deadline down on confirmed death and lost the race to Ray’s own detection.So the controller stops trying. Detecting this and ending the run in seconds is the supported behaviour; recovering from it is left to a future change – see design_vllm_fault_tolerance.md section 8.5.7.
- nemo_rl.distributed.refit_watchdog.is_refit_abort(error: BaseException) bool[source]#
True for a RefitAborted, or for one flattened to a plain Exception in transit.
The type check alone is not enough anywhere downstream of a vLLM
collective_rpc: the abort is raised inside the engine core process and arrives at the Ray actor asException("Call to <method> method failed: ..."). A bareexcept RefitAbortedthere is dead code, which is what job 6484412 demonstrated – the deadline fired, the abort was named, and the run still wedged because the handler never matched.
- async nemo_rl.distributed.refit_watchdog.await_off_loop(fn)[source]#
Run a blocking call on a daemon thread so this actor’s event loop stays free.
Ray runs a SYNC actor method directly in the event loop –
sync_to_asyncwraps it asasync def wrapper: return func(...), with no executor – so a refit that blocks in NCCL starves every other call to the same actor.max_concurrencycannot help; it interleaves coroutines, and a coroutine blocked in C never yields.That is why the recovery could not run in job 6509685. The controller gave up on the stuck refit and called
init_collectiveto rebuild, but that call queued behind the refit still occupying this loop. Rank 0 is the rendezvous master, so the store was never created, and the surviving generation worker timed out dialling it for 300s, twice, before the run ended.Daemon, because
asyncio.to_thread’s default executor is non-daemon and joined at interpreter exit: a thread still parked in NCCL would hang shutdown, trading a wedge in the refit for a wedge on the way out.No timeout here on purpose. Bounding the wait is the controller’s job (
_sync_weights_within); this only decides which thread blocks.
- nemo_rl.distributed.refit_watchdog.sync_stream_within(
- stream,
- budget_s: Optional[float],
- what: str,
Wait for
stream’s enqueued work, giving up afterbudget_s.WHY THIS EXISTS, and why the watchdog above is not enough. Aborting a communicator does not retire work already enqueued on a CUDA stream. When a generation rank stops receiving mid-refit the sends sit on the stream forever, and
torch.cuda.synchronize– which waits on the whole device – never returns. The watchdog cannot help: the abort fires, the kernels do not retire, the guarded block never exits, and- Attr:
RefitAbortWatchdog.firedis never read. No exception-translation reaches a hang.
Job 6485245 measured exactly that on 4xGB200: both policy workers parked in
synchronize1801s after their own abort had logged, while the generation workers had already unwound and gone idle.So the wait is bounded here rather than trusted to end. The event is POLLED, not waited on, so nothing can be left holding the GIL, and the happy path still finishes with the same device-wide
synchronize()– behaviour is identical when nothing is wrong.This does NOT recover the fleet. In-flight kernels are orphaned and the caller’s CUDA context should not be trusted afterwards, so the
RefitAbortedraised here is expected to end the run – attributably, in seconds, rather than after a 30-minute stall. Recovering a frozen-but-alive rank on this transport stays out of scope.budget_sof None or <= 0 keeps the original unbounded synchronize, so a run with no refit deadline configured behaves exactly as before.
- nemo_rl.distributed.refit_watchdog.RELEASE_GRACE_S#
30.0
- nemo_rl.distributed.refit_watchdog.release_within(release, budget_s: float, what: str) None[source]#
Run a teardown that may never return, without letting it wedge the caller.
THE SIXTH UNBOUNDED WAIT, and the one that only the reshard transport reaches.
StatelessProcessGroup.abort()callsabort_xferdtensor_python_subcommunicatorsbefore the parent, and those split children exist ONLY on the Python reshard path – on the packed-broadcast path that call finds no cache entry and returns immediately.ncclCommAbortjoins the communicator’s proxy thread, and a proxy thread blocked reading from a SIGSTOPped peer never returns: the socket is open and idle, so nothing errors and nothing times out. SIGKILL closes it and the proxy errors out at once, which is why the killed variants never see this.Job 6518381 measured the consequence. On the rebuild, train rank 0 – the rendezvous store’s master – entered
init_collective, printed its line, and never bound the port, because the only statement between the two is the old group’s release. The surviving generation rank then spent 600s (300s, an 89s backoff, 300s again) failing to connect to a store that was never created, and the run died at 700s having condemned the right shard and planned the right membership.Bounded, on a DAEMON thread, and deliberately not joined.
asyncio.to_threadand a bareThreadPoolExecutorboth use non-daemon threads that the interpreter joins at exit, which would move the wedge to shutdown rather than remove it. Nothing downstream reads a result: the release exists to stop resources accumulating across rebuilds, so a release that never finishes costs one stuck thread, while waiting on it costs the run.
- nemo_rl.distributed.refit_watchdog._ARMED_LOCK#
‘Lock(…)’
- nemo_rl.distributed.refit_watchdog._ARMED: set[RefitAbortWatchdog]#
‘set(…)’
- nemo_rl.distributed.refit_watchdog.stand_down_armed_watchdogs() int[source]#
Disarm every refit deadline armed in this process, and say how many.
THE DEADLINE IS FOR A SILENT PEER, NOT A DEAD ONE, and firing it on a dead one is strictly harmful. When a generation rank’s process is gone its sockets close, NCCL’s own error path unblocks the survivors, and the run recovers off the pre-existing actor-death route – job 6405953 passed the reshard kill variant that way with
RefitAbortedappearing zero times, before any deadline existed.Once the deadline was added it started winning that race. It aborts at its timeout,
sync_stream_withingives up on kernels already enqueued on the trainers’ streams, and the CUDA context cannot be trusted afterwards – so the rebuild that would have succeeded now cannot.recovery-reshard-refithas failed continuously since job 6512153, which is the run where the deadline first began firing on that path, and jobs 6521181/6523731/6582457 each confirmed the abort never retires: not with the peer SIGKILLed, not with the release pinned to the caller’s device.So the controller stands the deadline down the moment a probe reports an actor DEATH, which is conclusive in a way a timeout is not. The frozen case is untouched: a frozen rank is alive, no death is ever recorded, and the deadline still fires and still ends the run attributably.
The controller can reach this at all only because the refit runs off the actor’s event loop (see
await_off_loop); a worker blocked in the loop could not service the call.Idempotent, and safe to call when nothing is armed.
- class nemo_rl.distributed.refit_watchdog.RefitAbortWatchdog(
- group: Optional[Union[nemo_rl.distributed.refit_watchdog._Abortable, collections.abc.Sequence[Optional[nemo_rl.distributed.refit_watchdog._Abortable]]]],
- timeout_s: Optional[float],
Abort the given group(s) if the guarded block outlives
timeout_s.Use as a context manager around the collective::
with RefitAbortWatchdog(self.model_update_group, timeout_s) as guard: ...collective... if guard.fired: raise RefitAborted(...)A sequence may be passed instead of one group, and the nccl_reshard transport needs that: it moves weights over per-PP-stage bulk groups and then broadcasts the remainder over the shared
model_update_group, so a hang can be in either family and nothing at this level can tell which. Aborting all of them costs nothing –abort()is idempotent and safe on a group that never built a communicator – and the recovery rebuilds every family regardless.timeout_sofNoneor<= 0disarms it entirely: no thread is started andfiredstays False, so the default configuration is bit-for-bit the old behaviour.Initialization
- property armed: bool#
- stand_down() None[source]#
Cancel this deadline without firing it; see stand_down_armed_watchdogs.
Sets the same event the guarded block sets on a clean exit, so the watch thread returns without aborting anything and
firedstays False. Racing a watch thread that is already past its wait is harmless:_doneis only ever set, never cleared, and the abort it performs is idempotent.
- property fired: bool#
True if the deadline passed and abort() was called.
- nemo_rl.distributed.refit_watchdog.hold_refit_for_fault_injection() None[source]#
Block a refit receive while a test holds it open. Inert unless asked.
Does nothing unless
NRL_REFIT_HOLD_FILEnames a path that exists, so a real run pays oneos.path.existsper refit and behaves no differently.It exists because “kill a shard during the refit” is otherwise untestable. A refit on the functional test’s model takes ~0.10s, and the harness has to notice one started and then find and kill a process: job 5925668 aimed at the collective and landed in the RPC epilogue instead. That is a real failure mode and worth handling, but it is not the one the test claimed to cover, so the abort-and-rebuild path went unexercised while the run still reported a result.
A file rather than a fixed delay because the harness has to hold one specific refit – the one after the step it kills at. A delay on every refit would slow the whole run for the sake of one moment and still not be aimed at it.
Bounded by
NRL_REFIT_HOLD_MAX_Sso a harness that dies mid-test cannot wedge the worker it was holding.