nccl_ep: generation-tag LOW_LATENCY P2P signals to prevent stale-signal consumption - #2306
nccl_ep: generation-tag LOW_LATENCY P2P signals to prevent stale-signal consumption#2306Oseltamivir wants to merge 1 commit into
Conversation
…ption The LOW_LATENCY count/flag protocol double-buffers signal slots and polls for a nonzero value, but the signal values carry no generation: a signal left over from the previous use of the same parity buffer (two calls earlier) is indistinguishable from a fresh one whenever the workload repeats (per-pair token counts identical; finish flags are the constant 1). If any rank falls one full parity cycle behind its peers -- any scheduling hiccup suffices -- the peers consume its stale signals, complete their calls without it, and lap it arbitrarily. The pipeline desynchronizes and eventually wedges with systematic 'NCCL EP timeout for dispatch/combine receive' warnings followed by trap() -> cudaErrorLaunchFailure. Observed as a deterministic failure of LOW_LATENCY mode on GB200/GB300 NVL72 (fails within hundreds of iterations at any token count) and as the rare top-rung combine corruption on x86 platforms. Root-caused with a device-side event ring: at the wedge, ranks were executing different call sequences (two ranks in dispatch NVIDIA#12, one in NVIDIA#11, one still in combine NVIDIA#9) with receivers recording counts 'from' the lagging rank for calls it never executed. Fix: tag every P2P signal value with a per-op generation and poll for the exact generation, making stale signals inert: count: (gen << 16) | (numTokensSent + 1) poll: value >> 16 == gen flag: (gen << 16) | 1 poll: value >> 16 == gen The generation is stamped per launch via cudaMemcpyToSymbolAsync from pinned staging (stream-ordered; a SEND-phase launch advances it, a RECV-only launch reuses it). The GIN path is unchanged (its accumulating signals have different semantics). Generation state is process-global in this change; moving it into per-group workspace is a follow-up. Validated on GB200 NVL72 (4 GPU single tray and 8 GPU across two trays, world in one NVLink domain): previously wedging runs now complete the full decode ladder (T=1..256, 2048 samples per point) with correctness checks passing.
Every nccl-ep low-latency leg wedges. The wheel's LL count/flag protocol double-buffers signal slots and polls for a nonzero value, but the values carry no generation: at a repeating workload a signal left over from the previous use of the same parity buffer is bit-identical to a fresh one, so a rank that slips one parity cycle behind is lapped by peers consuming its stale signals. The pipeline desynchronises and wedges on dispatch/combine receive timeouts, then traps the kernel (cudaErrorLaunchFailure). Reported upstream as NVIDIA/nccl#2303 and fixed by NVIDIA/nccl#2306. This was known on GB200/GB300, which is why those SKUs never carried ll_backends rows. It is now confirmed on x86 too: 5/5 reproductions across h100-dgxc, b300 and b200-dgxc through the real launcher over SSH, and 4/4 in sweep run 30155842613 (h100, h200, b200, b300), with a laggard rank that is stable per node across re-runs. The upstream report's claim that low-latency "works on classic intra-node NVLink (x86_64)" does not hold. Nothing in this tree can fix it: prepare_backend.sh installs the published nccl4py wheel, and the from-source arm that could carry a patched contrib/nccl_ep is still deferred. Normal (high throughput) mode is unaffected and stays green on all six NVIDIA SKUs, EP8 everywhere plus EP16 on the GB SKUs. Remove the rows rather than leave them dispatching known failures, so the sweep reflects what the backend can actually do. Restore them together with a COLLX_NCCL4PY_SPEC bump that contains the fix; test_nccl_ep_rollout_shape now pins the absence and records that condition.
* CollectiveX: add NCCL EP as a fourth pluggable EP backend
NVIDIA's native MoE dispatch/combine on the NCCL Device API (nccl/contrib/nccl_ep,
consumed as the published nccl4py[cu13]==0.3.1 wheel, libnccl_ep v0.1.0), wired in
behind the same case/shard codec as deepep-v2, mori and uccl-ep.
- bench/ep_nccl.py: the adapter. NVIDIA-only, cu130, BF16-only (upstream RELEASE.md
lists its FP8 machinery unsupported). HT = HIGH_THROUGHPUT/FLAT with the harness's
unweighted rank-sum combine; LL = LOW_LATENCY/EXPERT_MAJOR. Forms its own NCCL
communicator and uses the torch process group only to broadcast that communicator's
unique id, so no MPI is introduced.
- runtime/prepare_backend.sh: nccl_ep_{spec_slug,cache_root,activate,probe,install,
prepare} - a pip install into an isolated $root/site keyed by cpu/arch/image/spec
under the shared /cx-cache, guarded by flock + a .ready marker, with a node-local
fallback for manual runs. Mirrors the uccl_* seam.
- runtime/common.sh: pins the wheel spec, plus the repo/commit for a from-source
fallback that stays deferred (contrib/nccl_ep is absent from the release tags, so
such a build must use the post-merge master commit recorded here).
- launchers: launch_single-slurm.sh and launch_gb-nv.sh accept the backend; the GB
launcher's pinned-DeepEP source stage becomes conditional, since nccl-ep is pip-only.
- configs/platform_config.json: h100/h200/b200-dgxc and b300 get nccl-ep:[8];
gb200/gb300 get nccl-ep:[8,16]. EP16 on the x86 SKUs is deliberately absent - the
cross-node RDMA path fails inside NCCL EP v0.1.0's GDAKI GIN transport with an
illegal memory access at nccl_ep.cc:2884, identically over RoCE and IB, so it is an
unsupported coverage row rather than a dispatched cell. EP16 is scheduled only where
it stays inside the scale-up domain (MNNVL/NVL72).
- sweep_matrix.py emits BF16 cases only; the sweep workflow gains the backend option.
- tests/: rollout-shape and case-argv round-trip coverage, extending the existing
seam-contract pattern.
Validated on metal through the production launchers (not CI): EP8 normal green on
h100-dgxc, b200-dgxc, b300, gb200 and gb300, plus EP16 normal green on gb200 and
gb300 over MNNVL, every point correctness-passing across the full decode and prefill
ladders; h200-dgxc EP8 was validated in an earlier pass and its cluster was saturated
during the latest one.
Known issue, tracked upstream: the low-latency path wedges. Registry rows still list
ll_backends nccl-ep:[8] on the x86 SKUs, but every low-latency attempt on h100-dgxc,
b300 and b200-dgxc times out its dispatch receives and traps the kernel
(cudaErrorLaunchFailure). This is the stale-signal/pipeline-desync race in the wheel's
LL count/flag protocol reported as NVIDIA/nccl#2303 and fixed by NVIDIA/nccl#2306; it
cannot be fixed from this tree, which installs the published wheel. Those rows should
be dropped or gated in a follow-up unless a fixed wheel lands first.
* CollectiveX: put the mi-amds uccl-ep backend cache on shared storage
launch_mi-amds.sh prepared the from-source backend cache under COLLX_SQUASH_DIR. That works for
the single-slurm launcher, whose squash dir sits on shared storage, but this SKU's squash dir is
node-local and root-owned (/var/lib/squash: drwxr-xr-x root root on the runner host,
drwxrwxrwx on the compute nodes), so it failed twice over:
- the submit-side mkdir was denied to the runner account, which is the observed failure
(PermissionError: '/var/lib/squash/.collectivex-backend-cache-<uid>' out of probe.py's
prepare_cache); and
- even granting root there, the directory created on the submit host is not the one the
compute node bind-mounts, because the path is node-local.
Use COLLX_STAGE_DIR as the cache parent instead. On this SKU it resolves to the runner-shared
stage base, which is runner-owned and on the cluster's shared filesystem (verified visible from
a compute node), and the cache lands beside job_<tag> rather than inside it, so the
build-once/reuse-per-allocation behaviour survives stage cleanup between allocations.
Only uccl-ep takes this path; mori ships in the image and needs no cache, which is why mori legs
were green on this SKU while both uccl-ep legs failed in sweep run 30155842613.
* CollectiveX: drop the nccl-ep low-latency rows until a fixed wheel ships
Every nccl-ep low-latency leg wedges. The wheel's LL count/flag protocol double-buffers signal
slots and polls for a nonzero value, but the values carry no generation: at a repeating workload a
signal left over from the previous use of the same parity buffer is bit-identical to a fresh one,
so a rank that slips one parity cycle behind is lapped by peers consuming its stale signals. The
pipeline desynchronises and wedges on dispatch/combine receive timeouts, then traps the kernel
(cudaErrorLaunchFailure). Reported upstream as NVIDIA/nccl#2303 and fixed by NVIDIA/nccl#2306.
This was known on GB200/GB300, which is why those SKUs never carried ll_backends rows. It is now
confirmed on x86 too: 5/5 reproductions across h100-dgxc, b300 and b200-dgxc through the real
launcher over SSH, and 4/4 in sweep run 30155842613 (h100, h200, b200, b300), with a laggard rank
that is stable per node across re-runs. The upstream report's claim that low-latency "works on
classic intra-node NVLink (x86_64)" does not hold.
Nothing in this tree can fix it: prepare_backend.sh installs the published nccl4py wheel, and the
from-source arm that could carry a patched contrib/nccl_ep is still deferred. Normal (high
throughput) mode is unaffected and stays green on all six NVIDIA SKUs, EP8 everywhere plus EP16 on
the GB SKUs.
Remove the rows rather than leave them dispatching known failures, so the sweep reflects what the
backend can actually do. Restore them together with a COLLX_NCCL4PY_SPEC bump that contains the
fix; test_nccl_ep_rollout_shape now pins the absence and records that condition.
* CollectiveX: reap stray -tw containers before a leg touches the GPUs
A case container can outlive its GHA job. `docker run --rm` removes a container only when it
exits, so when Actions kills the runner's process tree - job cancellation, or the 350-minute
timeout firing on a wedged run - the daemon keeps it alive, and the workflow's non-root cleanup
step cannot remove a root-owned container.
The stranded container pins every GPU on the node, and what it breaks is not obvious from the
symptom: hipIpcGetMemHandle begins returning "invalid argument" during RCCL setup for any job with
three or more ranks, while one- and two-rank jobs keep passing, so the node looks healthy under a
casual probe. Both -tw nodes have now hit it. tw032 carried a wedged MoRI low-latency leg from run
29736782796 for five days, which failed all four of its legs in run 30155842613 across both mori
and uccl-ep; removing the container restored 4- and 8-rank RCCL immediately. tw018 then stranded
one the same way in run 30165164821 when a MoRI normal leg hung to the job timeout. A wedged
low-latency run is a known class on this stack, so each occurrence poisons a node until someone
notices and clears it by hand.
Reap at launcher start rather than on exit: the stranding case is precisely the one where the
launcher is killed and its own traps never run, so the next leg on that node is the only reliable
place to clean up. The -tw fleets run one runner per node, so any container from the pinned image
that predates this launcher belongs to a finished leg; the comment records what to tighten if a
pool ever puts two runners on one node. Containers are also labelled with the execution id now, so
that narrower predicate is available and stray containers are traceable to the leg that made them.
* CollectiveX: document NCCL EP in the README and methodology
The backend landed without its doc rows, leaving two statements false: both files
claimed an FP8 dispatch on every backend, and neither the backend scope table nor
the ll_backends prose mentioned NCCL EP.
Scope the FP8 claim to the backends that support it, add a NCCL EP scope row, and
record why it carries no low-latency cell (NVIDIA/nccl#2303) and why x86 EP16 is an
unsupported coverage row.
|
Thanks for the PR. NCCL-EP is moved to the nccl-extensions repo. Would you like to migrate the PR over? We can also help making a copy. |
|
Migrated to NVIDIA/nccl-extensions#6 — thanks @kwen2501. The port is not a straight copy: it is rebased onto The root-cause description has also been corrected. This PR attributed the failure to a rank lapping a parity cycle and said LL worked on x86; both were wrong. The real trigger is two LL handles on one group aliasing the same parity signal slots — Closing in favour of the migrated one. |
Fixes issue #2303: the
LOW_LATENCYreceive-timeout failure reported in #2303 (LL dispatch/combine receives time out on GB200/GB300 NVL72 while HT works).Root cause
The LL count/flag protocol double-buffers signal slots and polls for a nonzero value, but signal values carry no generation: a signal left over from the previous use of the same parity buffer (two calls earlier) is bit-identical to a fresh one whenever the workload repeats — per-pair token counts are workload-determined, and finish flags are the constant
1. If any rank falls one full parity cycle behind (any scheduling hiccup suffices), its peers consume the stale signals, complete their calls without it, and lap it arbitrarily. The pipeline desynchronizes and wedges with the familiar systematicWarning: NCCL EP timeout for dispatch/combine receive→trap()→ error 719.We root-caused this with a device-side event ring (globaltimer-stamped records of DISP/COMB start, clean-done, count/flag sent/got, dumped at first timeout). At the wedge the four ranks were executing different call sequences — two ranks in dispatch #12, one in #11, one still in combine #9 — while receivers had recorded
CNT_GOT"from" the lagging rank for dispatch calls it never executed: stale counts from two calls earlier, value-indistinguishable at a fixed workload.This explains the platform asymmetry: on GB200/GB300 the failure is reproducible within hundreds of iterations at any token count (fast iterations make the 2-call lap easy), while on x86 single-node platforms the same defect surfaces only rarely as top-load combine corruption. Any µs-scale perturbation of the kernels (e.g. printf instrumentation) masks it — which made it look like a memory-ordering issue. It is not one: the CUDA-level primitives check out (we validated
st.release.sys/ld.acquire.sysposted-store visibility across cuMem FABRIC and POSIX-FD peer mappings on GB200 in isolation), and neither system-scope atomics for the signal stores nor__threadfence_system()after the buffer cleans changes the outcome.Fix
Tag every P2P signal value with a per-op generation and poll for the exact generation, so stale signals are inert:
The generation is stamped per launch (stream-ordered
cudaMemcpyToSymbolAsyncfrom pinned staging; a SEND-phase launch advances it, a RECV-only launch reuses it). The GIN path is untouched — its accumulating signals have different semantics. This also removes the protocol's sensitivity to clean-vs-arrival ordering on the recycled parity buffer.Notes for review:
numTokensSent + 1fits 16 bits up to 65534 dispatch tokens per rank; anEP_HOST_ASSERTguard could be added if desired.Validation
GB200 NVL72 (aarch64, CUDA 13.0 driver, nvcc 13.3, world within one NVLink domain):
One pre-existing rarity remains out of scope: an occasional combine-correctness flake at exactly
max_dispatch_tokens_per_rank(top rung), which we also observe on x86 with the unmodified wheel.