Skip to content

feat(sc): keep training when a generation shard dies - #3591

Open
terrykong wants to merge 29 commits into
feat/sc-resiliency-02-fleet-health-routerfrom
feat/sc-resiliency-03-elastic-recovery
Open

feat(sc): keep training when a generation shard dies#3591
terrykong wants to merge 29 commits into
feat/sc-resiliency-02-fleet-health-routerfrom
feat/sc-resiliency-03-elastic-recovery

Conversation

@terrykong

Copy link
Copy Markdown
Collaborator

Note

Mirror of #3472, originally opened by @asolergi-nv from the asolergi-nv/RL fork.
The branch was re-pushed to NVIDIA-NeMo/RL unchanged (same head SHA) so the four parts
can be linked as a proper PR stack — gh stack cannot operate on fork branches.
Please review and comment here. The original #3472 is left open for history.

Part 3/4 of #3454

What this fixes

With #3470 and #3471 in place a dead engine no longer wedges rollouts and no longer receives traffic — but the run still dies, and it dies in the worst possible way.

The refit communicator (model_update_group) is built once at setup over every training and inference rank. A NCCL broadcast requires every rank in the communicator to take part, so when a generation rank is gone the next weight sync blocks forever inside NCCL: no exception, no progress, no CPU burn, and Ray still reporting every actor healthy. This is the failure the whole effort exists to remove.

It is also ordered so that the hang comes first: sync_weights does ray.get(futures_train) before the ray.get(futures_inference) that would surface the dead actor, so the trainer blocks before anything can report the death.

What it does

Reconcile before every refit, not on a death event. reconcile_communicator() is called at the top of _sync_weights — the one point where the refit group is provably idle and every rank is synchronized, which matters because the operations that change membership are themselves collectives. Doing it every time is idempotent and converges after a missed or reordered health update, instead of needing replay.

Rebuild over the survivors, so training continues on what is left. Trainers are never excluded, so rank 0 stays a trainer and the broadcast root is stable.

Both NCCL transports recover, by different routes. nccl_reshard needs more than the plain broadcast: both communicator families rebuilt and the refit plan regenerated.

Four decisions:

  • "Absent" is deliberately not the complement of "serving". The obvious implementation reads serving_shards(), and it is a bug. SUSPECT (failing probes, not yet condemned) and STALE (reloaded, holding old weights) are both withheld from traffic while their processes are alive and join a refit normally. Using the serving set would abort a run on one failed probe — and would abort it precisely when a STALE shard is waiting to be refit, which is the recovery, not the failure. Hence GenerationFleetMonitor.absent_shards() over {DEAD, RESTARTING, RETIRED}.

  • Rebuilding the communicator is only half a recovery. Every refit dispatch goes through run_all_workers_*, which walks the whole worker group — so after a loss it kept calling the dead shard's Ray actor and the next refit failed with RayActorError. Both dispatches now address only surviving DP leaders. (This shipped broken in an intermediate commit here and is fixed within this PR; kept as separate commits because the fix is instructive.)

  • nccl_reshard cannot simply be resized. Its bulk path is a mesh-to-mesh redistribute, not a broadcast: prepare_nccl_reshard_refit_info derives each parameter's destination placements from gen_world_size. Reusing a plan built for the old fleet does not error — a stale mesh is still a valid mesh — it just has survivors writing the slices the dead shard owned and leaving their own unwritten. So the plan is regenerated, and init_communicator and the rebuild share one _build(membership) so that arithmetic exists once and every normal run exercises the rebuild path.

  • Survivors are compacted to contiguous prefixes, not left with a hole. The reshard destination mesh is torch.arange(offset, offset + num_gpus), so a gap silently misaligns every parameter rather than erroring. The rank arithmetic lives in a pure weight_sync/membership.py, separate from Ray dispatch, because it is the part that must be exactly right and the part that cannot be exercised without ≥3 GPUs.

Tests

  • StatelessProcessGroup.abort() verified on 2×A6000: with a peer SIGKILLed mid-broadcast, a survivor blocked in the collective was released 0.15 s after another thread called abort(). abort() and not destroy() — NCCL documents destroy as an intra-node collective every rank must call or it hangs, which is exactly what a dead rank cannot do.
  • tests/functional/grpo_sc_generation_shard_recovery.sh is the gate, runs on both refit transports in the SingleController lane, and self-skips below 3 GPUs rather than passing vacuously.

Issues

List issues that this PR closes (syntax):

Usage

  • You can potentially add a usage example below
# Add a code snippet demonstrating how to use this

Before your PR is "Ready for review"

Pre checks:

  • Make sure you read and followed Contributor guidelines
  • Did you write any new necessary tests?
  • Did you run the unit tests and functional tests locally? Visit our Testing Guide for how to run tests
  • Did you add or update any necessary documentation? Visit our Document Development Guide for how to write, build and test the docs.

Additional Information

  • ...

asolergi-nv and others added 26 commits August 10, 2026 09:39
Elastic recovery rebuilds the refit communicator whenever the generation
fleet's membership changes, so init_collective goes from running once per
job to running once per recovery. Two things had to change before that is
safe.

Add StatelessProcessGroup.abort(). It is idempotent, safe on a group whose
communicator was never built, and drops its reference *before* calling
abort so a failed release cannot leave broadcast() pointing at a dead
communicator. abort(), not destroy(): NCCL documents destroy as an
intra-node collective that every rank must call or it hangs, which is
precisely what a rank whose process has died cannot do. Verified on
2xA6000 -- with a peer SIGKILLed mid-broadcast, a survivor blocked in the
collective was released 0.15s after another thread called abort().

Release the previous group on both sides of the refit before rebuilding.
Both init_collective implementations previously overwrote
self.model_update_group outright, stranding the old NCCL communicator and
its TCPStore. That is invisible in a one-shot job, which is why it
survived until membership became dynamic, and unbounded once recovery can
repeat.

model_update_group is now declared on both classes instead of springing
into existence on first assignment, so a rebuild can test for a previous
group without probing for the attribute. That also removes a
pyrefly ignore[implicitly-defined-attribute] on the vLLM side.

broadcast() now raises with a diagnostic instead of an AttributeError
when the group has no communicator -- the failure a rebuild bug produces.

Verification: 8 new process-group tests; 30/30 in the vllm lane including
2 new init_collective tests; ruff clean; pyrefly errors drop 9 -> 7 (the
7 remaining are missing optional imports, fastokens and awscrt, in files
this commit does not touch). test_vllm_generation.py was run with and
without this change and is identical at 9 failed / 38 passed / 3 skipped
-- those failures are a pre-existing vLLM/dynamo engine-init issue on
A6000 ('QKVParallelLinear' object has no attribute 'workspace'),
structurally upstream of anything this commit changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: asolergibert <asolergibert@nvidia.com>
A NCCL broadcast needs every rank in the communicator to take part, so
when a generation rank dies the refit blocks forever inside NCCL: no
exception, no progress, and Ray still reporting every actor healthy. That
silent wedge is the failure this effort exists to remove. This commit
converts it into a precise error; rebuilding over the survivors, which
turns the stop into a recovery, is the next step.

Add WeightSynchronizer.reconcile_communicator(absent_shards). It is
non-abstract and defaults to a no-op, so the transports that own no NCCL
world of their own -- IPC, HTTP, checkpoint-engine -- are unaffected. The
two NCCL transports refuse the refit when a rank is missing.

Call it from the top of _sync_weights. Reconciling on a schedule rather
than on a death event is idempotent and converges after a missed or
reordered health update, and that point is the only one where the refit
group is provably idle and every rank is synchronized -- which matters
because the operations that change membership are themselves collectives.

"Absent" is deliberately not the complement of "serving". A SUSPECT shard
is failing probes but not yet condemned, and a STALE shard has reloaded
and holds old weights; both are withheld from traffic, and both processes
are alive and join a refit normally. Reading the serving set would abort
a run on a single probe blip, and would abort it precisely when a STALE
shard is waiting to be refit -- which is the recovery, not the failure.
GenerationFleetMonitor.absent_shards() therefore uses its own state set,
{DEAD, RESTARTING, RETIRED}.

The two transports raise different messages on purpose. The plain
broadcast could in principle drop a receiver, but nccl_reshard cannot:
prepare_nccl_reshard_refit_info derives each parameter's destination
placements from gen_world_size, so resizing without regenerating the plan
would leave survivors holding slices nobody wrote -- silent corruption,
worse than stopping.

Inert by default: async_rl.fleet_health.enabled is false, so there is no
monitor, no notion of a shard being gone, and the transport keeps the
membership it was built with.

Verification: 18 new tests, including the cases pinning SUSPECT and STALE
as present and DEAD and RESTARTING as absent; 1259 passed / 7 skipped
across algorithms, experience, weight_sync, single_controller,
distributed, fleet health and the router; ruff clean; pyrefly unchanged
at the same 7 pre-existing missing-import errors in files this commit
does not touch. Three existing _sync_weights tests build their controller
by hand and needed _fleet_monitor added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: asolergibert <asolergibert@nvidia.com>
…ards

Turns the previous stop into a recovery. When a generation shard dies the
refit communicator still contains its ranks, so the broadcast blocks
forever inside NCCL. P3a.2 detected that and failed loudly; this rebuilds
the communicator without the dead ranks so training continues on what is
left.

Rebuild rather than shrink. The pinned NCCL runtime exports
ncclCommShrink but not ncclCommGrow, so a shrunk world could never take a
recovered engine back -- one mechanism that works in both directions
beats two that each work in one. It is also what nccl_reshard will need,
since that transport must regenerate its refit plan rather than resize.

The rank arithmetic is a pure function in weight_sync/membership.py,
separate from the Ray dispatch that applies it, because it is the part
that has to be exactly right and the part that cannot be exercised here:
observing a real shard loss needs at least three GPUs, so that losing one
still leaves a fleet. An off-by-one does not crash, it points a receiver
at the wrong slice of the broadcast.

Survivors are compacted to contiguous prefixes rather than leaving a hole
where the dead shard was. Not cosmetic: the nccl_reshard destination mesh
is torch.arange(offset, offset + num_gpus), so a gap would misalign every
parameter's placements. It also matches what shrink does to a live
communicator, so both paths describe the same world.

VllmGeneration.rebuild_collective addresses the surviving DP leaders
directly instead of going through run_all_workers_multiple_data, which
walks every worker in the group and would therefore dispatch to the shard
we are rebuilding *because* it is gone. Only leaders are called; each
collective_rpcs into its own TP/PP workers.

Trainers are never excluded, so rank 0 stays a trainer and the broadcast
root is stable across a rebuild. Each rebuild takes a fresh port, since
the previous world's rendezvous store may still be bound, and
StatelessProcessGroup.abort() now releases that store so repeated
recoveries do not accumulate one per recovery.

nccl_reshard still refuses, with a message pointing at the transport that
does recover. Regenerating its plan is the next step.

Adds tests/functional/grpo_sc_generation_shard_recovery.sh: kills one of
two generation shards mid-run and asserts the job completes all steps,
that the rebuild actually happened, and that the metrics are intact --
completion alone would also be satisfied by a run that never noticed the
death. It needs >= 3 GPUs and self-skips below that rather than passing
vacuously, and is registered in the SingleController lane in full mode.

Verification: 36 new unit tests (18 on the rank layout alone); 801 passed
across weight_sync, single_controller, distributed, experience, fleet
health and the router; ruff clean; pyrefly back to the same 7
pre-existing missing-import errors with membership.py added to scope.
End-to-end recovery is NOT verified here -- it needs the >= 3 GPU
functional test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: asolergibert <asolergibert@nvidia.com>
… refit dispatch

Two things, because they are the same defect seen from two sides.

FIXES A GAP IN THE PREVIOUS COMMIT. Rebuilding the communicator is only
half a recovery: update_weights_from_collective and nccl_reshard_refit
dispatch through run_all_workers_single_data, which walks the whole
worker group. After a shard was lost they kept calling its dead Ray
actor, so the next refit failed with RayActorError and the run still
died -- just later, and with a less obvious cause than the hang it
replaced. Both now address the surviving DP leaders, recorded by
set_refit_membership. Before any loss the membership is unset and every
leader is addressed, which is the whole life of a run that never loses a
shard.

nccl_reshard now recovers instead of refusing. It needs more than the
plain broadcast: the shared model_update_group and the per-PP-stage bulk
groups both have to be rebuilt, and the refit plan has to be regenerated,
because prepare_nccl_reshard_refit_info derives each parameter's
destination placements from the inference world size. Reusing a plan
built for the old fleet does not error -- a stale mesh is still a valid
mesh -- it just has survivors writing the slices the dead shard owned and
leaving their own unwritten.

init_communicator and the rebuild now share one _build(membership), so
there is a single copy of that arithmetic. Two copies is exactly how the
communicators and the plan would drift apart, and the drift is silent. It
also means every normal run exercises the rebuild path rather than
leaving it as a rarely-taken branch.

Membership is recorded before the build, not after: step 3 distributes
the regenerated plan through prepare_nccl_reshard_refit_info, which
consults it, so setting it afterwards sent the new plan to the shard the
rebuild had just excluded. Caught by the tests, not by review.

RefitMembershipChanged is removed. Nothing raises it now that both
transports recover; NoSurvivingShards covers the terminal case where the
whole fleet is gone.

The recovery functional test gains REFIT_TRANSPORT, and the CI lane runs
it on both transports. Its rebuild assertion now matches either
transport's log line -- as written it only matched the collective one, so
a reshard regression would have passed silently.

Verification: 12 new tests on reshard rebuild and refit dispatch; 180
passing in weight_sync, 626 across weight_sync, single_controller,
distributed, fleet health and the router; ruff clean; pyrefly unchanged
at 7 pre-existing missing-import errors. End-to-end recovery remains
unverified here and needs the >= 3 GPU functional test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: asolergibert <asolergibert@nvidia.com>
Two portability bugs, both of which only bite on the >= 3 GPU machines
where this test is the only place the recovery scenario can run at all.

1. Megatron asserts global_batch_size % (micro_batch_size *
   data_parallel_size) == 0. The config inherits 512 / 4 from
   grpo_math_1B.yaml and tp=pp=cp=1, so dp is just the training GPU
   count -- and the test took every GPU the host had:

     3 GPUs -> dp=1   512 %  4 = 0  ok
     4 GPUs -> dp=2   512 %  8 = 0  ok
     5 GPUs -> dp=3   512 % 12 = 8  assertion failure
     8 GPUs -> dp=6   512 % 24 = 8  assertion failure
    16 GPUs -> dp=14  512 % 56 = 8  assertion failure

   8 is the usual CI runner size, so this would have failed on the first
   real CI run with an error saying nothing about shard recovery. Round
   the training ranks down to a power of two -- 512 and 4 are both powers
   of two, so any power-of-two dp divides -- and claim only the GPUs
   actually used rather than the whole node.

2. `pgrep -f VllmAsyncGenerationWorker` matched three things per shard:
   the Ray actor, the per-worker venv python child, and the bash launcher
   that execs it. Measured live on a single-shard run, the substring
   matched 2-3 processes where there was exactly one actor.

   So GEN_PIDS[0], the lowest pid, was as likely to be a child as an
   actor, and the guard `(( ${#GEN_PIDS[@]} < 2 ))` was satisfied by the
   children alone -- it would pass with ZERO actors present. Killing a
   child leaves both shards serving, the run completes exactly as it
   would have anyway, and the test reports a pass having never exercised
   recovery. A false pass, which is worse than a false failure here.

   Match the actor structurally instead, the same way the chaos test now
   does, require exactly GEN_GPUS of them, print the victim, and verify
   it actually died.

Cannot be run end-to-end on a 2-GPU box, so: the actor matcher is
verified against all seven process titles observed in a live run and
against live pid counts, and the sizing arithmetic against every host
size from 2 to 16 GPUs.

Signed-off-by: asolergibert <asolergibert@nvidia.com>
collections.abc sorts before dataclasses. Same invisible-locally rule as
the previous two branches -- ruff's `I` selection only runs via the
second ruff hook in .pre-commit-config.yaml.

Signed-off-by: asolergibert <asolergibert@nvidia.com>
Job 5861743 on a 4-GPU GB200 cluster failed all five kill-based functional
tests for one reason: the harness found zero generation actors.

  [recovery] FAIL: expected exactly 2 generation actors, found 0
  [chaos]    FAIL: job died before the kill      (both idle and serving)

At train step 3, with generation demonstrably working. Both chaos variants
failing identically is the tell -- idle and serving are different patterns,
so this is not a state-timing race, nothing matched at all.

Both tests picked their victim by Ray's process TITLE
(ray::VllmAsyncGenerationWorker). That works on the development
workstation and matches nothing on that cluster. Titles are a runtime
implementation detail; the GCS actor table is the runtime's own record of
which pid is which actor, so ask that instead.

Not ray.util.state.list_actors: it goes through the dashboard HTTP state
server and init_ray starts Ray with include_dashboard=False, so it raises
ServerUnavailable. ray._private.state.actors() reads GCS directly.
Verified against a live cluster from a separate process -- it returns
exactly the actor pids.

The old diagnostics were useless here: both greped for "ray::" and so
printed nothing at precisely the moment the ray:: assumption was itself
wrong. They now dump unfiltered process listings.

LIMITATION, stated rather than hidden. Ray only exposes the running method
via the dashboard state API, so idle-vs-serving still needs titles. Where
titles work the distinction is preserved (verified: one actor shows
ray::...Worker, another ray::...Worker.<method>). Where they do not, chaos
now says so and offers VICTIM_STATE=any, which still asserts a bounded
attributable failure but stops distinguishing the detection path from the
in-flight-RPC path.

NOTE ON PLACEMENT: the chaos change belongs on the containment branch,
where that test is introduced. It is here temporarily so branches 3 and 4
can be re-run while the containment PR's CI is in flight; it should be
moved down once that finishes.

Signed-off-by: asolergibert <asolergibert@nvidia.com>
Job 5866390 still failed all five kill-based tests, but the two harnesses
failed differently and only one of them was informative.

  recovery: "found 0" and the diagnostic printed NOTHING -- no error, no
            pids. So the helper attached to Ray fine and matched zero
            actors, and there was no way to tell "no actors" from "wrong
            field name" from "wrong state string".
  chaos:    "could not attach to a running Ray cluster" -- but that came
            after "job died before the kill", so Ray was legitimately gone
            by then. Consistent, not a second bug.

The helper now always dumps the GCS actor table to stderr -- class name,
state, pid, name, one line each -- before reporting matches. One run will
say what that cluster actually calls its generation actors, instead of a
third round of guessing. It is also less eager to filter: State is no
longer required to be ALIVE, since a RESTARTING shard is still a real
process, and over-filtering is what produced the silent empty result.
Only Pid 0 is excluded, because there is nothing to kill.

Also fixes a performance bug this introduced. The chaos loop called the
helper every 0.2s, and each call is a full ray.init/shutdown of about
three seconds, so VICTIM_WAIT_S=300 meant roughly 60 checks rather than
1500 and spent the budget on Ray handshakes. Discovery now runs once with
its own retry and budget (ACTOR_WAIT_S), then only the titles are polled
-- the actor set is stable once generation is up, only the state changes.
Separating the budgets also separates the failure messages: "no actors
found" and "actors never reached the state" were previously the same line.

The recovery query gets a bounded retry for the same reason: a single
query races the GCS write.

Signed-off-by: asolergibert <asolergibert@nvidia.com>
Job 5886540 failed all five kill-based tests and the logs could not say
why, because two things were missing.

1. NEITHER failure path dumped the training log. recovery printed only
   "expected exactly 2 generation actors, found 0", which reads as a
   harness bug -- but the ps dump in the same block listed ONLY harness
   processes (the two sruns, `uv run ...recovery.sh`, `bash
   ...recovery.sh`). The training job the harness had just reported as
   pid=523789 was absent, and its command line contains
   `policy.generation` six times, so it would have matched had it been
   alive. The run had ended; the harness had no idea and said something
   misleading instead. Both paths now tail the training log.

2. The recovery retry loop never rechecked the job. Each attempt is a
   full ray.init/shutdown, so ten of them is roughly thirty seconds --
   and on this hardware `dp` runs its whole 2-step job in about 2.5
   minutes, nearly all startup, so the remaining steps finish well inside
   that window. The loop now checks the job every attempt and reports
   "the run ended before a shard could be killed", with the hint to raise
   MAX_STEPS or lower KILL_AFTER_STEP, rather than blaming actor
   discovery.

Also makes the attach failure diagnosable. `address="auto"` is resolved
from a file in Ray's temp dir, so the helper now reports RAY_ADDRESS,
RAY_TMPDIR, TMPDIR, which temp dir it consulted, and what is in it.
"Could not find any running Ray instance" currently cannot distinguish
"the cluster is gone" from "the cluster is up but its session is
elsewhere", and those need opposite fixes.

What is still unexplained: during the chaos discovery loop the training
job was demonstrably alive and the helper still could not attach. That is
the open question, and these diagnostics exist to answer it on the next
run rather than by a fourth guess.

Signed-off-by: asolergibert <asolergibert@nvidia.com>
…teps

The training logs settled both open questions.

WHY THE JOB VANISHED. It did not crash -- it finished. The chaos log ends
with "SC run complete: {'train_steps': 50, 'trainer_version': 50}". On
GB200 a step takes seconds (the dp test runs its whole job in about 2.5
minutes, nearly all startup), so the run outlasts nothing. Recovery's 12
steps completed while the harness was still looking for a shard to kill.
MAX_STEPS is now 24, so the run is still going well after the kill --
otherwise "it completed" proves nothing about recovery.

WHY THE HELPER COULD NOT ATTACH. `address="auto"` resolves through
/tmp/ray/ray_current_cluster, and that marker is not written for a
driver-managed cluster -- verified: it is absent here too, while
`ray.init()` is live. The session directory does record the GCS port, so
the address can be rebuilt from session_latest/gcs_server_port_* plus the
recorded node ip. Verified against a live cluster: the rebuilt address is
byte-identical to ray.get_runtime_context().gcs_address.

The helper now tries RAY_ADDRESS, then "auto", then the rebuilt address,
and only then reports failure -- with RAY_ADDRESS/RAY_TMPDIR/TMPDIR, the
temp dir it consulted and its contents, and what the rebuild produced.

Signed-off-by: asolergibert <asolergibert@nvidia.com>
Job 5888097 got further -- the diagnostics now say the right things -- but
the one message that would explain the failure is still being thrown away.

What the run showed. The address fallback works: it rebuilt
'10.109.19.148:21829' from session_latest, which is a real cluster
address. The recovery test correctly reported "the run ended before a
shard could be killed" instead of blaming actor discovery. And the
training logs confirm both jobs ran to completion -- 50/50 and 24/24
steps, the recovery run taking 8.5 minutes, so the runs are no longer too
short.

What is still unexplained is why discovery finds nothing while the job is
alive. Both failure dumps run AFTER the job has gone, so they can only
ever report "cluster not found" -- which is true but useless. The
in-loop attempts are the informative ones and their stderr went to
/dev/null.

It now goes to $EXP_DIR/actors.err, and the failure path prints the last
30 lines of it, labelled as the attempt made while the job was alive and
distinguished from the post-mortem call. Three rounds of this have been
undiagnosable for want of that one redirect.

Suspected but unconfirmed: each attempt now costs two ray.init calls
(auto, then the rebuilt address), so ten attempts can outlast the run's
remaining steps. The captured stderr will show whether that is what is
happening or whether the connect fails for another reason.

Signed-off-by: asolergibert <asolergibert@nvidia.com>
…ath that fires

Job 5890572 failed for two reasons, both in the harness.

1. The discovery loop outlived the job it was meant to interrupt. Step
   timings in the log are 20.6s, not the "seconds" assumed earlier -- that
   figure came from the dp test, which uses a far smaller config. So the
   24-step run lasts about 8 minutes and had roughly 7 left after step 3.
   Ten discovery attempts, each costing two ray.init connect timeouts of
   about 20s, consume all of it. Each attempt is now bounded by
   ACTOR_QUERY_TIMEOUT_S (20s) so a failing query fails fast.

2. The stderr dump added last round was on the wrong branch. The run took
   the "run ended before a shard could be killed" path, which only tailed
   the training log; the actors.err dump sat in the "expected exactly N
   actors" path, which did not fire. So the file was written and never
   shown. Both paths print it now.

MAX_STEPS stays at 24: with 20s steps that is 8 minutes, which is ample
runway once discovery stops eating it.

Signed-off-by: asolergibert <asolergibert@nvidia.com>
The actors.err file did not exist after job 5890572, even though the run
took the path that writes it. Two rounds have now been spent routing this
diagnostic through a separate artifact, and neither produced a file that
survived to be read.

So stop using one. The harness log is already captured and already
collected; put the helper's stderr straight into it. stdout (the pids)
goes to a temp file, stderr into a shell variable, and the first two
failing attempts print it inline. Capped at two so a ten-attempt loop
does not flood the log, and shown again on the failure path.

Verified end to end against a live Ray cluster: the pids are still parsed
correctly from stdout (2 actors found, matching ray.get) while stderr is
captured separately for reporting.

This is a diagnostic-plumbing fix, not a fix for the underlying problem.
Discovery finding nothing while the job is alive is still unexplained --
but the next run will finally say why, in the log you already have.

Signed-off-by: asolergibert <asolergibert@nvidia.com>
Root cause, finally. The harness detects progress by grepping RUN_LOG,
and RUN_LOG was empty until the run ended.

Job 5892910, with timestamps:

  10:40:38  "train step 3/24" produced
  10:48:15  EngineCore shutdown -- the run finishes
  10:48:21  discovery attempt 1

Discovery attempt ONE ran six seconds after the job had already gone. The
wait-for-step-3 loop polls every 5s, so it should have fired at 10:40:43;
it did not fire for 7 minutes 43 seconds, by which point there was
nothing left to kill.

The actor's print already uses flush=True, but that only reaches the
DRIVER: Ray forwards actor output there, and the driver's own stdout is a
redirected file, which Python block-buffers. Demonstrated directly -- a
process writing to a redirected file shows 0 bytes on disk two seconds in
and only appears at exit, while the same process with PYTHONUNBUFFERED=1
is visible immediately.

This explains every kill-based failure on this cluster, and why they all
looked like actor-discovery problems: discovery was fine, it was just
never started until the cluster it was meant to query had shut down. It
also explains why the earlier process-title and Ray-attach theories each
looked plausible and each failed to fix anything.

Applies to both harnesses; the chaos test greps the same way.

Signed-off-by: asolergibert <asolergibert@nvidia.com>
Job 5893807 is the first run where the harness worked end to end: step 3
detected, both actors found via the GCS, one killed cleanly. The run then
wedged, and the log could not say where.

It also disproved the diagnosis I had drawn from the previous run. The
ordering is:

  711  _sync_weights: sync done      step 3's refit COMPLETED
  724  train step 3/24               step 3 finished cleanly
  726  fleet: shard 0 healthy -> suspect
  734  fleet: shard 0 suspect -> dead
  742  rollout stall -- 0 rollouts in flight, step 4/24

So the shard died AFTER the refit completed, and step 4's _sync_weights
never started. There is no hung collective: the wedge is on the rollout
side, and "0 rollouts in flight" says the pump stopped without saying
whether it is parked on _rollout_permitted, on the _buffer_capacity
semaphore, or in the sampler. Those need different fixes.

(The surviving shard's EngineDeadError at 11:39:00 is not the cause
either -- that is the last timestamp in the log, i.e. teardown when the
harness SIGKILLed the run.)

The chaos test has dumped stacks on a wedge for a while; this one did not,
which is why a full cycle was spent guessing. It now dumps the
SingleControllerActor with --locals -- the locals are the point, since the
frame alone does not distinguish which of the three parks it is -- plus
the policy and generation workers, and the fleet/refit history.

No production change. The abort-and-rebuild fix is not implemented,
because the evidence no longer supports the hang it was meant to break.

Signed-off-by: asolergibert <asolergibert@nvidia.com>
…builds

The py-spy dump from job 5898311 settles where the recovery test wedges,
and it is the refit collective after all:

  ray::MegatronPolicyWorker.broadcast_weights_for_collective
      synchronize (torch/cuda/streams.py:108)
      packed_broadcast_producer (nemo_rl/utils/packed_tensor.py:109)
      broadcast_weights_for_collective (megatron_policy_worker.py:2180)

Both policy workers, actively stuck on a CUDA stream sync that cannot
complete because the dead generation rank never joins. The surviving vLLM
worker is idle, not in update_weights_from_collective. (An earlier reading
of the log ordering suggested the refit had completed and the wedge was on
the rollout side. The stacks disprove that.)

What the logs still cannot say is WHY the membership was not reconciled
first, and that is the difference between two opposite fixes:

  absent == {}   reconcile ran before fleet health recorded the death, so
                 it correctly did nothing -- the bug is the race, and the
                 fix is to re-check immediately before the broadcast.
  absent == {0}  reconcile saw the dead shard and reconcile_communicator
                 still returned False -- the bug is in the rebuild path.

`rebuilt = 0` is consistent with both, because the print fired only on a
rebuild. It now reports the absent set and the decision every time.

Not yet fixed, and worth recording: an abort-and-rebuild cannot be
delivered as designed. Breaking a hung collective means calling abort() on
the worker's process group, but the worker actors are created without
max_concurrency, so a blocked actor has no thread to service that RPC. Any
abort path has to make those actors concurrent first, or bound the
collective from inside the worker. Which of those is right depends on the
absent set above.

Signed-off-by: asolergibert <asolergibert@nvidia.com>
Elastic recovery worked only when the death landed OUTSIDE the refit
window. Job 5899746 recovered cleanly and completed 24/24 steps; job
5898311, same code, died inside the window and wedged for 1801s. py-spy
showed both policy workers blocked in
packed_broadcast_producer -> cuda stream synchronize, waiting on a
generation rank that no longer existed. The refit is about 10% of
wall-clock (weight_sync 1.93s of an 18.94s step), so this is a routine
event, not a corner case.

The existing reconcile could not cover it. It runs at the top of
_sync_weights, which is right for what it does -- the group is idle there
and every rank is synchronized, and the membership operations are
themselves collectives -- but it is a pre-check. Once the broadcast is
running, the only code that could repair membership sits at the top of the
function that is already stuck, and _rollout_permitted stays cleared, so
the rollout pump parks too. P0.6 recorded this as constraint C1 and
deferred it here.

THE ABORT HAS TO COME FROM INSIDE THE WORKER. The controller cannot
deliver it: the collective blocks the worker actor's event loop and the
worker actors carry no max_concurrency, so an abort RPC would queue behind
the operation it is meant to interrupt. RefitAbortWatchdog therefore runs
a thread in the worker itself, which is the arrangement the design's NCCL
spike actually validated (a survivor released 0.15s after another thread
called abort()).

Two spike findings shape the API, and both are easy to get wrong:

  - An aborted collective returns WITHOUT raising, so try/except cannot
    detect it. The caller must ask whether the abort fired; hence the
    explicit `fired` flag rather than exception handling.
  - The destination buffers hold PARTIAL weights afterwards. Survivors are
    marked via the new mark_weights_partial(), which moves them to STALE --
    alive, refits normally, does not serve. Reusing STALE rather than
    inventing a state matters, because DEAD -> HEALTHY is already
    unreachable and STALE ignores successful probes, so the only route back
    into the serving set is a completed refit.

_sync_weights then reconciles, rebuilds, and retries exactly once. A second
abort is a real fault, and retrying forever would recreate the wedge this
removes.

Also reconciles immediately before the collective, not only at the top:
between the two there is calibration and two to_thread hops, and a death
recorded in that gap would otherwise be ignored until the next step.

Inert by default. refit_timeout_s defaults to None, which starts no thread
and leaves every path byte-identical to before.

Tests: 11 on the watchdog (disarmed cases start no thread; fired survives
the clean return; a failing abort does not escape; an exception in the
guarded block still disarms; 50 refits do not leak threads) and 4 on the
new state transition. 848 passed across the affected suites.

Not covered, and stated so a reviewer does not have to find it: a death
during the REBUILD itself, which is also a collective. Smaller window, same
watchdog could extend to it, out of scope here.

Signed-off-by: asolergibert <asolergibert@nvidia.com>
The recovery test kills at a step boundary, and the refit is only about
10% of wall-clock, so it reaches that window by chance. That is not a
theoretical concern: identical code both passed (job 5899746, recovered
and completed 24/24) and wedged for 1801s (job 5898311) on consecutive
runs, purely on where the kill landed. A fix validated by that coin flip
is not validated.

KILL_DURING_REFIT=true waits for a refit to begin and kills immediately.
The trigger is the "refit membership absent=" line, which is now printed on
every sync right before the collective, so it marks the start of the window
reliably rather than by timing guesswork.

Registered as a fourth recovery variant. It is the only test that exercises
the abort-and-rebuild path deterministically; the other three reach it
occasionally at best.

Ordering note for whoever reads this stack: this test is deliberately in
the same PR as the fix rather than after it. P3a.3 shipped a defect that
P3a.4 had to repair precisely because its suite never covered "and then a
refit happens" -- the fix looked right and nothing exercised it.

Signed-off-by: asolergibert <asolergibert@nvidia.com>
… test

The abort watchdog shipped unreachable. CollectiveWeightSynchronizer took
a refit_timeout_s argument, but there was no config field, the factory did
not forward one, and no test set it -- so the watchdog could never arm and
the whole path was dead code. Caught by asking which functional test
actually enables the feature, which is a better question than it sounds.

Adds async_rl.fleet_health.refit_timeout_s (Optional[PositiveFloat],
default None), forwards it through create_weight_synchronizer, and reads it
in the SingleController setup -- the only path with a fleet monitor to
recover with.

Enabled in the recovery functional test at 60s, in EVERY variant rather
than only KILL_DURING_REFIT. A kill at a step boundary can still land
inside the refit by chance -- that is precisely how job 5898311 wedged --
so leaving it off elsewhere would let the flaky case stay flaky.

60s against a healthy refit of ~1.9s for a 1.5B model on GB200. Chosen far
above rather than near it: firing early aborts a run that was merely slow,
firing late only means a wedge lasts a little longer before it breaks.
Still None by default, so nothing outside the test changes.

369 passed; ruff, isort and format clean.

Signed-off-by: asolergibert <asolergibert@nvidia.com>
The refit deadline reached only half the code. vllm_generation picks
between two methods by config:

  update_weights_from_collective_async   if async_engine   <- what runs
  update_weights_from_collective         otherwise         <- what I changed

Every SC functional test sets async_engine=true, so the very first refit
died in Ray's argument validation:

  TypeError: got an unexpected keyword argument 'refit_timeout_s'

Both recovery variants failed at step 1 of job 5924693 -- before reaching
any of the behaviour under test.

Two fixes, not one:

1. The async entrypoint takes refit_timeout_s and threads it through
   collective_rpc into the worker extension, which already had it.

2. That method wraps its body in `except Exception: return False`, which
   would have swallowed RefitAborted and reported a plain failure. The
   controller would then never see the signal telling it to rebuild and
   retry -- the run would just die, which is the outcome this whole change
   exists to replace. RefitAborted is now re-raised ahead of the blanket
   handler.

The second would not have shown up in this job even with the signature
fixed. It only appears when an abort actually fires, so it would have
looked like "the fix does not work" on some later run.

Adds a signature assertion covering both entrypoints. Nothing behavioural
could catch this: the call crosses a Ray actor boundary, where signatures
are validated at dispatch rather than at import, and the fakes in these
suites do not model that. Verified the test fails (2 failed) with the
signature reverted and passes (13) with it restored -- a test for a bug
this cheap is worthless if it does not actually detect the bug.

630 passed, 3 skipped across the affected suites; lint clean.

Signed-off-by: asolergibert <asolergibert@nvidia.com>
Job 5925668 ran both recovery variants and both died. The watchdog half worked
-- the trainer and the surviving vLLM engine each aborted at 60s, and the
1800s wedge was gone -- but nothing after the abort did.

Four defects, one per stage of the repair:

1. The handler asked the health monitor which shards were absent. That verdict
   is paced by probe rounds while the abort is an event, so the corpse was still
   SUSPECT, absent came back empty, and nothing was rebuilt. The retry then died
   on "StatelessProcessGroup has no communicator: the group was aborted and not
   rebuilt". The repair now probes on its own thread first, turning a race into
   a lookup, and a RayActorError is treated as conclusive rather than as one more
   ambiguous failure to count.

2. mark_weights_partial walks the *serving* shards, and a shard that has only
   just died is still serving -- so the corpse was moved to STALE, which is
   deliberately not an absent state. Even with the rebuild forced it would have
   been put straight back into the collective. Fixed by establishing the dead set
   before marking anything partial; mark_weights_partial already skips absent
   shards.

3. Nothing called report_refit, so mark_weights_partial was a one-way trip out of
   the serving set. A successful recovery would have left the fleet empty and the
   run would have died of an exhausted fleet -- a worse failure than the one being
   repaired. _promote_refit_shards now runs after every successful sync.

4. The other variant died of a RayActorError from ray.get(futures_inference)
   while ray.get(futures_train) had already returned: the broadcast completed and
   the shard died in the RPC epilogue. Survivors held complete weights and the run
   was killed anyway. Now recovered like the abort, minus the partial-weight
   marking, which would take a healthy fleet out of service over a transfer that
   worked.

When no shard can be identified as absent the refit now fails attributably
instead of retrying: there is no smaller membership to rebuild over, and
rebuilding the full one would hang on the same silent rank -- the wedge this
path exists to remove.

Also makes KILL_DURING_REFIT reproducible. A refit here takes ~0.10s, so the
harness could not aim at it: job 5925668 hit the RPC epilogue instead, testing
something real but not what the variant claims. NRL_REFIT_HOLD_FILE lets the
harness hold one specific refit open, kill inside it, and release the survivors
into a collective the victim will never join. Absent in every real run, where it
costs one os.path.exists per refit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: asolergibert <asolergibert@nvidia.com>
The abort-and-rebuild machinery reached only the collective path. factory.py
constructed CollectiveWeightSynchronizer with refit_timeout_s and
NcclReshardWeightSynchronizer without it, and that class had no such parameter
to begin with, so the reshard transport ran with no deadline at all: a
generation rank dying inside a reshard refit wedged exactly as before the
watchdog existed.

Nothing pointed at it. recovery-reshard kills at a step boundary, so it passes
through the between-refits path and never touches the missing machinery -- a
green run on a transport with no protection.

Plumbed the same way as the collective path, through every layer that crosses a
Ray boundary: the synchronizer, both lm_policy and vllm_generation dispatchers,
and BOTH generation worker entrypoints. The async one is what a real run uses,
and omitting it is precisely how the collective path failed at its first refit
with a TypeError out of Ray's argument validation.

RefitAborted is re-raised ahead of the blanket handlers in both workers. Folded
into `return False` it would be reported as a generic refit failure, which ends
the run instead of triggering the rebuild -- the wedge this replaces, wearing a
different error message.

The watchdog now accepts several groups. This transport moves bulk weights over
per-PP-stage groups and then broadcasts the remainder over the shared
model_update_group, so a hang can be in either family and nothing at that level
can tell which. All of them are aborted; abort() is idempotent and safe on a
group that never built a communicator, and the recovery rebuilds every family
anyway. Each abort is guarded separately, because the group that raises may not
be the one the caller is blocked in.

The signature test is now parametrized over both transports rather than
asserting on the one entrypoint that had already broken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: asolergibert <asolergibert@nvidia.com>
chaos-idle failed in job 5931102 while the run did exactly what the test exists
to verify: it died in seconds with the cause named -- "NoSurvivingShards: no
generation shard can take part in the refit; the fleet is gone". The harness
just did not recognise the exception.

Losing the only shard can surface as either of two typed errors, and which one
wins is timing, not meaning:

  GenerationFleetExhausted  too few shards SERVING, raised on the watchdog tick
  NoSurvivingShards         no shard can take part in the REFIT, raised by the
                            reconcile before every weight sync

The refit runs every step and the watchdog every watchdog.interval_s, so once
probe detection went from 30s to 5s the reconcile started getting there first.
Both are bounded and attributable, so both belong in the allowlist.

Not unified by having the reconcile call raise_if_exhausted() first, which looks
tidier and is a trap: an aborted refit marks every surviving shard STALE via
mark_weights_partial, so the serving set is legitimately empty between the abort
and the retry, and raising there would kill the run in the middle of the
recovery that was about to succeed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: asolergibert <asolergibert@nvidia.com>
The abort-and-rebuild path had end-to-end coverage on the collective transport
only. nccl_reshard had the deadline plumbed, the watchdog wired to both of its
communicator families, and the fault-injection hook already in its receive -- but
nothing ever aimed at it. recovery-reshard kills at a step boundary, so it
recovers BETWEEN refits and never aborts; everything specific to aborting a
reshard was resting on signature assertions.

That gap matters because the two aborts are not the same operation. The
collective path aborts one communicator. Reshard holds two families -- the
per-PP-stage bulk groups and the shared model_update_group -- a hang can be in
either, and the rebuild has to regenerate the refit plan as well as the
communicators. A regression in that is invisible to every other test.

Registers recovery-reshard-refit (REFIT_TRANSPORT=nccl_reshard +
KILL_DURING_REFIT=true) in the L1 lane and in submit-ci.sh, which is the one
combination that makes it happen: the hold file parks the receive, the victim is
killed inside it, and the survivors are released into a collective it will never
join.

Also guards the hook itself. Two source-level assertions, because vllm_backend
does `import vllm` at module scope and the default unit lane cannot import it:
the hold must exist in BOTH receives, and it must sit INSIDE the
RefitAbortWatchdog block. Held outside the guard the deadline clock never starts,
so the victim dies during an unguarded pause and the run hangs exactly as it did
before the watchdog existed -- while the test still reports a pass. Both
assertions were verified to fail against those two mutations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: asolergibert <asolergibert@nvidia.com>
Same defect as the chaos test, in the script that kills a shard on purpose:
cleanup() signals and returns, and SIGKILL does not free device memory
synchronously. This one is worse placed -- the lane runs five recovery variants
back to back and then chaos-serving, so there are six handoffs, and the recovery
script had no pre-flight GPU check at all. A leaked engine would surface
downstream as a placement-group timeout, which reads as an unrelated bug.

cleanup() now waits for the memory to come back, and a pre-flight waits for
$USED_GPUS before starting. It sits after the >= 3 GPU SKIP, so a host that can
never run this test still skips immediately rather than waiting first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: asolergibert <asolergibert@nvidia.com>
@terrykong
terrykong requested review from a team as code owners August 12, 2026 00:57
@terrykong
terrykong requested review from a team as code owners August 12, 2026 00:58
@copy-pr-bot

copy-pr-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

Auto-sync is disabled for ready for review pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@terrykong

Copy link
Copy Markdown
Collaborator Author

PR stack for #3454 (SingleController resiliency)

Mirror of #3472. Part 3/4 of the stack (#3593) — see #3589 for the full stack map. Based on #3590, so the diff here is only this layer (+3437 −59, 32 files) rather than the cumulative +9051 the original showed.

Part Mirror PR Original
1/4 contain rollout failures #3589 #3470
2/4 fleet health + Gym router #3590 #3471
3/4 keep training on shard death #3591 #3472
4/4 restart + re-admission #3592 #3473

Branch pushed unchanged from @asolergi-nv's fork (b04fe19f845626398cf17fd03321f5932c881691). Review here; #3472 stays open for history.

asolergi-nv and others added 3 commits August 12, 2026 15:29
upstream #3263 added a _should_use_nemo_gym check and a stale-in-flight abort to
_sync_weights, so the minimal controller this fixture builds no longer reaches
the refit -- it dies on AttributeError: no attribute '_master_config'.

An empty env dict selects the native path and an empty registry makes
_abort_stale_inflight a no-op. Neither is what these tests are about; both just
have to exist for the refit to be reached at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: asolergibert <asolergibert@nvidia.com>
…-resiliency-03-elastic-recovery

Signed-off-by: asolergibert <asolergibert@nvidia.com>

# Conflicts:
#	nemo_rl/algorithms/single_controller.py
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.

2 participants