Companion illustration for
NVIDIA-NeMo/RL #3592.
Permalinks pin the PR head 5b4a732; stdlib quotes are CPython 3.13.
Seven pages on this PR, one question each — this one: why the choice of thread matters.
Restarting an engine means blocking Ray calls that know nothing about asyncio, so the control
loop cannot simply await them. Both ways of handling that put the work on a real thread, and
both leave the event loop completely free — the rollout pump and the watchdog keep
running either way. They differ only in what happens to that thread when the restart never
finishes. Today the supervisor uses
asyncio.to_thread;
the trainer side of the same codebase already refuses to, and
starts its own daemon thread instead.
to_thread | dedicated daemon thread | |
|---|---|---|
| Event loop blocked? | No | No — identical |
| Thread comes from | the shared default pool, capped at min(32, cpus+4) | created for this one call |
| Daemon? | No — the process waits for it at exit | Yes — the process does not |
| Wait bounded? | not as written | wait_for(budget_s) |
| A hang costs | a pool slot forever, and shutdown | one thread, and a recorded failure |
The obvious repair is await asyncio.wait_for(asyncio.to_thread(...), 900). It does
bound the wait. It does not fix anything else, because a thread in Python cannot be
cancelled. The timeout abandons the await; the thread keeps running
restart_shard for as long as it wants. So one pool slot is gone for the rest of the
run, and the thread is still not a daemon, so shutdown still waits for it.
That is the whole reason the fix is three things at once: dedicated (does not eat a shared slot), daemon (cannot hold up exit), and bounded (the shard is marked failed instead of vanishing).
What "daemon" actually means. Python does not exit when your code finishes. It
first runs threading._shutdown, whose own comment reads "Wait for all non-daemon
threads to exit." So the flag answers exactly one question: does the process wait for this
thread before exiting? daemon=True means no — the interpreter exits and the thread
dies wherever it happens to be.
And the join really is unbounded. concurrent.futures.thread registers an exit hook
that puts a stop-sentinel on each worker's queue and then calls t.join() with no
timeout. A worker only checks that queue between work items, so a thread stuck inside
restart_shard never sees the sentinel and the join never returns.
Why this matters for a restart specifically. A model reload takes minutes, and the restart is aimed at a shard that may be on a node that is never coming back — so "this call might never return" is the expected case, not the unlucky one. That makes it the worst call in the feature to run on a shared, non-daemon, unbounded thread. The cost is a training job that has already given up on a shard but cannot put its GPUs down.