Skip to content

nccl_ep: generation-tag LOW_LATENCY P2P signals to prevent stale-signal consumption - #2306

Closed
Oseltamivir wants to merge 1 commit into
NVIDIA:masterfrom
Oseltamivir:fix/ll-generation-tagged-signals
Closed

nccl_ep: generation-tag LOW_LATENCY P2P signals to prevent stale-signal consumption#2306
Oseltamivir wants to merge 1 commit into
NVIDIA:masterfrom
Oseltamivir:fix/ll-generation-tagged-signals

Conversation

@Oseltamivir

@Oseltamivir Oseltamivir commented Jul 23, 2026

Copy link
Copy Markdown

Fixes issue #2303: the LOW_LATENCY receive-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 systematic Warning: NCCL EP timeout for dispatch/combine receivetrap() → 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.sys posted-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:

count: (gen << 16) | (numTokensSent + 1)     poll accepts iff value >> 16 == gen
flag:  (gen << 16) | 1                        poll accepts iff value >> 16 == gen

The generation is stamped per launch (stream-ordered cudaMemcpyToSymbolAsync from 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:

  • Generation state is process-global in this change; moving it into per-group workspace (or plumbing it as a kernel argument) is a straightforward follow-up we're happy to do if you prefer that shape.
  • numTokensSent + 1 fits 16 bits up to 65534 dispatch tokens per rank; an EP_HOST_ASSERT guard could be added if desired.

Validation

GB200 NVL72 (aarch64, CUDA 13.0 driver, nvcc 13.3, world within one NVLink domain):

  • Before: LL wedges within hundreds of iterations (both world=4 single tray and world=8 across two trays), 100% reproducible.
  • After: full decode ladder T=1..256, 2048 samples per point, dispatch+combine correctness checks passing; world=4 and world=8 validated.
  • HT behavior unchanged.

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.

…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.
Oseltamivir added a commit to SemiAnalysisAI/InferenceX that referenced this pull request Jul 25, 2026
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.
Oseltamivir added a commit to SemiAnalysisAI/InferenceX that referenced this pull request Jul 27, 2026
* 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.
@kwen2501

Copy link
Copy Markdown
Collaborator

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.

@Oseltamivir

Copy link
Copy Markdown
Author

Migrated to NVIDIA/nccl-extensions#6 — thanks @kwen2501.

The port is not a straight copy: it is rebased onto ll_ep.cuh / ll_ep_adapter.cu, and the generation now travels as a kernel argument (threaded like signalsBase) rather than a __device__ symbol, since the LL kernels over there are JIT-compiled and a symbol in the AOT module is not the one the kernel would read.

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 — handle->ll.buffer_idx is per-handle while its layout offsets point into the per-group rdma_buffer — and it reproduces on x86 too. Full details in #2303 and on the new PR.

Closing in favour of the migrated one.

@Oseltamivir Oseltamivir closed this Aug 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants