Repository navigation
[skyrl-train][bugfix] Run the sampler weight broadcast off the event loop - #2524
Conversation
…loop save_weights_for_sampler ran the broadcast's blocking ray.get inline in an async def, freezing the trainer's event loop for the whole transfer. Under the fully-async trainer the generator coroutines share that loop, so environment and tool work stalled for the transfer and any timeout armed just before it fired on the first tick after. Run the transfer in a worker thread. The thread is shielded: a cancellation arriving mid-transfer waits for it to finish before propagating, so the resume bracket never resumes generation onto partially transferred weights (the blocking version could not be cancelled mid-transfer either). Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Dominic Yurk <[email protected]>
There was a problem hiding this comment.
Code Review
This pull request refactors the weight transfer logic in WorkerDispatch to run asynchronously, offloading the blocking transfer operations to a worker thread using asyncio.to_thread and protecting them from premature cancellation with asyncio.shield. Unit tests are also added to verify that the event loop is not blocked and that cancellation properly waits for the transfer to complete. The feedback suggests improving exception handling during cancellation by chaining the underlying exception to asyncio.CancelledError to preserve the traceback for easier debugging.
| if cancelled: | ||
| if transfer.exception() is not None: | ||
| logger.warning(f"Weight transfer failed while cancelled: {transfer.exception()!r}") | ||
| raise asyncio.CancelledError |
There was a problem hiding this comment.
When the weight transfer task fails with an exception while the outer task is cancelled, raising a bare asyncio.CancelledError and logging a warning suppresses the exception traceback from the exception chain. To preserve the traceback and make debugging easier, we should chain the exception using from exc when raising asyncio.CancelledError.
| if cancelled: | |
| if transfer.exception() is not None: | |
| logger.warning(f"Weight transfer failed while cancelled: {transfer.exception()!r}") | |
| raise asyncio.CancelledError | |
| if cancelled: | |
| exc = transfer.exception() | |
| if exc is not None: | |
| raise asyncio.CancelledError("Weight transfer failed while cancelled") from exc | |
| raise asyncio.CancelledError |
There was a problem hiding this comment.
Addressed in ab16cf4: the cancellation is now raised from transfer.exception(), and the warning-only branch is gone.
[Medium impact] The change affects sampler weight sync across trainer workflows. Restore the KV cache before cancellation cleanup resumes requests before merging. Findings
|
| await self._inference_engine_client.sleep_for_weight_sync(offload_kv=offload_kv) | ||
| await self._inference_engine_client.wake_for_weight_sync(tags=["weights"]) | ||
| _broadcast_and_finish() | ||
| await _broadcast_and_finish() |
There was a problem hiding this comment.
Requests resume before KV returns
With fully_async.enabled and offload_kv_for_weight_sync enabled, cancellation during this await waits for the broadcast, then raises before wake_for_weight_sync(tags=["kv_cache"]). Cleanup calls only resume_generation, so requests resume while their KV cache memory is still asleep. Those requests can fail.
Restore the KV cache before resuming generation on this cancellation path, and add a test covering that order.
Knowledge Base Used: Weight synchronization
There was a problem hiding this comment.
Fixed in ab16cf4. The gap was already on main for a failed broadcast: an exception skipped the KV wake the same way. The KV wake is now its own cleanup_preserving_primary bracket inside the resume bracket, mirroring the synchronous-trainer path. test_offload_kv_wakes_kv_cache_before_resuming covers both the failure and the cancellation case.
| while not transfer.done(): | ||
| try: | ||
| await asyncio.shield(transfer) | ||
| except asyncio.CancelledError: | ||
| cancelled = True |
There was a problem hiding this comment.
Transfer errors replace cancellation
If cancellation arrives while transfer is running and the transfer then fails, the next await asyncio.shield(transfer) raises that failure directly. Only CancelledError is caught, so the code skips the branch meant to log the failure and preserve cancellation. The caller gets the broadcast error instead.
Catch the transfer error while waiting for the task to finish, then choose which error to raise after the loop. Add a test that cancels first and makes the broadcast fail afterward.
There was a problem hiding this comment.
Fixed in ab16cf4. The wait loop now uses asyncio.wait({transfer}), which never raises the transfer's error, so a cancellation is always re-raised after the transfer finishes, chained to the transfer's error. Covered by test_cancellation_survives_a_failed_transfer.
…rrupted sync On the fully-async offload-KV path, a broadcast that failed or was cancelled skipped wake_for_weight_sync(kv_cache), and the resume cleanup then scheduled the frozen requests onto a sleeping KV cache. Bracket the KV wake with cleanup_preserving_primary inside the resume bracket, as the synchronous-trainer path already does for its wake_up(kv_cache). Also wait on the transfer with asyncio.wait rather than shield: a transfer that failed after a cancellation arrived escaped the wait loop and replaced the cancellation. The cancellation now propagates, chained to the transfer's error. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Dominic Yurk <[email protected]>
SumanthRH
left a comment
There was a problem hiding this comment.
Thanks! I was able to reproduce the issue highlighted in the PR with a simulated setup with fully async RL for the multi-turn GSM8K task (50 generation workers). I made each env step in the generator's agent loop first make an HTTP request to a dummy env server, which always replies in ~2 s, with a 10 s timeout that isn't caught (like your custom generator):
async with session.post(dummy_env_server, timeout=aiohttp.ClientTimeout(total=10)) as resp:
await resp.read()To get a long weight transfer, I patched _broadcast_to_inference_engines to time.sleep(50) before the actual broadcast. Without the PR, the sleep runs on the event loop thread, and with the PR it runs in the to_thread worker.
Without the PR, the loop stalled for ~51 s, and right after the transfer finished, 54 in-flight env requests timed out at once (each had waited ~51 s for a ~2 s reply). The run died with TimeoutError ... raised in background generation worker. With the PR, all 10 steps (10 x 50 s syncs) completed with 0 timeouts in ~2k env requests. In-flight env requests finish during the sync, and the trajectories then just wait on generation until it resumes.
|
SkyRL-GPU-Megatron CI failure is unrelated: https://github.com/NovaSky-AI/SkyRL/actions/runs/40604113857/job/122539229148?pr=2524 |
|
I did a manual run of all the relevant GPU CI tests for the PR:
|
What does this PR do?
Moves the sampler weight transfer in
WorkerDispatch.save_weights_for_sampleroff the trainer's event loop.TLDR:
_broadcast_and_finish()is a blockingray.getcalled inline from anasync def. Under the fully-async trainer, that freezes every generator coroutine on the loop for the whole transfer. It now runs throughasyncio.to_thread, and a cancellation waits for it, so generation cannot resume mid-transfer. The offload-KV path also wakes the KV cache before resuming after a failed or cancelled sync.Why
save_weights_for_samplercall_broadcast_and_finish()synchronously._broadcast_to_inference_enginesisdeadline.ray_get(...)over the policy workers, so the event loop is blocked for the whole broadcast._run_generate_for_a_group_loopworkers, and whatever HTTP clients and environments they drive, run on that same loop. The engines are paused during the sync, but non-engine work (environment steps, tool calls, reward computation) stalls with them, so the overlap the fully-async trainer is meant to provide is lost for the length of the transfer. For a large MoE policy we measuredtiming/sync_weights_only_transferat 38–43 s on every step.finish_session. In our case it was a custom generator's 10 s HTTP read. The resultingTimeoutErrorbecame the generator'sBackgroundFailure, was re-raised at the next_drain_next_group, and killed the run. That failure is timing-dependent, so it can strike on any step once the transfer is longer than the shortest in-flight timeout.Second-order effect seen with uvloop 0.22.1
The cancelled
create_connection(sock=...)behind that timeout hit MagicStack/uvloop#738, a double close of a recycled fd. The next few generator groups then failed withRuntimeError('File descriptor N is used by transport ...'). That bug is fixed in uvloop 0.23.0. This PR removes the freeze that triggered it.Solution
_broadcast_and_finishis nowasync. It runs the broadcast plus_finish_weight_syncviaasyncio.to_threadand is awaited at every call site. The pause, sleep, wake and resume brackets around it, their ordering, and the trailingincrement_weight_version()are unchanged.pause_generationin KEEP mode, or sleep/wake), not by the frozen driver loop. Generators that run outside the driver process already send requests during a sync. Only the trainer coroutine issues engine control calls, and it still awaits the sync before moving on.asyncio.waiton the thread's future) and then propagates, chained to the transfer's error if it failed. Without that wait,cleanup_preserving_primary(resume_generation)would resume generation on the NCCL and offload-KV paths while the policy workers were still broadcasting. The blocking version could not be cancelled mid-transfer at all, and this keeps that guarantee.step_deadlinestate is ContextVar-based andto_threadcopies the context, so the threadeddeadline.ray_getstill getsremaining()as its timeout. On expiry,asyncio.timeoutcancels the awaiting task and the thread'sray.gettimes out at the same instant, so the wait adds nothing. The await is wrapped indeadline.operation("broadcast_to_inference_engines"), so aStepTimeoutErrornames the same operation whichever side fires first.ray.get, both thread-safe).last_weight_sync_secondsis set after the await. The colocated_finish_weight_sync→_offloadpath is likewiseray.getonly.main). On the fully-async offload-KV path, a failed or cancelled broadcast skippedwake_for_weight_sync(kv_cache), and the resume cleanup then scheduled the frozen requests onto a sleeping KV cache. The KV wake is now its owncleanup_preserving_primarybracket inside the resume bracket, as the synchronous-trainer path already does forwake_up(kv_cache).Test plan
New CPU tests in
TestSaveWeights, with a faketime.sleepbroadcast:test_broadcast_does_not_block_event_loopuses a 0.5 s broadcast. It arms a 0.2 sasyncio.timeoutaround a 0.05 s sleep while the sync runs, then asserts that the timeout did not fire, that the sync was still in progress, and thatlast_weight_sync_seconds >= 0.5. Onmainthe timeout fires, which is the production failure mode.test_cancellation_waits_for_the_transfer_before_resumingcancels the sync during a 0.3 s broadcast and asserts the orderbroadcast_done→resumed. Withto_threadbut no wait it fails with['resumed']. Onmainit fails withDID NOT RAISE, because the blocking sync completes before the cancellation can land.test_cancellation_survives_a_failed_transfercancels, then makes the broadcast fail. It asserts thatCancelledErrorpropagates with theRuntimeErroras its__cause__.test_offload_kv_wakes_kv_cache_before_resuming[failure|cancellation]runs the fully-async offload-KV path and asserts the orderwake_weights→broadcast_done→wake_kv_cache→resumed. Onmainthe failure case resumes without the KV wake.All three fail on this PR's first commit.
The existing
TestSaveWeightscases run unchanged. They cover bracket ordering, resume after a broadcast failure, and the delta, LoRA and colocated paths.RAY_ADDRESS=local)mainb304652uv run --isolated --extra skyrl-train --extra dev pytest tests/backends/skyrl_train/distributed/test_worker_dispatch.py... pytest tests/train/test_fully_async_trainer.py tests/train/test_async_utils.py tests/train/test_trainer.py -m "not vllm"pre-commit run --files <changed>No GPU CI was run. The broadcast itself is unchanged; only the thread it runs on moved.
🤖 Generated with Claude Code