Companion illustration for
NVIDIA-NeMo/RL #3592.
Permalinks pin the PR head 5b4a732.
Seven pages on this PR, one question each โ this one: the order things happen in.
Short answer up front: the restart itself is fully asynchronous and is queued behind nothing. It starts on the next fleet-health tick after the shard is marked DEAD, and it runs on a thread, so training and generation carry on underneath it. What is queued is everything after the engine comes back: re-entering the collective waits for the next refit, and on the NeMo-Gym path taking traffic again waits for one more fleet-health tick after that. On the default path โ native Ray calls, no router โ traffic resumes the moment the refit promotes it.
Every timeline below uses the same run: 4 generation shards, shard 2 dies,
with the shipped defaults
probe_interval_s: 5,
unhealthy_threshold: 3 and refit_timeout_s: 300. Time axes are not to
scale โ a model reload takes minutes while a probe takes milliseconds โ so each tick is labelled.
The SingleController runs
four asyncio tasks on one event loop.
One loop means they never truly run at the same instant โ they take turns, handing control over
at each await. Anything that would block for a long time is pushed onto a thread so
the other three keep their turns. Note what that does and does not buy: moving work to a thread
keeps the loop free, but the task that awaits that work is still stuck until it
finishes.
"On a thread" and "asynchronous" are not the same thing, and the two boxes on the right differ exactly there. Both the restart and the weight transfer are pushed off the loop, so neither freezes the other three pumps. But the weight transfer is awaited by the train pump: the refit is step 6 of a training step, right after step 5 trains the policy, and the pump cannot start the next step until it returns. So the weight transfer is synchronous with respect to the train pump โ it is moved off the loop to protect the watchdog and the probe, not to let training run ahead of it. The restart is the opposite: it is started and then nobody waits for it at all.
Two more things worth stating plainly. First, nothing takes a lock: there is no mutual exclusion between a restart and a refit, and no queue that one waits in for the other. Second, generation is not paused during a refit on this path: nothing waits for in-flight rollouts to finish before weights start being written, and the code marks adding that as a known TODO.
Before any restart can start, the shard has to reach DEAD. How long that takes
depends on how it failed, and the difference is large.
| How it fails | What the probe sees | Time to DEAD | Why |
|---|---|---|---|
| Crash โ the process is gone | RayActorError |
one probe, so โค 5s | Ray only reports this once the process is really gone, so it is conclusive. It goes straight to DEAD with no threshold. |
| Hang โ alive but not answering | timeout | three probes, so โ 15s | A timeout is ambiguous โ it could be a blip. It counts one failure, so the shard sits in SUSPECT until unhealthy_threshold is reached. |
A crash also does one extra thing, and it needs a sentence of background first. Every
refit arms a timer inside each trainer worker. If the weight transfer has not finished
within refit_timeout_s (300s by default), that timer breaks the NCCL communicator so
the trainers stop waiting on a rank that is never going to answer. It is the safety net for a
generation shard that goes silent mid-transfer.
On a confirmed death, the controller cancels that timer โ the code calls this "standing the deadline down". Cancelling the safety net sounds backwards, so here is why it is right. A process that has genuinely died closes its sockets, and NCCL sees that and unblocks the survivors by itself, which is the clean way out. If the timer fired first it would break the communicator instead, and breaking it strands GPU work already queued on the trainers' own streams โ leaving their devices in a state no rebuild can use. So on a confirmed death, doing nothing is strictly better than intervening.
A frozen-but-alive rank never produces a confirmed death, so its timer is never cancelled and still fires. That is the case the timer exists for.
A detail that surprises people: the probe only walks
serving_shards(),
which is HEALTHY plus SUSPECT. So a shard is not probed while
it is DEAD, RESTARTING or STALE. That is deliberate for the
first two, but it does mean a freshly restarted STALE shard is unwatched in the
window between its engine coming up and the refit that promotes it.
The common case, and the one the feature is built for. Shard 2 crashes while the run is generating. Nothing is mid-collective, so nothing is stuck.
Two things about the right-hand end. First, the refit step is not reloading anything โ the model was loaded from disk minutes earlier, during the restart. What the refit does is rebuild the NCCL group so it includes shard 2 again, and then send it the current weights. That is why the shard has to wait for a refit at all: it has a model, but the wrong version of it.
Second, the shard becomes HEALTHY at the end of that refit, when
_promote_refit_shards
runs โ not at the start. The little gap after it is a real wait, and whether it costs you
anything depends on how rollouts are routed:
| Routing | When the shard gets traffic again | Why |
|---|---|---|
| Native Ray calls ( generation_router.enabled: false, the default) |
immediately, on the next request | _next_dp_shard_idx asks the selector per request, and it reads serving_shards() live. Nothing is cached. |
| Via the NeMo-Gym router | at the next probe tick, so โค 5s later | Gym holds one fixed URL and never re-resolves it, so the router has to be told. _push_router_membership sends it serving_base_urls() on the probe pump's clock. |
So your guess about the router was right, but only for the Gym path โ and that push matters there for a specific reason: the restarted engine binds a new port, so without it Gym keeps talking to a socket nobody is listening on.
Now the timing is different, because the trainers are inside a collective that needs every rank. This is where the refit deadline earns its keep.
What gets retried is the weight transfer, once. Not the request, not the
training step. The controller calls
_sync_weights_within(..., "first");
when that raises it recovers and calls the same function again with "retry" โ same
weights, same training step, smaller group โ then
_promote_refit_shards
puts the survivors back in service. A second failure ends the run: at that point it is a fault,
not a membership problem.
The three numbered steps in the figure are
_recover_from_failed_refit:
probe every shard on the spot rather than on the 5s clock, mark the survivors
STALE if the failure
was an abort, then rebuild the communicator without the dead shard. If nothing is absent but
exactly one shard is already SUSPECT, that one is condemned as the silent
participant โ with several suspects, or none, it gives up rather than condemn a healthy shard by
guessing.
Why are the survivors STALE when they are perfectly healthy? Because
STALE is a verdict on the weights, not the process. Every other state
describes the process; STALE says "this engine's weights are not the current ones".
An abort stops a transfer mid-flight, so every receiving engine holds a mix of old and new โ
generating from that is silently wrong.
mark_weights_partial
puts it in one line: "the process is alive and refits normally, it just must not serve until a
refit completes."
It is the same state a restarted engine sits in, for the same reason and a different cause. Both stay in the group so the refit can reach them, and out of the serving set so nothing reads from them meanwhile.
Two things about the timing. The ledger marks shard 2 DEAD after
three failed probes โ about 15s โ but the trainers stay blocked for the full 300s deadline,
because nothing connects the ledger's verdict to an in-flight collective. Only a confirmed
process death ends the wait early, and it does so by
cancelling the deadline and letting
NCCL unblock the survivors itself. So a hang costs the full 300s; a crash costs seconds.
And the restart begins at that 15s mark, running on a thread the whole time the trainers are
parked โ the clearest "queued behind nothing" on this page. Its first act is to kill the hung
worker, which
recreate_worker expects
("a worker can be unresponsive rather than gone"). The replacement then has to load onto
the GPU that worker was holding, so if its EngineCore is orphaned rather than reaped the restart
fails on memory โ which is where RAY_process_group_cleanup_enabled matters most.
One case does not recover. On the nccl_reshard transport, a fault
while the bulk transfer is in flight cannot be recovered in-process: aborting the communicator does
not retire CUDA work already queued on the trainers' own streams, so no rebuild can bootstrap on
those devices. The run
detects this and ends in seconds with a named cause
rather than wedging. On the collective transport the same fault recovers.
This one is a bug rather than a timing story. A page of its own covers it start to finish, including why the metric meant to catch it cannot.
Shard 2 did not fail during this refit. It died in an earlier step and has
been restarting ever since, so when this refit settled its membership shard 2 was already
RESTARTING โ absent, and correctly left out. All of that works as intended. The only
new thing is when the reload finishes: it takes minutes and the loop is free throughout,
so it can land at any moment, including in the middle of someone else's refit.
It should stay STALE โ that is exactly the bug. Shard 2 took no part in
this refit, so nothing gave it current weights, and the design is explicit that a completed refit
is the only way back into the serving set. But
_promote_refit_shards
promotes every shard that is STALE when it runs, and
report_refit
takes the shard index on faith. Neither one checks participation, because until this PR nothing
could turn a shard STALE in the middle of a refit โ so "is STALE" and "was refitted"
described the same set, and no check was needed.
This is a live bug, raised in review and not yet fixed. The shard serves one step of rollouts generated from the checkpoint it loaded off disk, mixed into the training batch with nothing marking it. It self-corrects at the next refit, so the damage is bounded to one step โ but it is silent, and the metric meant to catch it is written by the same call, so it reports a healthy-looking weight version. The fix is to promote only the shards the refit actually wrote to.
| Step | Runs on | Waits for | How long |
|---|---|---|---|
| Notice a crash | probe pump | the next probe tick | โค 5s |
| Notice a hang | probe pump | three consecutive failed probes | โ 15s |
| Start the restart | probe pump | nothing โ same tick as the verdict | immediate |
| Reload the model | a thread | nothing โ nobody awaits it | minutes |
| Transfer the weights | a thread | โ, but the train pump waits on this one | seconds |
| Rejoin the collective | train pump | the next refit | one training step |
| Take traffic again โ native calls | rollout pump | nothing; the selector reads the ledger live | immediate |
| Take traffic again โ via the Gym router | probe pump | the next tick after it is HEALTHY | โค 5s |
So: is the restart synchronous with anything? No. It is started from the
fleet-health tick and immediately handed to a thread, and nothing waits on it โ not the refit, not
training, not generation. The supervisor's
tick()
returns as soon as it has started the work, and a shard already being restarted is skipped on later
ticks so a slow reload cannot pile up.
The waiting is all on the way back in, and it is deliberate: a restarted engine holds whatever
weights it loaded from disk, so it must sit out until a refit gives it current ones. That is what
STALE means โ in the group, so the refit reaches it, but not in the serving set.