Companion illustration for
NVIDIA-NeMo/RL #3592.
Permalinks pin the PR head 5b4a732. vLLM is pinned at 0.24.0 on x86_64 and
0.25.1 on aarch64.
Seven pages on this PR, one question each โ this one: how far down the stack a restart reaches.
A generation shard is one vLLM model replica. NeMo-RL gives it
tensor_parallel_size ร pipeline_parallel_size Ray actors, one per GPU bundle,
but only the first of them loads a model: that one gets
bundle_indices,
which is exactly what makes it the
model owner.
The rest set self.llm = None and
return immediately โ
they exist to hold their GPU bundle. The owner builds the vLLM engine with
distributed_executor_backend="ray"
whenever TPรPP > 1, and hands it
VLLM_RAY_BUNDLE_INDICES
so vLLM's own Ray workers land on those same bundles.
Take GB200 with 4 GPUs per node and tensor parallel = 8.
One replica no longer fits on a machine, and NeMo-RL notices:
model_parallel_size > cluster.num_gpus_per_node
switches the cluster to a single unified placement group covering every
inference GPU, instead of one placement group per node.
The bundles in that unified group are flattened into topology order and sliced into consecutive chunks of TPรPP. Each chunk is one replica. With 3 replicas that is 6 nodes and 24 bundles, and each replica straddles a node boundary โ the group is only ever labelled with the node of its first bundle.
So with TP=8 on 4-GPU nodes, restarting replica 2 recreates eight NeMo-RL actors โ four on node 4 and four on node 5 โ even though seven of them never held a model. They have to come back because they hold the bundles that vLLM's own workers are placed onto.
Every step of a restart, in order, with the code that does it.
| # | What happens | Code |
|---|---|---|
| 1 | The fleet-probe pump calls tick() after each probe round |
single_controller.py:2283 |
| 2 | tick() starts a restart for every shard that needs one, and returns |
engine_supervisor.py:69 |
| 3 | Picks shards that are DEAD and not already being restarted |
engine_supervisor.py:79 |
| 4 | mark_restarting โ RESTARTING, which also spends one attempt from the budget |
engine_supervisor.py:91 |
| 5 | Spawns a background task โ this is what makes it asynchronous | engine_supervisor.py:101 |
| 6 | Hands the blocking work to a thread so the event loop stays free | engine_supervisor.py:114 |
| 7 | restart_shard looks up the replica's first worker |
vllm_generation.py:715 |
| 8 | Loops over all model_parallel_size workers โ the 8 in the figure |
vllm_generation.py:717 |
| 9 | Kills the old actor first, so it cannot still be holding the GPU | worker_groups.py:727 |
| 10 | Gives the replacement a fresh name, because a dead actor's name lingers in the GCS | worker_groups.py:736 |
| 11 | Replays the recorded creation call โ same placement group, same bundle | worker_groups.py:740 |
| 12 | Creates the actor. Ray returns a handle straight away, before it is placed | worker_groups.py:227 |
| 13 | Swaps the new handle into the worker group in place | worker_groups.py:749 |
| 14 | load_model, but only on the deferred-load path |
vllm_generation.py:744 |
| 15 | post_init on the owner rebuilds the engine โ vLLM then makes its own 8 workers |
vllm_generation.py:748 |
| 16 | Asks the new engine for its base URL โ a new port, not the old one | vllm_generation.py:752 |
| 17 | mark_loaded records that URL and moves the shard to STALE |
engine_supervisor.py:128 |
| โ | If any of that raises, mark_restart_failed puts it back to DEAD to be retried |
engine_supervisor.py:121 |
Steps 9 to 13 are the only ones that touch Ray actors. There is no step that reaches into vLLM to restart a tensor-parallel rank: those workers are vLLM's, made when the engine is built at step 15, so they return as a consequence rather than as an instruction.
Ray can do this for you: max_restarts=-1 makes it recreate an actor forever.
NeMo-RL uses that in several places โ the environment actors, and the
generation router.
It is deliberately not used for generation workers, which take Ray's default of
0 โ "the actor doesn't need to be restarted". That is what makes a
RayActorError conclusive: nothing is going to bring that actor back on its own, so
the probe can go straight to DEAD without waiting to see if it recovers.
The router's own docstring says why the difference exists:
That is exactly a generation worker. Ray would re-run __init__ and stop there โ
no post_init, no URL report, no place in the refit collective, and no way for the
ledger to learn that any of it happened. You would get an actor that looks alive and serves
nothing. Steps 14 to 17 in the table above are precisely the work Ray would skip, which is why
the restart has to be driven by the application.
How far up does it restart? Up to and including every Ray actor NeMo-RL made for that one replica โ all 8 in this example, on both nodes โ and no further. The cluster, the unified placement group, its 24 bundles, the worker group and the other two replicas are all reused untouched. That is the point: a dead actor does not release its bundle, so the replacement lands on the same GPUs.
The one piece that can survive the kill. vLLM runs its EngineCore as an
ordinary child process โ no setsid, not a daemon โ and every cleanup path vLLM and
Ray provide runs inside the process being killed. So a SIGKILLed worker leaves the
EngineCore behind, still holding its GPU, and the replacement then has nowhere to load. That is
what RAY_process_group_cleanup_enabled=1 is for: it puts each worker in its own
process group so the raylet can kill the leftovers. It has to be set before
ray start, because the raylet reads it โ setting it in the driver does nothing on a
multi-node job, which is exactly the shape this page is about.