What a shard restart can and cannot do when a whole node dies

Companion illustration for NVIDIA-NeMo/RL #3592. Permalinks pin the PR head 5b4a732; Ray behaviour is quoted from the pinned version, 2.56.1.

Seven pages on this PR, one question each โ€” this one: the failure the restart is not built for.

The restart puts the replacement actors back on the same placement-group bundle the dead ones held. That is deliberate โ€” Ray does not release a bundle when an actor dies, so the replacement lands on the same GPU. It also means the restart is only ever as good as the bundle. If the engine died, the bundle is still there and the restart works. If the node died, the bundle went with it, and everything below follows from that.

Where a replacement bundle would have to go

A job with 2 NVLink domains and segment_size: 3, so nodes are handed out in complete 3-node segments. Training takes one segment, generation takes two โ€” 9 nodes, which is what the job asked for, so nothing is spare. Node 7 dies.

2 NVLink domains ยท segment_size = 3 ยท 3 training nodes + 6 generation nodes = 9 ยท 0 free NVLink domain A node 0 node 1 node 2 node 3 node 4 node 5 NVLink domain B node 6 node 7 node 8 dies Ray tries to move node 7's bundles somewhere else. It needs a node that is: 1. free โ€” but all 9 nodes are already held by training or generation 2. in NVLink domain B โ€” the bundle carries that domain as a resource, so domain A cannot take it Neither is available, so the bundles are never replaced. Ray 2.56.1, placement-group fault tolerance: "If the autoscaler can't provide additional resources or if you're not using the autoscaler, the placement group remains in the partially created state indefinitely." training generation dead the domain constraint on each bundle

What the restart does then

It does not fail. It stops, quietly, in a place with no timeout. Reading the calls in order:

recreate_worker(8) ray.kill(old) # already gone with the node create_worker(pg, bundle_idx=8) # .remote() returns a handle straight away โ€” # Ray does not wait to see if it can be placed -> returns a handle to an actor that will never start post_init_async(leader) ray.get(...) # blocks. the actor is still PENDING_CREATION, # so this call has nothing to wait on but forever

Ray creates actors asynchronously: create_worker ends in .remote() and hands back a handle whether or not the bundle can be scheduled. So the restart gets all the way to post_init before anything notices, and that ray.get has no deadline.

Three things follow, and none of them is loud:

There are no spare nodes, and nothing plans for them. The only "spare" in the codebase is a pool of spare prompts for the data plane. At the cluster level the job asks for exactly train_nodes + inference_nodes, and whatever is left over is handed to the teacher model rather than held back. So on a fixed allocation with no autoscaler โ€” a Slurm job โ€” a lost node is a lost node.

Making node loss survivable is a real feature, not a tweak: you would have to allocate spare nodes up front, keep them out of every cluster, and pick a replacement inside the same NVLink domain when segment_size is set. Worth saying plainly that today the restart handles a dead engine, not a dead machine.