Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9fc2c65
feat(sc): give the refit process group an abort-based lifecycle
asolergi-nv Jul 31, 2026
dd25b90
feat(sc): reconcile refit membership before every weight sync
asolergi-nv Jul 31, 2026
ba70ae2
feat(sc): rebuild the refit communicator over surviving generation sh…
asolergi-nv Jul 31, 2026
25c1eb6
feat(sc): recover the nccl_reshard transport, and skip dead shards in…
asolergi-nv Jul 31, 2026
5df6cc2
test(sc): size the recovery test to the host, and kill an actual shard
asolergi-nv Aug 1, 2026
aed318b
style(sc): satisfy the isort pre-commit hook
asolergi-nv Aug 4, 2026
c41da08
fix(sc): ask Ray which processes its generation actors are
asolergi-nv Aug 4, 2026
c1369eb
fix(sc): make actor discovery report what it found, and query it once
asolergi-nv Aug 5, 2026
62b2e94
test(sc): report why the run ended, and where Ray was looked for
asolergi-nv Aug 5, 2026
2fc1095
fix(sc): attach to Ray via the session dir, and give the run enough s…
asolergi-nv Aug 5, 2026
f13359f
test(sc): stop discarding the helper's stderr during discovery
asolergi-nv Aug 5, 2026
9b784f3
test(sc): bound each discovery attempt, and print its stderr on the p…
asolergi-nv Aug 5, 2026
c6d756d
test(sc): print the discovery helper's stderr inline, not to a side file
asolergi-nv Aug 5, 2026
fe5ddc3
fix(sc): unbuffer the training driver so the harness can see progress
asolergi-nv Aug 5, 2026
089599a
test(sc): dump stacks when the recovery test wedges
asolergi-nv Aug 5, 2026
5161d79
fix(sc): log the refit membership decision on every sync, not just re…
asolergi-nv Aug 5, 2026
4a3d94e
feat(sc): survive a generation rank dying during the refit
asolergi-nv Aug 6, 2026
665af08
test(sc): kill inside the refit window on purpose
asolergi-nv Aug 6, 2026
46760a2
feat(sc): make the refit deadline configurable, and turn it on in the…
asolergi-nv Aug 6, 2026
f44472c
fix(sc): give the ASYNC refit entrypoint the deadline too
asolergi-nv Aug 6, 2026
87b54c2
fix(sc): make the refit recovery actually recover
asolergi-nv Aug 6, 2026
8cd8e56
fix(sc): give the nccl_reshard transport the refit deadline too
asolergi-nv Aug 6, 2026
76867ee
test(sc): accept NoSurvivingShards as a bounded chaos failure
asolergi-nv Aug 7, 2026
e5ebe73
test(sc): make a reshard refit actually abort on hardware
asolergi-nv Aug 10, 2026
60bd8d3
Merge branch 'feat/sc-resiliency-02-fleet-health-router' into feat/sc…
asolergi-nv Aug 10, 2026
b04fe19
test(sc): give the recovery test the same GPU handoff discipline
asolergi-nv Aug 10, 2026
493c081
Merge branch 'feat/sc-resiliency-02-fleet-health-router' into feat/sc…
asolergi-nv Aug 12, 2026
9f905d1
test(sc): give the refit-recovery fixture what _sync_weights now needs
asolergi-nv Aug 12, 2026
d579c11
Merge branch 'feat/sc-resiliency-02-fleet-health-router' into feat/sc…
asolergi-nv Aug 12, 2026
e092837
Merge branch 'feat/sc-resiliency-02-fleet-health-router' into feat/sc…
asolergi-nv Aug 12, 2026
1910b5e
Merge branch 'feat/sc-resiliency-02-fleet-health-router' into feat/sc…
asolergi-nv Aug 12, 2026
67580f0
Merge branch 'feat/sc-resiliency-02-fleet-health-router' into feat/sc…
asolergi-nv Aug 12, 2026
b35aa0b
Merge branch 'feat/sc-resiliency-02-fleet-health-router' into feat/sc…
asolergi-nv Aug 12, 2026
b9608af
Merge branch 'feat/sc-resiliency-02-fleet-health-router' into feat/sc…
asolergi-nv Aug 12, 2026
c2c51bd
Merge branch 'feat/sc-resiliency-02-fleet-health-router' into feat/sc…
asolergi-nv Aug 12, 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
152 changes: 148 additions & 4 deletions nemo_rl/algorithms/single_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@

import ray
import torch
from ray.exceptions import RayActorError

from nemo_rl.algorithms.async_utils.staleness_sampler import create_sampler
from nemo_rl.algorithms.metric_utils import SetupTimingMetrics
Expand All @@ -62,8 +63,10 @@
from nemo_rl.data_plane import KVBatchMeta
from nemo_rl.data_plane.schema import DP_CALIB_INPUT_FIELDS
from nemo_rl.distributed.batched_data_dict import BatchedDataDict
from nemo_rl.distributed.refit_watchdog import RefitAborted
from nemo_rl.experience.failures import RolloutStall
from nemo_rl.experience.rollout_manager import RolloutOutcome
from nemo_rl.models.generation.fleet_health import ShardState
from nemo_rl.models.generation.sglang.sglang_generation import SGLangGeneration
from nemo_rl.models.generation.vllm import VllmGeneration
from nemo_rl.models.policy.tq_policy import TQPolicy
Expand Down Expand Up @@ -772,6 +775,14 @@ async def probe(shard_idx: int) -> None:
self._ray_get(worker_group.workers[worker_idx].is_alive.remote()),
timeout=fleet_cfg.probe_timeout_s,
)
except RayActorError as error:
# Conclusive, unlike a timeout: Ray only reports this once the actor
# process is actually gone. Counting it as one more ambiguous failure
# would delay the verdict by unhealthy_threshold intervals for no gain,
# and the refit deadline can expire inside that delay.
self._fleet_monitor.record_actor_death(
shard_idx, error=f"{type(error).__name__}: {error}"
)
except (Exception, asyncio.TimeoutError) as error:
self._fleet_monitor.record_probe(
shard_idx, ok=False, error=f"{type(error).__name__}: {error}"
Expand Down Expand Up @@ -806,6 +817,112 @@ async def _push_router_membership(self) -> None:
)
self._pushed_membership_epoch = epoch

async def _reconcile_refit_membership(self) -> bool:
"""Ask the weight transport to match the live fleet before the refit runs.

A no-op without fleet health: with no monitor there is no notion of a shard being
gone, so the transport keeps the membership it was built with -- which is the
pre-existing behaviour, and why this is inert by default.

Returns whether the communicator was actually rebuilt. The recovery path needs
that answer: after an abort the old communicator is gone, so "nothing to
reconcile" means there is nothing to retry with either.
"""
if self._fleet_monitor is None:
return False
absent = self._fleet_monitor.absent_shards()
# to_thread like every other call that reaches the workers: this can rebuild
# communicators via blocking Ray calls, and running it on the loop would freeze
# the watchdog, which is an asyncio task on that same loop.
rebuilt = await asyncio.to_thread(
self._weight_synchronizer.reconcile_communicator, absent
)
# Log unconditionally, not just on a rebuild.
#
# Job 5898311 wedged with both policy workers stuck in the refit broadcast and no
# rebuild logged, and "no rebuild logged" could not distinguish two causes needing
# opposite fixes: reconcile ran BEFORE the death was recorded (absent empty, so
# correctly did nothing, and the race is the problem), or it ran after and
# reconcile_communicator wrongly returned False. The absent set is the whole
# difference and it was not being printed.
print(
f" _sync_weights: refit membership absent={sorted(absent)} rebuilt={rebuilt}",
flush=True,
)
return rebuilt

async def _recover_from_failed_refit(self, failure: BaseException) -> 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.

``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*.

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.

The probe here is the point. Waiting for the health monitor to reach its own
conclusion is what failed before: its verdict is paced by probe rounds while this
is an event, so the abort arrived first and the rebuild saw an empty absent set
and did nothing. Asking now, on this thread, turns a race into a lookup.
"""
print(f" _sync_weights: {failure}; rebuilding and retrying once", flush=True)
if self._fleet_monitor is None:
# Without fleet health there is no notion of a shard being gone and nothing
# to rebuild against. Failing here is the pre-existing behaviour.
raise failure

# 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):
for shard_idx in self._fleet_monitor.serving_shards():
self._fleet_monitor.mark_weights_partial(shard_idx)

# 3. Rebuild without the dead.
rebuilt = await self._reconcile_refit_membership()
if not rebuilt:
# Nothing was identified as absent, so there is no smaller membership to
# rebuild over. Retrying would either die on the aborted communicator or --
# worse -- rebuild over the full fleet and hang on the same silent rank,
# which is the wedge this whole path exists to remove. Fail attributably.
raise RuntimeError(
"refit failed and no generation shard could be identified as absent, so "
"the communicator cannot be safely rebuilt; the run cannot continue. A "
"rank that is alive but not participating would produce this."
) from failure

def _promote_refit_shards(self) -> None:
"""Return shards holding current weights to the serving set.

The exit from STALE, and the reason marking partial weights is safe rather than
terminal. An aborted refit leaves every engine that was receiving with a mix of
old and new weights, so they are pulled out of service -- but nothing else moves
a shard out of STALE, so without this the recovery would succeed and then leave
the fleet empty, which ``raise_if_exhausted`` would end the run over. A worse
failure than the one being recovered from, and reached only on the recovery path.

Only STALE shards are promoted. A SUSPECT shard also took part in the refit, but
it is failing probes for its own reasons and promoting it here would reset the
failure count that is supposed to condemn it.
"""
if self._fleet_monitor is None:
return
for health in self._fleet_monitor.snapshot():
if health.state is ShardState.STALE:
self._fleet_monitor.report_refit(
health.dp_shard_idx, weight_version=self._trainer_version
)

async def _check_env_health(self, timeout_s: float) -> list[str]:
"""Ask each environment actor that exposes a health check whether it is whole.

Expand Down Expand Up @@ -920,6 +1037,13 @@ async def _sync_weights(

# TODO(#2625): Add drain-gate support during refit.

# Reconcile before the refit, not on a death event. The refit group is provably
# idle here and every rank is synchronized, which is required because the
# operations that change membership are themselves collectives. Doing it every
# time is also idempotent, so a missed or reordered health update converges on
# the next step instead of needing replay.
await self._reconcile_refit_membership()

t0 = time.monotonic()
kv_scales = None
if (
Expand All @@ -934,10 +1058,30 @@ async def _sync_weights(
)
kv_scales = calibration_result["layers"]

await asyncio.to_thread(
self._weight_synchronizer.sync_weights,
kv_scales=kv_scales,
)
# Reconcile once more, immediately before the collective.
#
# The reconcile above runs before calibration and two to_thread hops; a death
# recorded in that gap would otherwise be ignored until the NEXT step, by which
# time this broadcast is already hanging on the missing rank. Idempotent, so the
# common case is a no-op.
await self._reconcile_refit_membership()

try:
await asyncio.to_thread(
self._weight_synchronizer.sync_weights,
kv_scales=kv_scales,
)
except (RefitAborted, RayActorError) as failure:
await self._recover_from_failed_refit(failure)
# 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 asyncio.to_thread(
self._weight_synchronizer.sync_weights,
kv_scales=kv_scales,
)
# A completed refit is what makes an engine's weights current, so this is where a
# shard pulled out of service for holding partial ones earns its way back.
self._promote_refit_shards()
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
Expand Down
8 changes: 8 additions & 0 deletions nemo_rl/algorithms/single_controller_utils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,14 @@ class FleetHealthConfig(BaseModel, extra="allow"):
max_restart_attempts_per_shard: PositiveInt = 5
# Serving shards below which the run cannot usefully continue.
min_healthy_shards: PositiveInt = 1
# Deadline for one refit collective, after which each participating worker aborts its
# own communicator and the controller rebuilds over the survivors and retries once.
#
# None disarms it: no watchdog thread is started and the refit path is byte-identical
# to before. Set it well above a healthy refit -- observed at ~1.9s for a 1.5B model on
# GB200 -- 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.
refit_timeout_s: Optional[PositiveFloat] = None

@model_validator(mode="after")
def _check_consistent(self) -> "FleetHealthConfig":
Expand Down
2 changes: 2 additions & 0 deletions nemo_rl/algorithms/single_controller_utils/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,8 @@ def _build_generation_then_trainer(
train_cluster=train_cluster,
inference_cluster=inference_cluster,
refit_buffer_size_gb=policy_config.get("refit_buffer_size_gb"),
# Only armed when configured; None leaves the refit path unchanged.
refit_timeout_s=master_config.async_rl.fleet_health.refit_timeout_s,
)
weight_synchronizer.init_communicator()
setup_timing_metrics.collective_init_time_s = time.perf_counter() - t0
Expand Down
Loading
Loading