Skip to content

feat(rollout): give the rollout backend a pause for weight sync - #725

Open
karkuspeter wants to merge 2 commits into
nvidia-cosmos:mainfrom
karkuspeter:pkarkus/rollout-backend-pause
Open

karkuspeter wants to merge 2 commits into
nvidia-cosmos:mainfrom
karkuspeter:pkarkus/rollout-backend-pause

Conversation

@karkuspeter

Copy link
Copy Markdown

Stacked on #724 (pkarkus/hold-payload-egress-for-weight-sync), which adds the bracket
this hooks into. Please review that one first.

Summary

Weight sync overwrites the served model's tensors in place, which is only safe while no forward
is running. Nothing enforces that today: the main loop dequeues a command whenever it comes
round, and whether generation happens to be idle at that moment is a property of the backend
rather than anything the sync checks.

RolloutBase.paused() is where a backend that is not idle parks, and the sync brackets itself
with it. The default does nothing, which is correct for an engine whose generation call blocks
the main loop — every in-tree backend today.

Why it matters

Two cases are not idle when a command is dequeued:

  • Stream generation. main_loop calls consume_command on every iteration while
    RolloutTaskScheduler keeps working, so a P2R receive or an R2R broadcast can land on tensors
    an active task is reading.
  • A backend that serves on a thread of its own — an out-of-tree simulator-driven one, where
    generation is a long-running episode rather than a call — never stops on its own account.

The async modes are exempt: they write a buffer clone and install_inference_sync swaps it in at
its own safe point, so the served tensors are never written under a forward.

Open question for a maintainer

RolloutTaskScheduler already has the paused() this needs — added for weight sync, with a
docstring that says so, and never called from anywhere. Wiring it up is the obvious next step and
is left out here deliberately: paused(wait_for_active_tasks=True) waits out active tasks, and
whether that can strand a task whose results main_loop is responsible for collecting needs a
stream-generation run to answer. Happy to add it here if you can tell me it is safe, or to leave
it for someone who can run that mode.

Testing

tests/test_weight_sync_generation_pause.py, CPU-only. A backend that records when it parks
shows the R2R broadcast and the P2R receive both happening inside the park, an async-mode sync
leaving generation running, and the base class's default doing nothing. Added to the pytest line
in tests/run_test.sh.

Validation

  • python -m pytest -q tests/test_weight_sync_generation_pause.py tests/test_weight_sync_payload_egress.py tests/test_weight_sync.py tests/test_ranked_rollout_end_and_wst_fence.py — 56 passed
  • uvx ruff@0.12.7 format --check and uvx ruff@0.12.7 check on the changed files — clean

NCCL gives no guarantee for two communicators at once on one device, so a
data packer that ships payloads over NCCL must have no send in flight
while weight sync uses the device. `flush_pending_sends` drains what is
already in flight, but nothing stops the packer claiming the next payload
the moment it returns, and a sync spends most of its wall time after that
drain, waiting on the barrier for its peers. A payload claimed in that
window lands in the middle of the collective.

`payload_egress_held` prefers a packer's `hold_sends` context manager,
which holds for the whole sync, and falls back to the drain for a packer
that only has one. Both stay optional, so a packer that ships nothing
over this device's NCCL is unaffected. The two command handlers take it
as a decorator, so it covers a lazy engine initialization and the barrier
wait as well as the transfer, and the weight-sync thread takes it in its
run loop, which also brings P2R under the same guarantee.

The in-tree NCCL payload transport implements neither hook today, so it
is still exposed to this: its bounded sender pool accepts a request and
launches `nccl_send` whenever one arrives. Giving `NCCLRolloutMixin` a
`hold_sends` that stops accepting for the duration is the natural
follow-up, and needs its own multi-node validation.
Weight sync overwrites the served model's tensors in place, which is only
safe while no forward is running. Nothing enforces that today: the main
loop dequeues a command whenever it comes round, and whether generation
happens to be idle at that moment is a property of the backend rather
than anything the sync checks.

For an engine whose generation call blocks the main loop it is idle by
construction, which is why `RolloutBase.paused` defaults to doing
nothing. Two cases are not like that. Stream generation feeds a scheduler
that keeps working while the main loop dequeues commands, so a P2R
receive or an R2R broadcast can land on tensors an active task is
reading. And a backend that serves on a thread of its own, as an
out-of-tree simulator-driven one does, never stops on its own account.

`paused` is where such a backend parks, and the sync brackets itself with
it. The async modes are exempt: they write a buffer clone and
`install_inference_sync` swaps it in at its own safe point.

`RolloutTaskScheduler` already has the `paused` this needs, added for
weight sync and never called from anywhere. Wiring it up is the obvious
next step and is left out here deliberately: `paused` waits out active
tasks, and whether that can strand a task whose results the main loop
collects needs a stream-generation run to answer.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant