Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
15074e4
feat(single-controller): pause generation during refit
youngeunkwon0405 Aug 31, 2026
21bd668
Merge remote-tracking branch 'origin/main' into youngeunk/single-cont…
youngeunkwon0405 Aug 31, 2026
1f68285
fix(single-controller): enforce refit cache correctness
youngeunkwon0405 Aug 31, 2026
463f247
refactor(single-controller): simplify refit pause lifecycle
youngeunkwon0405 Aug 31, 2026
df75b8c
merge main into single-controller refit pause
youngeunkwon0405 Sep 1, 2026
c6a082b
docs(sc): remove refit timeout guidance
youngeunkwon0405 Sep 1, 2026
40ca90a
Merge branch 'main' into youngeunk/single-controller-refit-pause
youngeunkwon0405 Sep 1, 2026
ae0ca27
Merge remote-tracking branch 'origin/main' into youngeunk/single-cont…
youngeunkwon0405 Sep 2, 2026
efa7099
Merge remote-tracking branch 'origin/main' into youngeunk/single-cont…
youngeunkwon0405 Sep 9, 2026
13fd140
Merge remote-tracking branch 'origin/main' into youngeunk/single-cont…
youngeunkwon0405 Sep 14, 2026
c877a8e
refactor(single-controller): remove duplicate refit warning
youngeunkwon0405 Sep 14, 2026
558ceab
Bound single-controller generation pause and resume waits
youngeunkwon0405 Sep 14, 2026
d8e713d
Merge main and preserve refit pause through shard re-admission
youngeunkwon0405 Sep 15, 2026
bad0cb1
fix: preserve fleet health across refit pause and resume failures
youngeunkwon0405 Sep 15, 2026
8036c3e
Merge main and collect refit resume regression tests in CI
youngeunkwon0405 Sep 19, 2026
72f82b0
Merge main and resolve vLLM test imports
youngeunkwon0405 Sep 21, 2026
75e9352
chore: merge main and preserve generation control hooks
youngeunkwon0405 Sep 28, 2026
cbf5d3b
chore: merge main and preserve streaming PPO refit docs
youngeunkwon0405 Oct 12, 2026
70f3b39
test: cover pause recovery failures and restore timeout guidance
youngeunkwon0405 Oct 12, 2026
7de9e6d
fix: default async refit KV recomputation on
youngeunkwon0405 Oct 16, 2026
a1677bc
chore: refresh main and minimize inherited recipe defaults
youngeunkwon0405 Oct 16, 2026
a5052e4
test: align colocated Megatron smoke test with KV recomputation
youngeunkwon0405 Oct 16, 2026
d001be0
fix: address async KV refit review regressions
youngeunkwon0405 Oct 17, 2026
9c15e56
test: resolve functional recipe paths from repository root
youngeunkwon0405 Oct 17, 2026
064a49e
test: bind refit shard helper in reshard rebuild fixture
youngeunkwon0405 Oct 18, 2026
61dfb29
chore: merge main to satisfy L1 CI freshness
youngeunkwon0405 Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/guides/single-controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,9 @@ The SC path splits the async-GRPO loop across a rollout pump and a train pump th
2. **Actor startup**: `SingleControllerActor` launches `_rollout_pump` and `_train_pump` concurrently as asyncio tasks; both share the same `TQReplayBuffer` and `StalenessSampler`.
3. **Rollout pump loop**: `sampler.admit` gates dispatch against the current trainer version (returning a `target_step` for `in_order`); the pump then reserves a buffer slot, drives `RolloutManager.generate_and_push`, and commits with the observed `start_weight` / `end_weight`.
4. **Train pump loop**: `sampler.evict` removes out-of-window groups, and `sampler.select` chooses the next chunk. For PPO, `_value_stage` computes critic values and `_advantage_stage` computes advantages. TQPolicy then updates the policy: full-batch PPO uses `ppo.ppo_epochs` updates, while streaming PPO accumulates gradients across chunks for one update. Streaming PPO refits the updated policy before `_value_train_epochs` trains the critic on the assembled full batch for `ppo.critic_ppo_epochs`. GRPO uses one TQPolicy optimizer step per RL step.
5. **Weight sync**: after each optimizer step the pump bumps the trainer version, clears rollout permission, calls the weight synchronizer, and re-opens the rollout pump for the next version. In streaming PPO, it does the weight sync as soon as the policy training is finished, which is before the critic model training starts. This allows new batch generation with the updated policy weight overlapping with the critic training epochs.
5. **Weight sync**: after each optimizer step the pump bumps the trainer version, clears rollout permission, asks the generation backend to pause in-flight work, calls the weight synchronizer, resumes generation, and re-opens the rollout pump for the next version. vLLM's native pause preserves active request state; backends without pause support warn and retain their existing refit behavior. In streaming PPO, it does the weight sync as soon as the policy training is finished, which is before the critic model training starts. This allows new batch generation with the updated policy weight overlapping with the critic training epochs.
Comment thread
youngeunkwon0405 marked this conversation as resolved.
Resolved

For non-colocated vLLM, freezing a request does not stop its end-to-end generation timeout. Size `NRL_VLLM_ASYNC_TIMEOUT_SECONDS` and, when the generation router is enabled, `async_rl.generation_router.backend_timeout_s` to cover the longest expected generation plus time spent paused for refit, including membership recovery and a possible retry. With a finite `async_rl.generation_fleet_health.refit_timeout_s`, pause, each transfer attempt, and resume each use that timeout plus a 60 s grace; these are separate call budgets, not a single total refit budget. When `refit_timeout_s` is `null`, pause and resume retain a 360 s budget each, but the collective transfer wait is unbounded, so no finite worst-case total pause duration is guaranteed.

## Relation to Legacy Async GRPO

Expand All @@ -449,7 +451,7 @@ Do not carry `max_num_epochs: -1` across either. [ppo.md](./ppo.md#asynchronous-
| `enabled: true` | Implicit — SC is always async; use `sampler.max_lookahead_versions: 0` for sync semantics, `>= 1` for async |
| `max_trajectory_age_steps: N` | `sampler.name: in_order` with `sampler.max_lookahead_versions: N` |
| `warmup_generation_lead_steps` (PPO) | `sampler.warmup_lookahead_versions` — the lookahead to use while critic warmup is in progress |
| `recompute_kv_cache_after_weight_updates` | `recompute_kv_cache_after_weight_updates` (same) |
| `recompute_kv_cache_after_weight_updates` | `recompute_kv_cache_after_weight_updates` (same); when vLLM pauses natively, the preserved requests recompute their KV cache after refit |
| `in_flight_weight_updates` | Always effectively true; `false`-equivalent behavior is not yet supported (drain-gate tracked in [issue #2625](https://github.com/NVIDIA-NeMo/RL/issues/2625)) |
Comment thread
youngeunkwon0405 marked this conversation as resolved.
Resolved
| *(no legacy equivalent — matches legacy full-batch train semantics)* | `min_groups_for_streaming_train: ${grpo.num_prompts_per_step}`, or `${ppo.num_prompts_per_step}` on a PPO run |
| *(no legacy equivalent — matches legacy `max_trajectory_age + 1` batches in flight)* | `max_inflight_prompts: num_prompts_per_step × (max_lookahead_versions + 1)` |
Expand Down
104 changes: 75 additions & 29 deletions nemo_rl/algorithms/single_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -4247,18 +4247,19 @@ def _recovery_window(self) -> Iterator[None]:
# exhaustion check for the rest of the run.
self._recovering_from_refit = False

async def _recover_from_failed_refit(self, failure: BaseException) -> None:
async def _recover_from_failed_refit(
self, failure: BaseException, *, refit_started: bool
) -> None:
"""Drop whatever stopped participating, rebuild the communicator, allow a retry.

Two failures arrive here and they are not the same event:

``RefitAborted`` -- a rank went silent *inside* the collective and a worker's
watchdog broke it. Every engine that was receiving is left holding a mix of old
and new weights, so none of them may serve until a refit completes.
``RefitAborted`` -- pausing timed out, or a rank went silent inside the
collective and a watchdog broke it. Only the latter leaves receiving engines
holding partial weights; those cannot serve until a refit completes.

``RayActorError`` -- the collective finished and a shard died in the epilogue,
before its RPC returned. Nothing is partial; the survivors have complete weights.
Left uncaught this killed a run whose data transfer had *already succeeded*.
``RayActorError`` -- a shard died before transfer, or in the epilogue after
the collective finished. Nothing is partial; survivors have complete weights.

Both need the same repair, because both leave a communicator that no longer
matches the fleet, and in the abort case no communicator at all.
Expand All @@ -4277,10 +4278,9 @@ async def _recover_from_failed_refit(self, failure: BaseException) -> None:
# 1. Establish who is actually gone, now, rather than on the probe's clock.
await self._probe_generation_fleet()

# 2. Only an abort leaves partial weights behind. Marking survivors stale after
# a completed broadcast would pull a healthy fleet out of service over a
# transfer that succeeded.
if isinstance(failure, RefitAborted):
# 2. Only an interrupted transfer leaves partial weights. A pause failure
# before the transfer, like a death after it completed, does not.
if refit_started and isinstance(failure, RefitAborted):
for shard_idx in self._gen_fleet.serving_shards():
self._gen_fleet.mark_weights_partial(shard_idx)

Expand Down Expand Up @@ -5343,6 +5343,8 @@ async def _save_checkpoint_impl(
# and have Ray deliver it -- seconds, not minutes. If this fires first we lose their
# diagnosis and report only "the refit never came back", which is true but less useful.
_REFIT_UNWIND_GRACE_S = 60.0
# Pause/resume remain bounded even when the collective watchdog is disabled.
_GENERATION_CONTROL_TIMEOUT_S = 360.0

def _refit_await_budget_s(self) -> Optional[float]:
"""How long to wait for the refit before giving up, or None to wait forever."""
Expand Down Expand Up @@ -5418,18 +5420,23 @@ async def _sync_weights(
*,
calibration_data: Optional[BatchedDataDict[Any]] = None,
) -> int:
"""Pause new rollout dispatches, synchronize weights, resume.
"""Pause rollout dispatch and generation, synchronize weights, resume.

SC owns the pause gate. vLLM serves through the refit; it supports live weight updates.
A colocated engine is instead already stood down: the synchronizer's sync is the wake,
and step 4 resumes dispatch.
SC owns the rollout-dispatch gate and asks every generation backend to
pause in-flight work through the common refit lifecycle hooks. Backends
without native pause support warn and retain their existing in-flight
update behavior. Continuous-serving vLLM preserves request state while
paused. A colocated engine is already stood down before training; the
synchronizer's sync remains its weight-carrying wake.

Flow:
1. _rollout_permitted.clear() — no new dispatches
2. Optionally calibrate FP8 KV-cache scales.
2. Reconcile refit membership and optionally calibrate FP8 KV-cache scales.
3. Materialize deferred policy parameter all-gathers.
4. weight_synchronizer.sync_weights(kv_scales=...)
5. _rollout_permitted.set() — resume
4. generation.pause_generation_for_refit(clear_cache=...)
5. weight_synchronizer.sync_weights(kv_scales=...)
6. generation.resume_generation_after_refit()
7. _rollout_permitted.set() — resume dispatch

Args:
calibration_data: Optional data used to calibrate FP8 KV-cache
Expand Down Expand Up @@ -5493,7 +5500,25 @@ async def _sync_weights(
with self._timer.time("prepare_for_generation/sync_policy_params"):
await asyncio.to_thread(self._trainer.sync_params_before_refit)

clear_cache = self._async_cfg.recompute_kv_cache_after_weight_updates
timeout_s = self._refit_await_budget_s()
Comment thread
youngeunkwon0405 marked this conversation as resolved.
Resolved
if timeout_s is None:
timeout_s = self._GENERATION_CONTROL_TIMEOUT_S
generation_paused_for_refit = False
refit_started = False
print("⏸️ Requesting generation pause before refit", flush=True)
try:
generation_paused_for_refit = await asyncio.to_thread(
self._gen.pause_generation_for_refit,
clear_cache=clear_cache,
timeout_s=timeout_s,
)
if generation_paused_for_refit:
print(
f" {len(self._inflight_by_group_id)} in-flight rollout group(s) paused",
flush=True,
)
refit_started = True
await self._sync_weights_within(kv_scales, "first")
except (RefitAborted, RayActorError) as failure:
Comment thread
youngeunkwon0405 marked this conversation as resolved.
Resolved
# DETECT AND FAIL FAST, because this one cannot be recovered from.
Expand All @@ -5520,12 +5545,17 @@ async def _sync_weights(
)
raise
with self._recovery_window():
await self._recover_from_failed_refit(failure)
# Re-read: the recovery condemns the silent participant and rebuilds over
# the survivors, so the retry's membership is not the first attempt's. This
# is the likelier of the two windows -- the shard is restarting precisely
# because this refit just failed.
await self._recover_from_failed_refit(
failure, refit_started=refit_started
)
participants = self._refit_participants()
# A restarted engine may have joined the rebuilt membership without
# receiving the initial pause, even if it reuses the same shard index.
generation_paused_for_refit = await asyncio.to_thread(
self._gen.pause_generation_for_refit,
clear_cache=clear_cache,
timeout_s=timeout_s,
)
# Once only: a second failure is a real fault, not a membership problem,
# and retrying forever would recreate the wedge this exists to remove.
await self._sync_weights_within(kv_scales, "retry")
Expand All @@ -5539,12 +5569,28 @@ async def _sync_weights(
# promoted inside its window, and everything below this must still run on
# both paths.
self._record_refit_landed(participants)
if self._async_cfg.recompute_kv_cache_after_weight_updates:
# to_thread, like every other call into the workers here. Run directly on
# the loop this is a blocking Ray call, and a wedged generation worker would
# freeze the event loop itself -- taking the watchdog, which is an asyncio
# task on that same loop, down with it.
await asyncio.to_thread(self._gen.invalidate_kv_cache)

print("▶️ Requesting generation resume after refit", flush=True)
generation_resumed_after_refit = await asyncio.to_thread(
Comment thread
youngeunkwon0405 marked this conversation as resolved.
Resolved
self._gen.resume_generation_after_refit, timeout_s=timeout_s
)
if generation_paused_for_refit and not generation_resumed_after_refit:
raise RuntimeError(
"Failed to resume generation after a successful refit pause"
)

if clear_cache and not generation_paused_for_refit:
Comment thread
youngeunkwon0405 marked this conversation as resolved.
Resolved
# A backend that paused natively already received clear_cache=True at pause
# time. Other backends must explicitly confirm equivalent engine-side cache
# handling; merely clearing reusable prefix entries does not invalidate KV
# blocks still owned by running requests.
invalidated = await asyncio.to_thread(self._gen.invalidate_kv_cache)
if not invalidated:
raise NotImplementedError(
"recompute_kv_cache_after_weight_updates=True requires the "
"generation backend to clear active-request KV state during a "
"native refit pause or confirm equivalent engine-side handling"
)
elapsed = time.monotonic() - t0

print(f" _sync_weights: sync done in {elapsed:.3f}s", flush=True)
Expand Down
5 changes: 3 additions & 2 deletions nemo_rl/algorithms/single_controller_utils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,8 +318,9 @@ class FleetHealthConfig(BaseModel, extra="allow"):
# with RefitAborted -- still far better than hanging forever inside NCCL, but choose
# the deadline knowing there is no second chance.
#
# None disarms it: no watchdog thread is started and the refit path is byte-identical
# to before. Set it well above a healthy refit, because the cost of firing early is
# None disables the collective watchdog and controller's transfer deadline.
# Generation pause/resume still have a 360s timeout. Set this well above a healthy
# refit, because the cost of firing early is
# aborting a run that was merely slow, while the cost of firing late is only that a
# wedge lasts longer before it is broken.
#
Expand Down
29 changes: 23 additions & 6 deletions nemo_rl/models/generation/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -647,12 +647,22 @@ def set_rollout_weight_version(self, version: int) -> None:
f"token_capture.enabled is not supported for {type(self).__name__}"
)

# Optional hook; backends may override to invalidate any reusable caches
# (e.g., vLLM prefix/KV caches) after weight updates.
def invalidate_kv_cache(self) -> bool:
"""Confirm stale KV state cannot be reused after a weight update.

Backends may implement this by invalidating active-request KV state directly or
by performing equivalent engine-side handling atomically with refit. Clearing
only unreferenced prefix-cache entries is insufficient while requests are active.

Returns:
True only when the backend guarantees the recompute contract; False when it
is unsupported or could not be completed.
"""
return False

def pause_generation_for_refit(self, *, clear_cache: bool) -> bool:
def pause_generation_for_refit(
self, *, clear_cache: bool, timeout_s: Optional[float] = None
) -> bool:
"""Pause in-flight generation while preserving request state.

Backends with native in-flight refit support override this hook. The default
Expand All @@ -664,6 +674,7 @@ def pause_generation_for_refit(self, *, clear_cache: bool) -> bool:
Args:
clear_cache: Also clear the engine's reusable caches at pause time so
preserved requests recompute their KV after the weight update.
timeout_s: Maximum time to wait for engines to pause; None waits indefinitely.

Returns:
True if every engine paused; False when the backend has no native pause
Expand All @@ -672,16 +683,22 @@ def pause_generation_for_refit(self, *, clear_cache: bool) -> bool:
_warn_unsupported_in_flight_refit_pause_once(type(self).__name__)
return False

def resume_generation_after_refit(self) -> bool:
def resume_generation_after_refit(
self, *, timeout_s: Optional[float] = None
) -> bool:
"""Resume generation paused by :meth:`pause_generation_for_refit`.

The default implementation shares the once-per-backend warning emitted by
:meth:`pause_generation_for_refit` and lets the refit continue for backends
without native pause/resume support.

Args:
timeout_s: Maximum time to wait for engines to resume; None waits indefinitely.

Returns:
True if every engine resumed; False when the backend has no native resume
support. Backends with native support raise when resuming fails.
True if every surviving engine resumed; False when the backend has no
native resume support. With fleet health, confirmed dead engines may be
excluded if enough survivors remain. Other resume failures raise.
"""
_warn_unsupported_in_flight_refit_pause_once(type(self).__name__)
return False
Expand Down
Loading
Loading