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
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:
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:
Where it leaves the behaviour — now split by transport, which is more precise than the comment had it:
| Transport | Wedged-but-alive shard |
|---|---|
| packed broadcast | recovers — condemned by attribution, run continues |
nccl_reshard | run 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
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:
- Normally, each engine is its own little world of
tp × ppprocesses.get_rank()returns the position inside that engine — 0, 1, 2 — andrank_prefixis what slides the engine into the right slot of the big group. Adding the two is correct. - When
expert_parallel_size > tensor_parallel_size, NeMo-RL switches vLLM into its own data-parallel mode by settingVLLM_DP_SIZE(vllm_generation.py:236). vLLM then makesget_rank()global across all the engines. The offset is now already inside that number, and addingrank_prefixcounts it a second time.
Worked case: tp=1, pp=1, ep=8, eight generation GPUs, T training
ranks, nothing dead.
| shard | should be | actually computed | group size |
|---|---|---|---|
| 0 | T+0 | T+0 | T+8 |
| 1 | T+1 | T+2 | T+8 |
| 4 | T+4 | T+8 | T+8 ← past the end |
| 7 | T+7 | T+14 | T+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:
: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
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.
| # | Where | The finding | What landed |
|---|---|---|---|
| 1 | single_controller.py:1462comment |
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. |
| 2 | megatron_policy_worker.py:2880comment |
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. |
| 3 | megatron_policy_worker.py:2878comment |
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. |
| 4 | grpo_sc_..._recovery.sh:29comment |
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. |
| 5 | grpo_sc_..._recovery.sh:566comment |
~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. |
| 6 | collective_weight_synchronizer.py:189comment |
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. |
| 7 | grpo_math_1B_..._single_controller.yaml:104comment |
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…". |
| 8 | factory.py:45comment |
refit_timeout_s was new on the signature but missing from the Args: block. |
Documented: "Deadline for one refit collective, after which each participating…". |
| 9 | interfaces.py:472comment |
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). |
| 11 | single_controller.py:1840comment |
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. |
| 12 | setup.py:778comment |
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. |
| 13 | L1_Functional_Tests_SingleController.sh:108comment |
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. |
| 15 | setup.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. |
| 16 | config.py:302comment |
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. |
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.