Skip to content

fix(rccl): gate and bound the destroy-path intranode barrier - #12446

Closed
prasanna-amd wants to merge 13 commits into
developfrom
users/prasanna-amd/aicomrccl-2467
Closed

prasanna-amd wants to merge 13 commits into
developfrom
users/prasanna-amd/aicomrccl-2467

Conversation

@prasanna-amd

@prasanna-amd prasanna-amd commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

JIRA ID: AICOMRCCL-2467

Problem

The NCCL v2.31.2-1 sync added bootstrapIntraNodeBarrier to commDestroySync() to protect PXN proxy teardown. It is a socket-based dissemination barrier that requires every host-local rank to participate, and it has no cancellation protocol. A rank that does not arrive strands every peer that did, forever.

Ranks arriving at different times is fine — the barrier handles that correctly. Divergent participation is what hangs it, and the guard immediately above the barrier is itself a source of divergence: a rank with a non-zero abortFlag skips the barrier entirely. So ncclCommAbort on one rank — including the PyTorch ProcessGroupNCCL watchdog path, and an ncclCommShrink(NCCL_SHRINK_ABORT) that excluded a host-local rank — leaves its peers blocked in bootstrapRecv with no way out.

In the reproduced configuration the barrier was protecting nothing. RCCL defaults NCCL_PXN_DISABLE=1, and rcclSetPxn() only auto-enables PXN at nRanks >= 64 (gfx942) / >= 32 (gfx950), so a single-node comm has no PXN relay unless PXN is forced on explicitly.

Evidence

Captured under gdb from a live hang on a two-rank, single-node, one-process-per-rank reproducer:

#0  accept ()
#3  ncclSocketAccept(ncclSocket*, ncclSocket*, bool)
#4  bootstrapAccept(ncclSocket*, ncclSocket*, unsigned int volatile*)
#6  bootstrapRecv(void*, int, int, void*, int)
#7  bootstrapP2PBarrier(void*, int*, int, int, int)
#8  bootstrapIntraNodeBarrier(void*, int*, int, int, int)
#9  commDestroySync(ncclAsyncJob*)
#11 commReclaim(ncclAsyncJob*)
#14 ncclGroupEndInternal(...)
#15 ncclCommDestroy_impl(ncclComm*)
rank 1 teardown result
ncclCommDestroy after a 10s stagger no hang (control)
ncclCommAbort hang
exits without teardown hang

The passing stagger control is what makes this meaningful: the barrier is sound when participation agrees.

On AICOMRCCL-2468: this proves the mechanism is real and reachable on a single-node comm. It does not establish that DistributedDataParallelTest.test_nccl_backend_4gpu_module fails this way — that run's last log line is Init START, which points at init rather than destroy. That attribution is tracked on AICOMRCCL-2468 and remains open.

Fix

Two independent changes, both in commDestroySync().

1. Only run the barrier when PXN is actually enabled

if (COMPILER_ATOMIC_LOAD(comm->abortFlag, std::memory_order_acquire) == 0 && ncclPxnDisable(comm) != 1) {

The barrier exists solely to protect PXN connection establishment, so test that directly. ncclPxnDisable() derives from arch, nRanks and env, so every rank agrees on it by construction — which is the property that matters. On the overwhelming majority of RCCL comms this removes a deadlock risk that was protecting nothing.

comm->useNetPXN would be more precise, but it is unusable as a guard: the allgather that makes it comm-wide is conditional on LOCAL_REGISTER/GRAPH_REGISTER (generic.cc:30), and net.cc:489 keeps writing it afterwards for lazily established connections. Ranks can therefore disagree at destroy, which would reintroduce the hang in a new shape.

ncclPxnDisable() is now hardened against a null comm->topo, since the destroy path can reach it on a non-blocking comm whose init failed before the topology was built.

2. When it does run, bound it

bootstrapIntraNodeBarrierTimeout() gives the barrier an absolute deadline (RCCL_DESTROY_BARRIER_TIMEOUT_MSEC, default 120000; 0 restores upstream's unbounded wait). On expiry it returns ncclRemoteError. commDestroySync() logs that, continues teardown -- ncclProxyStop() still runs -- and propagates the error as its own result, which is upstream's existing handling for a failed barrier.

No membership calculation can predict every way a peer fails to arrive — most of all an ncclCommAbort elsewhere in the process, which makes that rank take the abortFlag branch and skip the barrier. The deadline covers that class generically.

The case this targets is specifically a peer that is alive but not participating. A peer that actually dies needs no timeout: the kernel closes its sockets on process exit, so the recv already errors. That asymmetry is why only the connect wait is bounded and the ack read in socketAccept() is left alone — reaching it means a peer completed the TCP connect, which a dead process cannot do.

What expiry costs is bounded. Proceeding means running ncclProxyStop() while a peer might still be establishing a PXN connection through us — the race the barrier exists to prevent — so it is worth being precise. The proxy service thread force-closes its peer and listen sockets on stop (proxy.cc:2090-2095, 2108), so the peer gets a closed socket or ECONNREFUSED and fails cleanly. Nothing shared is unmapped out from under it: the ncclShmClose() in ncclProxyStop() releases only this rank's client-side view, because a client attach leaves handle->shmPath NULL and ncclOsShmClose() unlinks only when that is non-NULL (os/linux.cc:777, 892). Teardown is also refCount-gated (proxy.cc:2284), so comms sharing this proxy state are untouched.

The residual risk is a false positive: a peer merely slow to reach destroy, rather than gone, loses whatever it was establishing. Hence a generous default rather than a tight one — 120s is orders of magnitude above the legitimate skew between host-local ranks of one comm reaching ncclCommDestroy, and sits below PyTorch's 600s ProcessGroupNCCL watchdog so it is never the deadline that fires first.

What this PR no longer does

Earlier revisions tried to vary the barrier's membership — a hasExcludedHostRank flag, then an abortExcludedRanks filter — to drop ranks that an NCCL_SHRINK_ABORT had excluded. Both were unsound and are fully reverted. Only the shrink survivors know about the exclusion; the excluded rank still holds its own parent handle and enters the full barrier, so filtering does not remove the hang, it relocates it onto the excluded rank. src/include/comm.h is back to a zero-line diff against develop.

Thanks to @nikitaxgusev for pushing on this, and to amd-argus for catching that the earlier nNodes > 1 guard rested on a wrong reading of ncclTopoGetPxnRanks().

Test

  • test/TeardownStressTests.cpp — four process-isolated GPU tests, each with a hard timeout so a regression fails rather than wedging CI:

    • DivergentTeardown_PeerDestroys_Completes — control, both ranks destroy, PXN off.
    • DivergentTeardown_PeerDestroys_PxnEnabled_Completes — same with NCCL_PXN_DISABLE=0, so the barrier genuinely runs.
    • DivergentTeardown_PeerAborts_DoesNotHang — peer aborts and skips the barrier, PXN off, so this covers the gate.
    • DivergentTeardown_PeerAborts_PxnEnabled_TimesOut — the case the gate does not cover: NCCL_PXN_DISABLE=0 plus an aborting peer, so the barrier is entered and only the deadline releases it. RCCL_DESTROY_BARRIER_TIMEOUT_MSEC is pinned to 5000 so the runner does not time out first, and destroy must take at least 4000 ms — a prompt return would mean the barrier was skipped and the deadline never exercised. Expiry reports ncclRemoteError here, which is the expected outcome, so this case asserts on duration while the other three keep the strict rc == ncclSuccess check.

    NCCL_PXN_DISABLE is pinned per child via setenv rather than inherited: TestConfig inherits the parent environment, and an ambient NCCL_PXN_DISABLE=0 (mi300_ainic_ccs_roce.json sets it) would otherwise silently change which path is under test.

  • test/host/init-test.cc — five microtests over commDestroySync, against a recording barrier fake:

    • PXN enabled: the barrier runs, through the bounded entry point, with the configured deadline and upstream's host-local membership.
    • PXN disabled, and comm aborting: the barrier is skipped, and ncclProxyStop() still runs.
    • Deadline expires: proxy still stops, and the error is reported rather than swallowed.
    • The 120000 ms default is pinned explicitly.

    Each was checked against a mutated guard — dropping the PXN condition, and swapping the bounded call back for the unbounded one — to confirm it fails for the intended reason rather than passing vacuously.

  • Host unit suites: 708/708 (rccl-UnitTestsMicroInit) and 317/317 (rccl-HostUnitTests) PASS.

Still outstanding

  • The GPU tests above are unverified. They are syntax-checked only; I cannot run GPU tests from this checkout, so all four must be proven in CI and I am not claiming they pass until a run link is posted.
  • CI has not run on this branch since 2026-10-08, against 7118726b2f8 — the reverted design. Every commit making up the current fix has zero CI coverage. The ci:single-arch label gate on therock-ci.yml explains part of that; it does not explain why the ungated rccl-host-unit-tests and rccl-default-behavior workflows also stopped.
  • Full GPU + framework (PyTorch c10d, JAX) runs against the current revision. Earlier numbers in this PR's history were measured against the reverted design and do not carry over.
  • The 120000 ms default is bounded by argument (below PyTorch's 600 s watchdog, far above legitimate host-local skew) but not by measurement. Open for a better-grounded number.

@therock-pr-bot

therock-pr-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

✅ All Policy Checks Passed

Check Status Details
📝 PR Description ✅ Pass —
⛔ Forbidden Files ✅ Pass —
🧪 Unit Test ⚠️ Warning Error: Source/code files changed without an accompanying unit test.
Expected: add at least one test file named like test_<name>.py / test_<name>.cpp (or <name>_test.*).
Current: code file(s) changed: projects/rccl/src/bootstrap.cc, projects/rccl/src/graph/paths.cc, projects/rccl/src/include/bootstrap.h, projects/rccl/src/include/socket.h, projects/rccl/src/init.cc (+6 more); no test file found
🚫 Draft PR 🔜 To Be Enabled —
🚩 Feature Flag 🔜 To Be Enabled —
📊 Code Coverage 🔜 To Be Enabled —

🎉 All policy checks passed!

📖 Need help? See the Policy FAQ for details on every check and how to fix failures.

🙋 Wish to Override Policy?

@therock-pr-bot

Copy link
Copy Markdown

🚫 Please fix the failed policies before requesting reviews.

The following policy checks failed:

  • ❌ PR Description

The Not ready to Review label has been added to this PR.
Once all policies pass, the label will be removed automatically.

@prasanna-amd
prasanna-amd marked this pull request as ready for review September 29, 2026 18:47
@prasanna-amd
prasanna-amd requested review from a team and a balanced review from Copilot September 29, 2026 18:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The barrier can still deadlock during teardown, and the new peer lookup can access memory outside the array.

Review effort: Balanced
Findings: 2 High severity · 1 Medium severity · 1 Low severity

Open (4)
What changed in this PR

This PR changes RCCL communicator teardown to avoid a bootstrap barrier when a local rank was excluded by shrink or when the communicator is single-node.

Changes:

  • Track whether shrink excludes a host-local rank.
  • Use that flag and the node count to decide whether teardown enters the barrier.
  • Add host tests for the new flag and update shrink-test peer data.
File Description
projects/​rccl/​test/​host/​init-test.cc Updates shrink fixtures and adds host-local exclusion tests.
projects/​rccl/​src/​init.cc Sets the exclusion flag and changes the teardown barrier guard.
projects/​rccl/​src/​include/​comm.h Stores the new flag on the communicator.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread projects/rccl/src/init.cc Resolved
Comment thread projects/rccl/src/init.cc Resolved
Comment thread projects/rccl/src/init.cc Resolved
Comment thread projects/rccl/src/init.cc Resolved

@amd-argus amd-argus Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ARGUS

Bottom line: Worth settling before approval: nNodes &gt; 1 also removes the only ordering between the legacy IPC deregister drain at init.cc:4026-4032 and ncclProxyStop, and that ordering is host-local, so nNodes == 1 is its worst case.

Comment @argus review to re-run, or @argus re-review after pushing.

Comment thread projects/rccl/src/init.cc Resolved
Comment thread projects/rccl/src/init.cc Resolved
Comment thread projects/rccl/test/host/init-test.cc Resolved
Comment thread projects/rccl/src/include/comm.h Resolved

@amd-argus amd-argus Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ARGUS

Bottom line: Worth settling before approval: comm-&gt;peerInfo can still be null at init.cc:4540, since ncclCommEnsureReady returns success out of its abortFlag branch without checking initState.

Comment @argus review to re-run, or @argus re-review after pushing.

Comment thread projects/rccl/src/init.cc Resolved
Comment thread projects/rccl/test/host/init-test.cc Resolved
Comment thread projects/rccl/test/host/init-test.cc Resolved

@amd-argus amd-argus Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ARGUS

Bottom line: Worth settling before approval: comm-&gt;nNodes &gt; 1 is not the same test as "no host-local proxy dependency", so a single-node comm relaying through a peer's proxy skips the barrier at init.cc:4033.

Comment @argus review to re-run, or @argus re-review after pushing.

Comment thread projects/rccl/src/init.cc Resolved
Comment thread projects/rccl/src/include/comm.h Resolved
@prasanna-amd
prasanna-amd force-pushed the users/prasanna-amd/aicomrccl-2467 branch from fc0050a to f2dfec7 Compare October 1, 2026 17:46

@amd-argus amd-argus Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ARGUS

Bottom line: Worth settling before approval: both new tests in TeardownStressTests.cpp skip inside the re-exec'd child and report PASS without forking a rank.

Comment @argus review to re-run, or @argus re-review after pushing.

@amd-argus amd-argus Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ARGUS

Bottom line: Nothing new worth raising; still open from earlier: TeardownStressTests.cpp:244. Worth clearing before approval.

Comment @argus review to re-run, or @argus re-review after pushing.

The NCCL v2.31.2-1 sync added bootstrapIntraNodeBarrier in
commDestroySync to protect PXN proxy teardown. This barrier requires
all host-local ranks to participate, which deadlocks in two cases:

1. After ncclCommShrink: excluded ranks abort and skip the barrier
   while non-excluded ranks enter it (AICOMRCCL-2467).
2. Single-node configs: PXN is not active (nNodes == 1) but processes
   reach destroy independently and the barrier deadlocks
   (AICOMRCCL-2468).

Guard the barrier on childCount == 0 (no shrink/split children) and
nNodes > 1 (PXN is multi-node only).
Replace the childCount == 0 condition with a dedicated hasExcludedLocalRank
flag. childCount is never incremented for shrink, so the old guard did not
skip the barrier for shrink parents. It also incorrectly removed PXN
teardown protection after normal split/grow operations.

The new flag is set during shrink when any excluded rank shares the same
host (via hostHash comparison). This precisely targets the case where a
local peer will skip the barrier after abort, while preserving the barrier
for healthy multi-node comms including those with hierarchical splits.
…ield comment

Move peerInfo_ array into Grow_ParentComm so all shrink tests have valid
peerInfo without per-test setup. This fixes the null dereference in
ShrinkIgnoresNoColor and eliminates copy-pasted peerInfo boilerplate
across five tests. Add inline comment to hasExcludedLocalRank field.
…de test

Add null check on comm->peerInfo before the hostHash loop — a
non-blocking comm whose init failed before allgather can have peerInfo
null with abortFlag set. Give Grow_ParentComm entries distinct non-zero
hostHashes so existing shrink tests don't accidentally match on
zero-initialized hashes. Add a multi-element exclude test where the
first entry misses and the second hits, exercising loop continuation.
Setting the flag for any shrink was not merely a lost optimization, it
introduced a new hang. Under NCCL_SHRINK_DEFAULT the excluded rank is
healthy: its abortFlag is 0 and it still destroys its parent, so it does
enter bootstrapIntraNodeBarrier. A surviving host-local rank that skipped
the barrier would strand it there forever.

Only NCCL_SHRINK_ABORT leaves behind a peer that will never arrive.

The three host tests covering this path were written against
NCCL_SHRINK_DEFAULT, which was itself the tell; they now use
NCCL_SHRINK_ABORT. A fourth asserts NCCL_SHRINK_DEFAULT leaves the flag
clear so the regression cannot return quietly.

Also adds permanent coverage for the destroy-path barrier deadlock to
TeardownStressTests.cpp. Two ranks, one process each, divergent teardown:
the control has both ranks destroy, the regression has the peer abort so
it skips the barrier. Both run under ProcessIsolatedTestRunner with a hard
timeout, so a regression fails the test rather than wedging CI.
The barrier exists only to protect PXN connection establishment, so test
that directly instead of using nNodes > 1 as a proxy for it.

nNodes > 1 was not the same question. proxyRank at net.cc:489 comes from
the graph == NULL branch of ncclTopoGetNetDev(), which under the default
NCCL_P2P_PXN_LEVEL=2 assigns a peer's rank from purely local topology --
no nNodes test, no system->inter test. The earlier justification for
nNodes > 1 cited ncclTopoGetPxnRanks(), but that function only feeds the
proxy pre-connect at init.cc:2511 and does not gate net.cc at all. A
single-node comm with NCCL_PXN_DISABLE=0 really can relay through a
host-local peer's proxy.

ncclPxnDisable(comm) answers the actual question and every rank agrees on
it, since it derives from arch, nRanks and env. Agreement is the property
that matters: a barrier some ranks enter and others skip is what hangs.

comm->useNetPXN would be more precise still, but it is unusable here --
the allgather that makes it comm-wide is conditional on LOCAL_REGISTER or
GRAPH_REGISTER (generic.cc:30), and net.cc:489 keeps writing it afterwards
for lazily established connections, so ranks can disagree at destroy.

Also harden ncclPxnDisable() against a null comm->topo, now that it is
reachable from the teardown path, and rename hasExcludedLocalRank to
hasExcludedHostRank: it is computed from peerInfo[].hostHash, while every
other local* field in ncclComm is node-scoped, and node != host on MNNVL.
EnvVars reports 0 GPUs inside a process re-exec'd by ProcessIsolatedTestRunner
and a skipped child is scored as a pass, so the maxGpus gate was silently
turning both divergent-teardown tests into no-ops that reported PASS without
ever forking a rank. Guard with !isIsolatedChild(), matching TeamTopologyTests,
and read the count from a cached EnvVars() instead of constructing a TestBed
before fork().

Also drop the retracted ncclTopoGetPxnRanks justification from the header
comment; the reason a two-rank single-node comm has no PXN relay is RCCL's
NCCL_PXN_DISABLE=1 default plus rcclSetPxn()'s rank thresholds.
@prasanna-amd
prasanna-amd force-pushed the users/prasanna-amd/aicomrccl-2467 branch from 327297f to e66003d Compare October 5, 2026 21:20

@amd-argus amd-argus Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ARGUS

Bottom line: LGTM.

Comment @argus review to re-run, or @argus re-review after pushing.

Comment thread projects/rccl/src/init.cc Resolved
Comment thread projects/rccl/test/host/init-test.cc Resolved
Comment thread projects/rccl/src/init.cc Resolved
Comment thread projects/rccl/src/init.cc Resolved
Comment thread projects/rccl/src/init.cc Resolved
Review feedback on #12446: skipping the host-local barrier in
commDestroySync() whenever a shrink had excluded a same-host peer threw
away PXN teardown protection for every surviving rank, when only the
dead peer actually needed to be left out.

Record the ranks an NCCL_SHRINK_ABORT removed, and drop just those from
the barrier membership so the survivors still rendezvous. Every survivor
holds the same exclude list, so they derive the same membership and the
same root, and bootstrapP2PBarrier already returns early at nranks == 1.

Also fix the PXN gate polarity: ncclPxnDisable() returns an env-derived
value the rest of the codebase compares against 1, so == 0 wrongly
skipped the barrier for NCCL_PXN_DISABLE=2.
@prasanna-amd

Copy link
Copy Markdown
Contributor Author

Pushed 7118726b2f8 implementing the review feedback.

Barrier membership instead of skipping it. hasExcludedHostRank is gone. The parent now records the global ranks an NCCL_SHRINK_ABORT removed (comm->abortExcludedRanks, accumulated across shrinks, never cleared, freed in commFree), and commDestroySync() filters exactly those out while building hostRanks. Survivors still rendezvous with each other, so PXN teardown protection is kept; only the dead peer is no longer expected. Every survivor holds the same excludeRanksList, so they all compute the same membership and the same hostRanks[0] root, and bootstrapP2PBarrier returns early at nranks == 1 (bootstrap.cc:2038) for a lone survivor on a host.

PXN gate polarity. ncclPxnDisable(comm) == 0 → != 1, matching paths.cc:1045 and search.cc:1672.

Comment layout. Upstream's comment is restored verbatim inside the if; the RCCL rationale now sits in an // RCCL: block above it.

Field placement. The new fields live in the [RCCL] block rather than in the middle of the upstream struct.

Tests. Host tests reworked to assert the recorded set rather than a bool, with a non-zero caller rank, accumulation across two shrinks, and a direct test of the membership helper. 707/707 rccl-UnitTestsMicroInit pass. TeardownStressTests.cpp now checks the ncclCommDestroy/ncclCommAbort rc in the child (a non-success rc used to exit 0 and score as a pass), pins NCCL_PXN_DISABLE per child so an ambient value cannot silently change which path is under test, and adds a NCCL_PXN_DISABLE=0 control so the barrier is proven to still complete when every rank enters it.

Still out of scope and tracked separately: with PXN genuinely enabled, an ncclCommAbort peer (no exclude list at all) still strands the barrier. That needs a real cancellation protocol, not membership filtering.

@amd-argus amd-argus Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ARGUS

Bottom line: Worth settling before approval: a rank excluded by NCCL_SHRINK_ABORT that destroys its parent rather than aborting now enters the barrier the survivors filtered it out of, and hangs where it previously completed.

Comment @argus review to re-run, or @argus re-review after pushing.

Comment thread projects/rccl/src/init.cc Resolved
Comment thread projects/rccl/src/init.cc Resolved
Comment thread projects/rccl/src/init.cc Outdated
}
int const peer = comm->localRankToRank[i];
if (comm->peerInfo[peer].hostHash != comm->peerInfo[comm->rank].hostHash) continue;
if (peer != comm->rank && commRankWasAbortExcluded(comm, peer)) continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Argus already flagged this and it is still open. An excluded rank never ran shrink, so destroying its own parent or a sibling comm sends it back into the barrier waiting for everyone, while the survivors already left. This filter does not cover that, so multi-node still hangs. How do we close it, and would a barrier with a timeout be the fuller fix?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes -- a barrier with a timeout is the fuller fix, and that is what b7bc5b6 does. The filter is gone; hostRanks is upstream's loop verbatim again.

Your framing is what made the difference. Filtering tries to predict who will not arrive, which is only possible for the one failure mode we happened to be looking at, and only from the side that happens to know. A deadline does not need to know who or why. It covers the excluded rank destroying its parent, it covers a plain ncclCommAbort on a host-local peer (the AICOMRCCL-2468 shape I had previously said needed a follow-up notification protocol), and it covers a rank that simply died.

Mechanically:

  • ncclSocketAccept takes int64_t deadlineNs = 0. It is a busy spin over socketProgressState, not a blocking accept(2), so the deadline drops straight into the existing loop. The expiry check must return before the post-loop switch, which maps a still-Accepting socket to ncclSuccess for the asyncFlag callers -- otherwise a peer that never arrived reports as a completed accept.
  • Threaded through bootstrapAccept -> socketAccept -> bootstrapRecvDeadline -> bootstrapP2PBarrier as a defaulted parameter, so no existing caller changes behaviour. The connect side was already bounded (~59.5s retry -> ncclRemoteError), so the accept side was the only gap.
  • One deadline for the whole barrier, not one per round.
  • bootstrapIntraNodeBarrierTimeout wraps it; RCCL_DESTROY_BARRIER_TIMEOUT_MSEC tunes it and 0 restores the unbounded wait.

Expiry is non-fatal: commDestroySync already logged the barrier result and continued teardown, so a deadlock degrades into a late PXN connection failure on the peer that never showed. A rank that gives up also stops feeding its own dst, so its peers hit their own deadlines in turn and the host drains in roughly one timeout rather than cascading.

One thing I would like a second opinion on: the default is 120000 ms, which is my own pick and not derived from anything. It wants to be comfortably longer than a slow-but-healthy PXN connect and comfortably shorter than whatever job-level watchdog sits above us. If you have a better-grounded number, I will take it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, and that is what the PR is now -- the filter is gone entirely in b7bc5b61c28, hostRanks is upstream's loop verbatim, and the deadline is the fix. Your framing is what moved it; filtering tries to predict who will not arrive, which only works for the one failure mode we happened to be looking at and only from the side that happens to know.

End-to-end coverage is now up (38d7a5b5e5d, DivergentTeardown_PeerAborts_PxnEnabled_TimesOut), so the mechanism is no longer asserted without a test.

Leaving this open for the one thing I still owe you an answer on: the 120000 ms default. I said it was my own pick and not derived from anything. I can now at least bound it from both sides, though I would still rather have your read than ship my own number unchallenged:

  • Upper bound. PyTorch's ProcessGroupNCCL watchdog is 600s. The destroy deadline must fire well below that or it is never the thing that fires, and the user sees the watchdog's much less specific failure instead.
  • Lower bound. This wants to exceed any legitimate skew between host-local ranks of one comm reaching ncclCommDestroy. That is sub-second in normal operation and seconds under a pathological stall, so 120s is orders of magnitude of headroom.
  • What a false positive costs. A peer merely slow rather than gone loses whatever PXN connection it was establishing. Non-fatal but real, which argues for generous over tight.

That reasoning admits anything from roughly 60s to 300s. I picked the middle. If you have a number grounded in an actual observed PXN connect time I will take it over mine.

// Pinned to NCCL_PXN_DISABLE=1, RCCL's default. With PXN genuinely enabled an
// ncclCommAbort peer still strands the barrier -- that needs a cancellation
// protocol and is tracked separately; do not widen this case without one.
TEST(Teardown, DivergentTeardown_PeerAborts_DoesNotHang)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test pins NCCL_PXN_DISABLE=1 and calls ncclCommAbort with no shrink, so it passes via the PXN skip, not via the filtering. The main new logic of this PR has no end-to-end test. Can we add a real shrink(SHRINK_ABORT) + destroy repro with PXN on that actually hits the filter?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filtering this was asking about is gone as of b7bc5b6, so the specific test you asked for -- shrink(SHRINK_ABORT) + destroy with PXN on, hitting the filter -- no longer has anything to hit.

But I do not want to close this out on that technicality, because the underlying complaint survives the revert: the main new logic of this PR still has no end-to-end test. It is now the timeout rather than the filter, and the gap is the same one you identified. What exists today:

  • DivergentTeardown_PeerAborts_DoesNotHang pins NCCL_PXN_DISABLE=1, so it passes via the PXN skip and never reaches the barrier -- exactly your original point, still true.
  • DivergentTeardown_PeerDestroys_PxnEnabled_Completes pins NCCL_PXN_DISABLE=0 and does run the barrier, but both ranks arrive, so it proves the barrier still works rather than proving the deadline fires.

What is missing is the case that matters: NCCL_PXN_DISABLE=0 plus one rank aborting, asserting that the survivor returns from ncclCommDestroy in about the timeout rather than hanging, and with a non-success rc so the assertion is falsifiable. That is the test that would have failed before this PR and passes after it. I am writing it next, along with host-level coverage of the timeout value actually reaching bootstrapIntraNodeBarrierTimeout and of an expiry not failing teardown.

One caveat I would rather state than have you discover: I cannot run GPU tests from my current checkout -- the compute nodes on this cluster have no path to it -- so the end-to-end case will be verified in PR CI, not locally. I will post the run link rather than just asserting it passes.

Holding this thread open until that is up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test is up, in 38d7a5b5e5d: DivergentTeardown_PeerAborts_PxnEnabled_TimesOut.

NCCL_PXN_DISABLE=0 plus one rank calling ncclCommAbort. Rank 0 genuinely enters the barrier, the peer never arrives, and only the deadline can release it. Before this PR that hung forever.

Two details aimed at the vacuous-pass problem you raised, since "the test passes" is worth very little on its own here:

  • RCCL_DESTROY_BARRIER_TIMEOUT_MSEC is pinned to 5000 in the child. At the 120000 default the runner's own timeout fires first and the case reports a hang rather than the deadline working.
  • Destroy must take at least 4000ms. This is the part that keeps it honest: a prompt return would mean the barrier was skipped via the PXN gate, so the deadline was never exercised -- precisely the "passes via the skip, not via the new logic" failure you called out originally. Without this floor the case would still go green if someone later widened the gate.

Expiry reports ncclRemoteError, which is correct rather than a failure, so this case asserts on duration instead of rc. The other two keep the strict rc == ncclSuccess check from pvallem's thread.

Also removed a comment above DivergentTeardown_PeerAborts_DoesNotHang that still claimed the PXN-on abort case "needs a cancellation protocol and is tracked separately". That was true of the old design and would have warned the next person off writing exactly this test.

Caveat I stated earlier and will restate: I cannot run GPU tests from my checkout, so this is verified by syntax check locally and must be proven in CI. I am not claiming it passes until I can post the run. CI has not run on this branch since 2026-10-08 -- the ci:single-arch label gate explains part of that but not all of it, and I am chasing the rest. Holding this thread open until the run link is here.

@nikitaxgusev

Copy link
Copy Markdown
Contributor

I think its scope has moved beyond AICOMRCCL-2467: the ticket concerns a caller-side c10d bug, and the PyTorch fix makes the test pass without any RCCL changes.

The shrink-abort filtering (abortExcludedRanks) addresses a separate issue and should have its own ticket, I think. The new GPU test, DivergentTeardown_PeerAborts, does not exercise that filtering: it sets NCCL_PXN_DISABLE=1 and never calls shrink, so it passes because the PXN gate skips the barrier.

Could you clarify what remains to be addressed and the goal of this PR, move the filtering work to a separate ticket.
I don't get? who will review the commDestroySync diff against next upstream during the next NCCL sync?

Revert the abort-excluded-rank filtering from 7118726 and replace it
with a deadline on the barrier itself.

Filtering was unsound. The exclude list exists only in the surviving
processes, so a rank removed by NCCL_SHRINK_ABORT never learns it was
dropped: it still holds its parent handle, still enters the full barrier,
and waits for peers that already rendezvoused without it. That moves the
hang onto the excluded rank rather than removing it, and regresses the
GrowMPITests Grow_ThenShrinkAbort shape wherever PXN is on. Any membership
derived from survivor-only knowledge has this flaw, including the earlier
hasExcludedHostRank skip.

What is left is two layers that both hold without agreement between ranks:

1. The PXN gate, unchanged from baf39df. The barrier protects PXN
   connection establishment only, and ncclPxnDisable() is derived from
   arch, nRanks and env, so every rank computes it identically. The
   hostRanks loop is restored to upstream verbatim.

2. A deadline. A peer can fail to arrive for reasons no membership
   calculation can predict - ncclCommAbort elsewhere in the process, a
   rank that died outright - so the barrier now gives up and reports an
   error rather than blocking forever. Expiry was already non-fatal here:
   the result is logged and teardown continues, degrading a deadlock into
   a late PXN connection failure on the peer that never showed.

The deadline is plumbed as a defaulted parameter through ncclSocketAccept,
bootstrapAccept, socketAccept and bootstrapRecvDeadline, so no existing
caller changes behaviour. One deadline covers the whole barrier rather
than each round. A rank that gives up stops feeding its own dst, so its
peers hit their own deadlines in turn and the host drains within roughly
one timeout instead of cascading.

RCCL_DESTROY_BARRIER_TIMEOUT_MSEC tunes it; 0 restores upstream's
unbounded wait.

comm.h is back to no change at all against develop.
Three claims in the comments added by b7bc5b6 did not survive being
checked.

"One deadline for the whole barrier ... so the total wait is what the
caller asked for" was false. bootstrapSend keeps its own connect retry
per round, so the worst case is the deadline plus that retry. Says so
now.

"Degrades a deadlock into a late PXN connection failure on the peer" was
asserted, not verified. It happens to be true, and now carries the
evidence: the proxy service thread force-closes its peer and listen
sockets on stop (proxy.cc:2090-2095, 2108), so the peer gets a closed
socket or ECONNREFUSED rather than hanging. Worth recording because the
opposite was plausible -- ncclProxyStop does call ncclShmClose on the
proxy ops pool, which looks like it could unmap a segment peers still
hold. It cannot: a client attach leaves handle->shmPath NULL and
ncclOsShmClose unlinks only when it is non-NULL (os/linux.cc:777, 892),
so only this rank's own view is released. Teardown is also refCount-
gated (proxy.cc:2284), so sibling comms sharing the proxy are untouched.

"A rank that died outright" was listed as a case needing the timeout. It
is not: the kernel closes a dead process's sockets, so the recv already
errors out. The case that actually needs this is a peer that is alive but
not participating, having taken the abortFlag branch and skipped the
barrier. That also explains why the ack read in socketAccept is left
unbounded -- reaching it means a peer completed the TCP connect, which a
dead process cannot do.

The consequence worth naming: an alive-but-not-participating peer is
indistinguishable from a slow one, so expiry is a guess, and a wrong
guess is paid for by a healthy peer. That makes the default a
correctness-relevant choice rather than a cosmetic one, so it is now
justified in place -- 120s is far above the expected skew between
host-local ranks reaching destroy, and below PyTorch's 600s watchdog,
beyond which it could never be the thing that fires.

No functional change.

@amd-argus amd-argus Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ARGUS

Bottom line: Worth settling before approval: rccl-HostUnitTests will not link, because test/host/bootstrap_wrapper.cc:24 still stubs the three-parameter ncclSocketAccept.

Comment @argus review to re-run, or @argus re-review after pushing.

Comment thread projects/rccl/src/include/socket.h Resolved
@prasanna-amd prasanna-amd changed the title fix(rccl): skip bootstrapIntraNodeBarrier when it would deadlock fix(rccl): gate and bound the destroy-path intranode barrier Oct 14, 2026
The deadlineNs parameter added to ncclSocketAccept left the host-test link
stub at the old three-parameter form. Default arguments are not part of a
function's type, so bootstrap.cc's calls resolved to a symbol nothing
defined and rccl-HostUnitTests failed to link.
Five microtests over commDestroySync: the barrier runs with the configured
deadline when PXN is on, is skipped when PXN is off or the comm is aborting,
and on expiry still stops the proxy while reporting the error. Each was
checked against a mutated guard to confirm it fails for the right reason.

Also resets the recording fake between tests, and drops the peerInfo
scaffolding in Grow_ParentComm left over from the reverted membership filter.
The PXN-on + peer-abort case is the one the gate does not cover: rank 0
genuinely enters the barrier and only the deadline can release it. Pins
RCCL_DESTROY_BARRIER_TIMEOUT_MSEC low so the runner does not time out
first, and requires destroy to take at least that long -- a prompt return
would mean the barrier was skipped and the deadline never exercised.

Also drops a comment that still claimed this case needed a cancellation
protocol, which the deadline supersedes.
@prasanna-amd

Copy link
Copy Markdown
Contributor Author

@nikitaxgusev -- overdue reply to your questions from 10-08. Taking them in order, because two of them dissolved when the design changed and two did not.

"Move the filtering work to a separate ticket." No ticket needed -- the filtering is gone. Reverted in b7bc5b61c28 because it was unsound, not merely out of scope: the exclude list exists only in the surviving processes, so an NCCL_SHRINK_ABORT-excluded rank still enters the full barrier while survivors rendezvous without it. The filter relocated the hang rather than removing it. comm.h is a zero-line diff against develop.

"DivergentTeardown_PeerAborts does not exercise the filtering." Correct then, and the more durable form of the complaint -- that the main new logic had no end-to-end test -- was still true after the revert, just about the deadline rather than the filter. Fixed in 38d7a5b5e5d: DivergentTeardown_PeerAborts_PxnEnabled_TimesOut runs NCCL_PXN_DISABLE=0 with an aborting peer, so the barrier is genuinely entered and only the deadline releases it. It also requires destroy to take at least the barrier timeout, so it cannot go green via the PXN skip -- which is exactly the vacuous pass you identified.

"Who will review the commDestroySync diff against next upstream during the next NCCL sync?" A fair worry, and the reason the hunk is now shaped the way it is. Upstream's three comment lines are back verbatim inside the if, untouched; everything RCCL-specific sits above it in a block that opens // RCCL: and enumerates the two divergences with their ticket numbers. So the next sync sees an added block adjacent to unmodified upstream text, not rewritten upstream text. The functional divergence is two things and both are stated there: a && ncclPxnDisable(comm) != 1 on the guard, and bootstrapIntraNodeBarrierTimeout in place of bootstrapIntraNodeBarrier.

"The ticket concerns a caller-side c10d bug, and the PyTorch fix makes the test pass without any RCCL changes." This is the one I cannot close out, and I would rather say so than hand-wave it. I have not verified it -- you did not cite a PR number and I have not found the change you mean. Could you point me at it?

Two things I would want to separate before concluding it makes this PR unnecessary:

  1. Whether the PyTorch fix makes DistributedDataParallelTest.test_nccl_backend_4gpu_module pass is a question about AICOMRCCL-2468, and I agree the attribution there is unproven -- that run's last log line is Init START, which points at init, not destroy. I have said as much on 2468 and it stays open.
  2. Whether RCCL should block forever when a host-local peer does not arrive at a barrier is a separate question, and a caller-side fix does not answer it. The gdb backtrace in the description is from a plain two-rank reproducer with no PyTorch in the picture: rank 1 calls ncclCommAbort, rank 0 sits in accept indefinitely. The passing stagger control rules out "it is just slow". Any application that aborts one rank and destroys another reaches this, and a 600s watchdog above us does not make an unbounded wait inside us correct.

So I would frame the PR as: it does not depend on the 2468 attribution and should not be judged on it. If your PyTorch fix does resolve the c10d case, that is good and orthogonal -- happy to retitle to drop the 2467 framing if the ticket is genuinely caller-side.

@amd-argus amd-argus Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ARGUS

Bottom line: LGTM.

Comment @argus review to re-run, or @argus re-review after pushing.

@prasanna-amd

Copy link
Copy Markdown
Contributor Author

Withdrawing the question I just asked -- I found it: pytorch/pytorch#200372, "[c10d] Abort the parent group in shrink_group with SHRINK_ABORT" (open, fixes pytorch#194679). @nikitaxgusev, you were right and I should not have made you supply the reference.

Its description names the mechanism independently of anything in this PR:

On NCCL 2.31+, a graceful destroy runs bootstrapIntraNodeBarrier in commDestroySync, which waits for the excluded ranks that already aborted, so test_shrink_group_flags hangs.

That is the same barrier and the same arrival asymmetry as the gdb trace in the description, reached from the caller side. So I accept your point: this PR should not be justified on AICOMRCCL-2467. The c10d-specific hang has a caller-side fix and does not need RCCL to change.

What I think survives, and what I would like the PR judged on:

  1. #200372 fixes the one caller that was known to abort-then-destroy. It does not make the barrier bounded. Any application that calls ncclCommAbort on one rank and ncclCommDestroy on a host-local peer still blocks in accept indefinitely -- the two-rank reproducer here has no PyTorch in it at all.
  2. The PXN gate is independent of all of this and defensible on its own: RCCL defaults NCCL_PXN_DISABLE=1, so on the overwhelming majority of comms the barrier is a deadlock risk protecting nothing.

Concretely: happy to retitle and refile this against a new ticket for the general unbounded-barrier case, and let AICOMRCCL-2467 be closed as caller-side once #200372 lands. Your call on whether that is cleaner than keeping the existing number -- say which and I will do it.

(AICOMRCCL-2468 is unaffected either way. Its attribution remains unproven and open; that run fails at Init START, not destroy.)

@alex-breslow-amd alex-breslow-amd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

prasanna-amd added a commit that referenced this pull request Oct 29, 2026
…13789)

JIRA ID: AICOMRCCL-2468

## Summary

The `whl-multi-arch` index on `rocm.nightlies.amd.com` **stopped
publishing torch on 2026-08-22**. It still returns 200 and still serves
wheels, so nothing failed — `uv` just resolves to the newest wheel the
index offers, which has not moved since August.

Every `pytorch_c10d` CI run since 2026-09-12 has installed the identical
build:

```
2.15.0a0+rocm10.1.0a20260822
```

So for two months this job has been reporting green against a
two-month-old PyTorch while appearing to test nightlies.

This points the install at the current publishing location,
`nightly.repo.amd.com/rocm/pytorch/whl-next/`, which today resolves
`2.16.0a0+rocm10.2.0a20261019`.

## Why the extra index is required

The `device-gfx942` extra resolves `rocm-sdk-device-gfx942`, which on
the new host is published to `rocm/core/whl-next/`, **not**
`rocm/pytorch/whl-next/`. With a single `--index-url` the resolve fails
outright:

```
And because you require torch[device-gfx942], we can conclude that your
requirements are unsatisfiable.
```

With both indexes it resolves cleanly (verified via `uv pip install
--dry-run`):

```
+ torch==2.16.0a0+rocm10.2.0a20261019
+ rocm-sdk-core==10.2.0a20261019
+ rocm-sdk-device-gfx942==10.2.0a20261019
+ rocm-sdk-libraries==10.2.0a20261019
+ triton==3.9.0+gitfc8d31a5.rocm10.2.0a20261019
```

## Test sources follow automatically


`projects/rccl/ci/scripts/test_pytorch_c10d.py::clone_pytorch_test_sources`
date-pins the cloned PyTorch test tree off the installed wheel's version
string (the `--shallow-since` path). The test tree therefore moves
forward with the wheel — no separate bump needed here.

## Relationship to AICOMRCCL-2467

`test_shrink_group_flags` has been timing out at 300 s in CI. The
upstream teardown fix is pytorch/pytorch#194775 (landed 2026-10-13),
which the frozen August wheel cannot possibly contain. Because the test
sources are date-pinned off the wheel, this change pulls in both the
fixed runtime and the updated test.

This is complementary to #12446 (the RCCL-side destroy-path barrier
fix), not a replacement for it.

## Deliberately floating, not pinned

This tracks the latest nightly rather than pinning an exact build, which
restores the originally intended behaviour of this job. The tradeoff is
that an upstream nightly regression can turn CI red; that is preferred
here over silently testing stale wheels, which is the failure mode this
PR exists to fix.

## Test plan

- [ ] `therock-rccl-test-pytorch-distributed.yml` dispatched on this
branch, `test_scope: all`
- [ ] Confirm the run log reports a `2.16.0a0+rocm10.2.0a2026101x`
version rather than `...20260822`
- [ ] Confirm `test_shrink_group_flags` no longer times out

---------

Co-authored-by: renjithravindrankannath <94420380+renjithravindrankannath@users.noreply.github.com>
@prasanna-amd

Copy link
Copy Markdown
Contributor Author

Closing: not needed. The test_shrink_group_flags hang (AICOMRCCL-2467) is fixed on the PyTorch side by pytorch/pytorch#194775 (aborts the shrink parent instead of destroying it), and the related teardown hangs by pytorch/pytorch#200673. No RCCL change is required for the reported failures.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants