Skip to content

[Issue]: Deadlock between ncclLocalOpAppend and the proxy progress thread when the proxy op pool is exhausted #2353

Description

@Cryspia

How is this issue impacting you?

Application hang

Share Your Debug Logs

NCCL_DEBUG=INFO was enabled for the reproductions. NCCL logs nothing at all
when the hang occurs — no WARN, no timeout, no async error. The last NCCL output
is from initialisation, and it looks entirely healthy: 16 collective channels
alternating across the two rails.

NCCL INFO 16 coll channels, 16 collnet channels, 0 nvls channels, 16 p2p channels
NCCL INFO Channel 00/0 : 0[0] -> 1[0] [send] via NET/IB/0
NCCL INFO Channel 01/0 : 0[0] -> 1[0] [send] via NET/IB/1
NCCL INFO Channel 02/0 : 0[0] -> 1[0] [send] via NET/IB/0
...  (even channels on IB/0, odd on IB/1)

The silence is expected given the mechanism: nothing detects the condition. So
the useful evidence is thread state at the moment of the hang, captured with
eu-stack on both ranks.

Rank 1, main thread — blocked in the append path. Note it is still inside
ncclEnqueueCheck, so the kernel for this collective was never launched:

#0  __sched_yield
#1  ncclLocalOpAppend(ncclComm*, ncclProxyConnector*, ncclProxyOp*)
#2  SaveProxy(ncclComm*, ncclChannel*, int, int, ncclProxyOp*, int, bool*)
#3  ncclProxySaveOp(ncclComm*, ncclProxyOp*, bool*)
#4  uploadProxyOps(ncclComm*, ncclKernelPlan*)
#5  hostStreamPlanTask(ncclComm*, ncclKernelPlan*)
#6  ncclLaunchKernelAfter_NoCuda(ncclComm*, ncclKernelPlan*)
#7  groupLaunch(ncclAsyncJob*, ncclSimInfo_v22200*)
#8  ncclGroupEndInternal(ncclSimInfo_v22200*)
#9  ncclEnqueueCheck(ncclInfo*)
#10 pncclAllGather

Rank 1, proxy progress thread — asleep at the same instant:

#0  <futex>
#1  pthread_cond_wait
#2  ncclProxyGetPostedOps(ncclProxyState*, int*)
#3  ncclProxyProgress(void*)

Rank 0, proxy progress thread — idle with no work (two samples 8 s apart,
identical):

#0  __sched_yield
#1  ncclProxyProgress(void*)

The stalled collective is ncclAllGather, 16x64640 bfloat16 (~2 MB).

Fabric counters while hung — port_xmit_data on both rails of both nodes,
sampled three times over 9 seconds, byte-for-byte unchanged, while both GPUs sit
at 96% utilisation:

02:15:32Z  head_xmit=162459179474  worker_xmit=127395400347  gpu 96% / 96%
02:15:36Z  head_xmit=162459179474  worker_xmit=127395400347  gpu 96% / 96%
02:15:41Z  head_xmit=162459179474  worker_xmit=127395400347  gpu 96% / 96%

Collectives are correctly matched. We instrumented torch.distributed to log
every collective's op, shape and dtype on both ranks: 162696 comparable
collectives, 0 mismatches, identical order
. (Comparing call counts alone
proves nothing here, since NCCL pairs by enqueue order — a single differing op
would deadlock while both counters stayed equal, so the identity has to be
compared.)

Every IB error counter is zero on both rails and both nodes — no
port_rcv_errors, port_xmit_discards, local_link_integrity_errors,
symbol_error, link_downed, and no out_of_sequence / packet_seq_err /
req_cqe_error in hw_counters.

Not yet attached: NCCL_TOPO_DUMP_FILE output. Happy to provide it — say the
word and we will restart with it set. Also happy to share the full per-rank
eu-stack dumps and the raw collective-identity traces.

Steps to Reproduce the Issue

The cycle, from the source

All three legs are in src/proxy.cc. Line numbers for 2.28.9 (what we run)
and v2.31.2-1 (latest release, code unchanged in substance):

leg 2.28.9 v2.31.2-1 waits for
appender spins for a free op proxy.cc:498 proxy.cc:507 the proxy to return a free op
proxy sleeps proxy.cc:797 proxy.cc:850 pool->nextOps != -1, i.e. something posted
the only signal ncclProxyPost, proxy.cc:468 proxy.cc:476 ++proxyOps->count == MAX_OPS_PER_PEER (:516 / :525)

In ncclLocalOpAppend:

  int opIndex = proxyOps->freeOp;
  if (opIndex != -1) {
    ...
  } else {
    // Read the freeOps value and wait for a value different than -1. ...
    int freeOp = -1;
    while (freeOp == -1) {
      freeOp = __atomic_exchange_n(&pool->freeOps[tpLocalRank], -1, __ATOMIC_ACQUIRE);
      if (freeOp == -1) sched_yield();      // <-- blocks here forever
    }
    ...
  }
  ...
  if (++proxyOps->count == MAX_OPS_PER_PEER) {
    ...
    NCCLCHECK(ncclProxyPost(proxyOps->pool, nextOps, lastOp));   // <-- the only wakeup
  }

At the moment it blocks, this thread holds up to MAX_OPS_PER_PEER - 1 ops in its
local chain (proxyOps->nextOps .. nextOpsEnd) that have not been posted. So
pool->nextOps stays -1, the proxy stays asleep, no ops are freed, count
never reaches MAX_OPS_PER_PEER, and the post that would break the cycle is
unreachable. The appender waits for the proxy; the proxy waits for the appender.

This is not the condition-variable lost wakeup that v2.31.2's new predicate
addresses:

// v2.31.2-1, proxy.cc:849
// Predicate avoids lost wakeups and guarantees we observe stop after ncclProxyProgressDestroy.
pool->cond.wait(lock, [&]() { return pool->nextOps != -1 || state->stop.load(...) != 0; });

That predicate is correct. It does not help here, because nothing ever posts and
so the predicate simply stays false.

What it takes to trigger

One rank must get far enough ahead of its peer to exhaust the pool
(MAX_OPS_PER_PEER = 2*MAXCHANNELS*2*NCCL_MAX_DEV_WORK_P2P_PER_BATCH = 2048).

That is not an exotic state. Collective calls enqueue and return, so any rank that
does not synchronise pulls ahead of one that does. In our workload, rank 0
synchronises each step to copy sampled tokens back to the host and rank 1 does
not, leaving rank 1 a steady ~61 collectives ahead on every healthy step. With 16
channels active, a few hundred queued collectives reaches 2048 proxy ops.

We do not have a standalone NCCL-only reproducer. What reproduces it reliably is:
2 ranks, 1 GPU each, on separate nodes, TP=2 over RoCE, driving a stream of small
collectives where one rank synchronises per iteration and the other does not, with
frequent mid-iteration cancellation so the queue depth keeps varying. We are glad
to try a minimal nccl-tests-style reproducer if that would help — a loop where
rank 0 calls cudaStreamSynchronize each iteration and rank 1 does not, run long
enough for rank 1's lead to reach ~2048 proxy ops, looks like it should suffice.

Intermittency

Timing-sensitive but frequent enough to be practical to hunt:

requests to reproduce
unpatched 23, 38, 60, 102, 109, 188, 248 — 7 of 8 runs hung
patched 1546 requests over 150 min, 0 hangs

Same seed and settings in every run. The single unpatched run that did not hang
(431 requests) was also the only one with our tracing disabled — extra logging
load makes it markedly more likely, which is what an ordering bug of this shape
would predict. Under ordinary production load, without any tracing, it occurred
roughly once every 24-33 hours.

Previous versions

Unknown — this is the first NCCL version we have run this workload on. The code
path is unchanged in the latest release, so we would not expect any released
version to differ.

Suggested fix

Post the accumulated chain before blocking. That signals the proxy, which consumes
those ops and returns free ones, so the appender can proceed. The existing
MAX_OPS_PER_PEER branch already has exactly the right logic — including the rule
that the trailing ops of the current opCount must be held back, since "posting
them in different batches would break proxyArgs aggregation with subs" — so it can
be factored out and reused:

static ncclResult_t ncclLocalOpFlush(struct ncclProxyOps* proxyOps, bool* posted) {
  struct ncclProxyOpsPool* pool = proxyOps->pool;
  *posted = false;
  if (proxyOps->nextOps == -1) return ncclSuccess;
  uint64_t lastOpCount = pool->ops[proxyOps->nextOpsEnd].opCount;
  int lastOp = -1, toSend = 0, ops = 0;
  for (int op = proxyOps->nextOps; op != proxyOps->nextOpsEnd; op = pool->ops[op].next) {
    ops++;
    if (pool->ops[op].opCount != lastOpCount) { lastOp = op; toSend = ops; }
  }
  if (lastOp == -1) return ncclSuccess;     // nothing safely postable
  int nextOps = proxyOps->nextOps;
  proxyOps->nextOps = pool->ops[lastOp].next;
  pool->ops[lastOp].next = -1;
  NCCLCHECK(ncclProxyPost(pool, nextOps, lastOp));
  proxyOps->count -= toSend;
  *posted = true;
  return ncclSuccess;
}

then, in the else branch that currently spins:

  } else {
    bool flushed = false;
    NCCLCHECK(ncclLocalOpFlush(proxyOps, &flushed));   // break the cycle
    int freeOp = -1;
    while (freeOp == -1) { ... }                        // unchanged
  }

The MAX_OPS_PER_PEER site calls the same helper and keeps its existing WARN +
ncclInternalError when *posted comes back false.

One case this does not cover: if every op in the local chain shares a single
opCount, nothing is safely postable and the wait can still stall. We have not
observed that, and did not want to relax the aggregation rule without knowing why
it exists — flagging it in case it needs handling too.

Happy to open a PR if the approach looks right.

NCCL Version

2.28.9+cuda13.0

Your platform details

GPU & network. 2 x NVIDIA DGX Spark (GB10, compute capability 12.1),
1 GPU per node, aarch64. Driver 580.173.02, CUDA 13.0.88.
ConnectX-7 joined back-to-back (no switch) at 200 Gb/s, exposed by the kernel
as two RDMA devices because the card runs multi-host mode:

$ nvidia-smi topo -m
        GPU0    NIC0    NIC1    NIC2    NIC3    CPU Affinity  NUMA Affinity  GPU NUMA ID
GPU0     X      NODE    NODE    NODE    NODE    0-19          0              N/A
NIC0    NODE     X      PIX     NODE    NODE
NIC1    NODE    PIX      X      NODE    NODE
NIC2    NODE    NODE    NODE     X      PIX
NIC3    NODE    NODE    NODE    PIX      X

rocep1s0f0:    state=ACTIVE  rate=200 Gb/sec (4X HDR)  link_layer=Ethernet  gid[3]=RoCE v2
roceP2p1s0f0:  state=ACTIVE  rate=200 Gb/sec (4X HDR)  link_layer=Ethernet  gid[3]=RoCE v2

Dual-rail: NCCL_IB_HCA lists both HCAs, NCCL_IB_GID_INDEX=3, the two rails on
disjoint /24s. NCCL puts even channels on IB/0 and odd on IB/1.

Environment. Containers (Docker), Ubuntu 24.04.4, kernel 6.17.0-1029-nvidia.
NCCL from the nvidia-nccl-cu13 wheel, used via PyTorch 2.11.0+cu130.

Non-default NCCL settings:

NCCL_CUMEM_ENABLE=0        # GB10 cannot load nvidia-peermem and its allocator
NCCL_NVLS_ENABLE=0         # does not export dmabuf handles
NCCL_IGNORE_CPU_AFFINITY=1 # single NUMA node, HCA numa_node = -1
NCCL_IB_GID_INDEX=3
NCCL_CROSS_NIC=1

Scalability. Seen at the smallest possible multi-node size: 2 ranks, 2
nodes, 1 GPU each
. We have not tried other rank counts. Since the mechanism is
purely host-side thread ordering within one rank, we would not expect the rank
count to matter, only that one rank runs ahead of another.

It reproduces identically through PyTorch's ProcessGroupNCCL and through our
framework's own NCCL wrapper, so it is not specific to either.

Error Message & Behavior

First error: there is none. This is a large part of what made it hard to
diagnose. NCCL emits no WARN and no timeout. PyTorch's ProcessGroupNCCL
watchdog, which polls ncclCommGetAsyncError, also reports nothing and is found
parked in its normal pthread_cond_timedwait — we did not call
ncclCommGetAsyncError directly, so treat that as indirect evidence.

The collective's own timeout cannot fire either: PyTorch's default collective
timeout is 30 minutes, while the framework above it gives up on its own RPC after
300 s and tears the process down — 25 minutes too early — so
TORCH_NCCL_DUMP_ON_TIMEOUT never runs and the flight recorder is never dumped.

Expected. ncclAllGather completes, or some layer reports an error.

Actual. The call never returns. Both ranks' GPUs spin at 96% and the RoCE byte
counters stop moving entirely. We can only bound the duration from below: our
framework kills the engine at its own 300 s RPC timeout, and the longest we
observed before that was 240 s of a completely stalled engine. Nothing in that
window suggested it would ever recover. From
outside, every health signal looks fine: containers up, HTTP endpoint answering,
links up, no error counters, no OOM, memory and disk healthy. Only a thread dump
shows the two threads waiting on each other.

Activity

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions