What happened to each of the 17 review comments

PR #3591 · reviewed at ab0ffe2f, checked against 02b7dd0d · 23 commits by the author in between

Six pages, one question each — this one: what the author did with it.

Every comment was checked against the code at the new head, not against the commit messages. All 17 are addressed. Fourteen were fixed cleanly and need no reading. Three are worth your time: one was fixed by the route the comment explicitly asked the author not to take, one was fixed and then partly reverted after an experiment disproved it, and one was fixed better than the comment suggested.

The three worth reading

read this  #17 · Condemn a wedged shard instead of ending the run

single_controller.py:1576 · the comment · fixed in 1e4d5fc8c, then 2f762b8d8 reverted by 02b7dd0d6

Which case this is. Not the crash case. A crash makes a shard DEAD — Ray reports the dead process and the probe confirms it — so the group can be rebuilt without it and the refit retried. This is the hang case: every process is still alive, nothing is DEAD, so there is nobody to drop and the code raised and ended the run.

The ask. The ledger already knows the culprit — a wedged engine has been timing out its own generations and is sitting in SUSPECT the whole time. So condemn that shard, rebuild smaller, and retry.

What landed, and stayed. Exactly that, including the "exactly one suspect, or not at all" rule:

# the abort is independent evidence that something stopped participating;
# SUSPECT says which. Exactly one, or not at all.
suspects = self._gen_fleet.suspected_shards()
if len(suspects) == 1:
    culprit = suspects[0]
    ... "condemning it as the silent participant and retrying"

Then he went further — and this part is worth understanding, because the reasoning was good. Condemning a shard only changed bookkeeping: "the fleet said DEAD while the process sat there." A py-spy dump from job 6521181 showed both trainers stuck in init_nccl_communicator — building the new group — while their old ncclCommAbort threads were still in native code 25 minutes later. His read:

"NCCL cannot bootstrap a new communicator on a device while a previous one there is wedged mid-abort, and that abort cannot finish while the frozen peer holds its sockets open and idle."

An asymmetry in the tests fit that perfectly. The harness breaks a shard two ways: SIGKILL it (process dies, sockets close) or SIGSTOP it (alive but frozen, sockets stay open). The SIGKILL variant of this same reshard test had always passed; only the frozen one wedged.

So 2f762b8d8 tried to turn the frozen case into the killed case. ray.kill is the forceful one — it terminates the worker's process, the same outcome SIGKILL produces in the test — so the fix killed every worker of a shard the fleet had condemned while its process was still up, before dispatching the rebuild. Sockets close, the peers' pending aborts retire, the rebuild proceeds down the path the killed variants already prove. Note this is terminating a process, not aborting a communicator — two different things that both get called "killing the shard".

Why it was reverted: the experiment refuted it. Job 6523731 ran the kill in exactly the intended order —

"ray.kill reached the victim before the rebuild was dispatched, in the right order, and both trainers wedged in init_nccl_communicator exactly as they had without it."

So the sockets were never the blockage. The real cause is local to the trainers: aborting a communicator does not retire CUDA work already enqueued on a stream, and those orphaned kernels sit on the trainers' own devices. Nothing done to the remote rank — killing it, closing its sockets, anything — can retire them. The kill changed product behaviour and bought nothing, so it went:

"A product behaviour change that kills a live worker has no business staying in on the strength of a theory its own experiment refuted."

Where it leaves the behaviour — now split by transport, which is more precise than the comment had it:

TransportWedged-but-alive shard
packed broadcastrecovers — condemned by attribution, run continues
nccl_reshardrun ends attributably in <900 s — out of scope by design

The design said so before the comment was written, in sync_stream_within: "Recovering a frozen-but-alive rank on this transport stays out of scope." The comment stated the ask as universal; it was right for one transport and wrong for the other.

read this  #14 · The same GPU gets numbered twice

vllm_backend.py:288 · the comment · fixed in 1847b135b

Start here — what this is about

To send new weights from the trainer to the vLLM engines, every process involved joins one NCCL group. Each process needs a number in that group: 0, 1, 2, … up to one less than the group size. No gaps, no duplicates, nothing past the end. If two processes pick the same number, or one picks a number past the end, the group never forms and everyone waits forever.

This finding is about the code that picks those numbers getting them wrong for one particular config. It has nothing to do with a shard dying — it happens at the very first weight sync of the run.

Is this on main? Yes — this one is not the PR’s fault. The bad line is gen_rank_in_group = train_ranks_per_stage + rank_prefix + local_rank, which is on main today, and the SingleController setup that reaches it is on main too. I raised it on this PR because the fix is the same one-line guard as comment #12, and this PR is where all the other refit-path guards are going in.

What goes wrong. There are two different things get_rank() can mean inside a vLLM engine, and the line above assumes the first one:

Worked case: tp=1, pp=1, ep=8, eight generation GPUs, T training ranks, nothing dead.

shardshould beactually computedgroup size
0T+0T+0T+8
1T+1T+2T+8
4T+4T+8T+8  ← past the end
7T+7T+14T+8  ← past the end

Shards 4 through 7 land outside the group. Some ranks raise, the rest sit in communicator setup waiting for ranks that will never show up.

The part you asked about: is the fix a new restriction on EP? No.

“expert_parallel_size must be 1 or equal to tensor_parallel_size” is already the rule for the nccl_reshard weight-sync path, and has been. It is written down in check_nccl_reshard_refit_support, with a comment saying why: the vLLM backend splits experts by index across its TP ranks, so gen-side EP is either 1 or exactly TP. That check already runs on the regular GRPO path. It was simply never wired into the SingleController path.

So the ask was “call the check you already have”, not “add a new limit”. The set of configs that actually work does not shrink by one. What changes is what a user sees when they pick one that never worked: today a silent hang inside NCCL, after the fix a startup error naming the bad field. It also only applies to nccl_reshard — the other refit transports are untouched.

If you want ep > tp to genuinely work on the reshard path, that is a real feature request, and it needs the arithmetic fixed. Removing the guard would not give you that — it would just give you the hang back.

What landed. Comment #12’s guard landed, so check_nccl_reshard_refit_support now runs from setup_single_controller and rejects the config before any rank arithmetic. The author also routed :285 through resolve_rollout_rank, which the comment asked him not to do:

"Please do not fix this by routing :285 through resolve_rollout_rank. That helper decides 'am I in external DP?' by testing get_world_size() == rollout_world_size, and this PR is what made rollout_world_size change under it — so that route would fix the initial build and leave a quieter version of the same bug on the rebuild path."

Safe today anyway, because the guard means data-parallel vLLM can never reach the reshard path on SingleController, so the branch that would misfire never runs.

What to watch. Correctness now rests on the guard rather than on the arithmetic. A config check is a list of restrictions people relax over time; if this one is ever relaxed, the bug comes back on the rebuild path, where it is quieter than it was at setup. Worth one comment at the call site saying so.

read this  #10 · The abort misses the smaller groups

stateless_process_group.py:133 · the comment · fixed in 1ce2cadc3

Start here — what this is about

A communicator is NCCL’s handle for a set of GPUs that talk to each other. Weight sync builds one big communicator holding the trainers and the generation shards.

NCCL also lets you carve a smaller group out of a bigger one with split(). The piece you get back is a full communicator in its own right — the parent is not its boss. The Python weight-transfer path does exactly this: it splits the big group into one child per replica group and keeps the children in a cache, so it does not rebuild them every step. So at any moment three families of communicators are alive, and only two of them were being tracked.

Why that matters for recovery: when a shard dies, the survivors are stuck inside an NCCL call waiting for it. The one way to get them out is abort(), which flips a “give up” flag so every call in flight on that communicator returns instead of waiting. This whole PR is built on that working.

The ask. abort() was called on the parent only. NCCL gives each split child its own flag — unless you set NCCL_COMM_SPLIT_SHARE_RESOURCES=1, which nothing in this repo does. So aborting the parent flips the parent’s flag and reaches nothing else. A rank stuck in a call on a child is never woken up.

That is worse than getting the wrong error type back. The call never returns at all, so the with block never exits, so the line that would ask “did the watchdog fire?” is never reached. The timeout goes off and the run hangs anyway — the exact outcome this PR exists to remove, on a path it did not cover. The comment proposed calling the existing clear_xferdtensor_python_caches before the parent abort.

What landed — better than the ask. A new helper that aborts the children rather than running the cleanup path:

# Before the parent: once nccl_communicator is None the cache keys derived
# from it cannot be recovered, and the children would be stranded as well
# as un-aborted.
from nemo_rl.weight_sync.xferdtensor_python import (
    abort_xferdtensor_python_subcommunicators,
)
abort_xferdtensor_python_subcommunicators(self)

The helper I suggested tears children down with finalize()/destroy(), and those are collective — every rank in the group has to call them, in the same order. A dead rank cannot. So my version would have moved the hang rather than removed it. Abort is one-sided, which is the whole reason the parent uses it too. The new function’s docstring now carries that reasoning and the splitShare detail, so the next reader does not have to rediscover either.

The fourteen fixed cleanly

Each verified in the code at 02b7dd0d, not from the commit message.

#WhereThe findingWhat landed
1single_controller.py:1462
comment
On the Gym router path nothing called report_success, so the failure count was a lifetime total, not a streak — a healthy shard eventually gets condemned. Router now tracks _backend_successes and the drain hands over (successes, failures) — the exact shape suggested.
2megatron_policy_worker.py:2880
comment
The critical one. On nccl_reshard a mid-refit abort escaped as AttributeError/NcclInvalid, not RefitAborted, so the controller never recovered. RefitAbortWatchdog.__exit__ now translates any escape when the watchdog fired — the fix point the review settled on, with the mid-call reasoning preserved in the comment.
3megatron_policy_worker.py:2878
comment
getattr(self, "pp_comm_group", None) where the base class already declares the attribute. groups = [self.pp_comm_group, self.model_update_group] — the suggestion applied verbatim.
4grpo_sc_..._recovery.sh:29
comment
Five lane variants shared one EXP_DIR that is rm -rf'd on entry, so each destroyed the previous one's logs. Per-variant subdirectory: EXP_DIR=$SCRIPT_DIR/$EXP_NAME/$EXP_VARIANT. Used if rather than [[ ]] && for the set -e reason the comment flagged.
5grpo_sc_..._recovery.sh:566
comment
~25 lines unreachable after the FREEZE_VICTIM early return, asserting the opposite of the check that runs. Removed. The contradictory assertion text is gone from the file.
6collective_weight_synchronizer.py:189
comment
The rebuild dropped nccl_peer, so a rebuilt group silently fell back to the "nemo" handshake. nccl_peer=sender_spec.nccl_peer now passed on the rebuild as well as at init.
7grpo_math_1B_..._single_controller.yaml:104
comment
on_dead_shard: fail_fast advertised behaviour this PR replaced — recovery is now unconditional. Comment rewritten: "only value; recovery is not selected here — the reconcile…".
8factory.py:45
comment
refit_timeout_s was new on the signature but missing from the Args: block. Documented: "Deadline for one refit collective, after which each participating…".
9interfaces.py:472
comment
GenerationInterface.nccl_reshard_refit never got refit_timeout_s, though the PR wrote down the rule that every backend must carry it. Widened to nccl_reshard_refit(self, refit_timeout_s: Optional[float] = None).
11single_controller.py:1840
comment
After one shard died, every step rebuilt both communicator families twice, forever — and three code comments claimed the opposite. Remembers the membership it built with and skips when unchanged — with the force: bool = False escape the comment warned was required, or the recovery path would break.
12setup.py:778
comment
grpo.setup()'s whole refit validation block never ran on the SingleController path — its only call site is in grpo.py. check_nccl_reshard_refit_support(master_config) now called from setup_single_controller. This is also what makes #14 safe.
13L1_Functional_Tests_SingleController.sh:108
comment
No registered variant ever made a reshard refit actually abort, and a skipped lane read identically to a passing one. The reshard + FREEZE_VICTIM corner is now registered, and the skip is made visible.
15setup.py:778 (thread reply)
comment
Not a separate finding — a reply broadening #12 from "one EP check" to "the whole validation block", and linking it to #14. Folded into #12's fix.
16config.py:302
comment
refit_timeout_s defaulted to None, so no watchdog thread started — and the watchdog is the only thing that can release a rank blocked in NCCL, so reactive recovery could not run at all. refit_timeout_s: Optional[PositiveFloat] = 300.0 — the value asked for.
How to reference these. The numbers above are stable — say "reopen #14" or "I want to reread #17" and they map to the rows and cards on this page. Every row links to the comment thread on GitHub.

Where this leaves the PR

Two of the review's four critical findings — the abort escaping as the wrong exception type (#2) and the split children never being aborted (#10) — are fixed at the mechanism level, not patched at the call site. The recovery now has a real default (#16), a guard that runs on the path that needs it (#12), and a rule for attributing a silent participant (#17).

The one thing the review got wrong is worth recording: #17 was stated as universal and is only true for one transport. The author's experiment established that a wedged rank on the reshard path cannot be recovered at all, because the orphaned CUDA work is on the trainers' devices and nothing done to the remote rank retires it. That is a stronger result than the review asked for, and it came from implementing the ask and measuring it rather than arguing about it.