Skip to content

[skyrl-train][bugfix] Run the sampler weight broadcast off the event loop - #2524

Merged
SumanthRH merged 2 commits into
NovaSky-AI:mainfrom
dyurk-lila:dyurk/weight-sync-off-event-loop
Oct 29, 2026
Merged

SumanthRH merged 2 commits into
NovaSky-AI:mainfrom
dyurk-lila:dyurk/weight-sync-off-event-loop

Conversation

@dyurk-lila

@dyurk-lila dyurk-lila commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Moves the sampler weight transfer in WorkerDispatch.save_weights_for_sampler off the trainer's event loop.

TLDR: _broadcast_and_finish() is a blocking ray.get called inline from an async def. Under the fully-async trainer, that freezes every generator coroutine on the loop for the whole transfer. It now runs through asyncio.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

  1. All six call sites in save_weights_for_sampler call _broadcast_and_finish() synchronously. _broadcast_to_inference_engines is deadline.ray_get(...) over the policy workers, so the event loop is blocked for the whole broadcast.
  2. In the fully-async trainer, the _run_generate_for_a_group_loop workers, 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 measured timing/sync_weights_only_transfer at 38–43 s on every step.
  3. A timeout armed by a coroutine just before the freeze fires on the first tick after it, even though the awaited operation was healthy. In-tree, that includes the 10 s bound on finish_session. In our case it was a custom generator's 10 s HTTP read. The resulting TimeoutError became the generator's BackgroundFailure, 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 with RuntimeError('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_finish is now async. It runs the broadcast plus _finish_weight_sync via asyncio.to_thread and is awaited at every call site. The pause, sleep, wake and resume brackets around it, their ordering, and the trailing increment_weight_version() are unchanged.

  • Engine safety is unchanged. Requests are kept off half-updated weights by the engine-side bracket (pause_generation in 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.
  • Cancellation. A cancellation that arrives mid-transfer, for example on shutdown, waits for the transfer to finish (asyncio.wait on 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.
  • Deadlines. step_deadline state is ContextVar-based and to_thread copies the context, so the threaded deadline.ray_get still gets remaining() as its timeout. On expiry, asyncio.timeout cancels the awaiting task and the thread's ray.get times out at the same instant, so the wait adds nothing. The await is wrapped in deadline.operation("broadcast_to_inference_engines"), so a StepTimeoutError names the same operation whichever side fires first.
  • Thread safety. The thread makes only Ray calls (actor-method dispatch plus ray.get, both thread-safe). last_weight_sync_seconds is set after the await. The colocated _finish_weight_sync → _offload path is likewise ray.get only.
  • KV cache on an interrupted sync (pre-existing on main). On the fully-async offload-KV path, a failed or cancelled broadcast skipped wake_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 own cleanup_preserving_primary bracket inside the resume bracket, as the synchronous-trainer path already does for wake_up(kv_cache).
  • Not done: per-caller fixes, such as a longer timeout in each HTTP client. Any coroutine on the trainer loop can be hit, and the freeze grows with model size.

Test plan

New CPU tests in TestSaveWeights, with a fake time.sleep broadcast:

  • test_broadcast_does_not_block_event_loop uses a 0.5 s broadcast. It arms a 0.2 s asyncio.timeout around 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 that last_weight_sync_seconds >= 0.5. On main the timeout fires, which is the production failure mode.
  • test_cancellation_waits_for_the_transfer_before_resuming cancels the sync during a 0.3 s broadcast and asserts the order broadcast_done → resumed. With to_thread but no wait it fails with ['resumed']. On main it fails with DID NOT RAISE, because the blocking sync completes before the cancellation can land.
  • test_cancellation_survives_a_failed_transfer cancels, then makes the broadcast fail. It asserts that CancelledError propagates with the RuntimeError as its __cause__.
  • test_offload_kv_wakes_kv_cache_before_resuming[failure|cancellation] runs the fully-async offload-KV path and asserts the order wake_weights → broadcast_done → wake_kv_cache → resumed. On main the failure case resumes without the KV wake.

All three fail on this PR's first commit.

The existing TestSaveWeights cases run unchanged. They cover bracket ordering, resume after a broadcast failure, and the delta, LoRA and colocated paths.

command (CPU, RAY_ADDRESS=local) main b304652 this PR
uv run --isolated --extra skyrl-train --extra dev pytest tests/backends/skyrl_train/distributed/test_worker_dispatch.py 2 failed / 22 passed 27 passed
... pytest tests/train/test_fully_async_trainer.py tests/train/test_async_utils.py tests/train/test_trainer.py -m "not vllm" — 32 passed
pre-commit run --files <changed> — ruff, black, secrets pass

No GPU CI was run. The broadcast itself is unchanged; only the thread it runs on moved.

🤖 Generated with Claude Code

…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]>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +829 to +832
if cancelled:
if transfer.exception() is not None:
logger.warning(f"Weight transfer failed while cancelled: {transfer.exception()!r}")
raise asyncio.CancelledError

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in ab16cf4: the cancellation is now raised from transfer.exception(), and the warning-only branch is gone.

@greptile-apps

greptile-apps Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] The change affects sampler weight sync across trainer workflows.

Restore the KV cache before cancellation cleanup resumes requests before merging.

Findings

  1. P1 Requests resume before KV returns ▶
  2. P2 Transfer errors replace cancellation ▶

Diagram

sequenceDiagram
  participant T as Trainer
  participant W as Transfer thread
  participant E as Inference engines
  T->>E: Pause requests and sleep KV
  T->>E: Wake weights
  T->>W: Start weight transfer
  Note over T: Other coroutines keep running
  T->>T: Receive cancellation
  W-->>T: Transfer finishes
  T->>T: Raise cancellation
  Note over E: KV wake is skipped
  T->>E: Cleanup resumes requests
Loading

Reviews (1) · Last reviewed commit: "[skyrl-train][bugfix] Run the sampler we..." · Reviewed by Greptile

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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +824 to +828
while not transfer.done():
try:
await asyncio.shield(transfer)
except asyncio.CancelledError:
cancelled = True

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 SumanthRH left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@SumanthRH

Copy link
Copy Markdown
Member

@SumanthRH

Copy link
Copy Markdown
Member

I did a manual run of all the relevant GPU CI tests for the PR:

Test file Test Backend Result
test_save_weights_for_sampler.py test_save_weights_for_sampler_then_inference[no_colocate_fsdp] FSDP ✅
test_save_weights_for_sampler_then_inference[colocate_fsdp] FSDP ✅
test_save_weights_for_sampler_multiple_training_steps FSDP ✅
test_offload_kv_weight_sync.py test_offload_kv_weight_sync_preserves_inflight_request FSDP ✅
test_worker_dispatch_offload.py test_colocate_all_only_one_model_on_gpu FSDP ✅
test_gpu_state_tracking_accuracy FSDP ✅
test_colocate_policy_critic_training_switch FSDP ✅
test_dispatch_set_lr[fsdp] FSDP ✅
test_dispatch_set_lr[megatron] Megatron ✅
test_delta_weight_sync_e2e.py test_delta_weight_sync_sparse_update_e2e[fsdp] FSDP ✅
test_delta_weight_sync_sparse_update_e2e[megatron] Megatron ✅

@SumanthRH
SumanthRH merged commit a45caba into NovaSky-AI:main Oct 29, 2026
11 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants