LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

NCCL Deep Dive: Multi-GPU Collectives, Ring vs Tree, and Debugging Distributed Training

ncclgpucudadistributed-trainingmachine-learningpytorchhpcnvlinkinfiniband

When a 70-billion-parameter model is training across 512 GPUs, the all-reduce that synchronizes gradients every step has to move tens of gigabytes of data between every pair of nodes, finish in tens of milliseconds, and not drop the bandwidth of the most expensive hardware in the datacenter. The library doing that work is almost always NCCL — NVIDIA’s Collective Communication Library — and when it hangs or underperforms, training is down until you can figure out why.

NCCL is the layer that sits between your deep-learning framework (PyTorch’s DDP, DeepSpeed, Megatron-LM, JAX’s pjit) and your hardware (NVLink, PCIe, InfiniBand, RoCE). It implements the operations everyone needs — AllReduce, AllGather, Broadcast, Reduce, ReduceScatter — across one or more nodes, and it chooses algorithms, transports, and buffer sizes dynamically based on the topology it discovers.

This post is the book knowledge you want before a distributed training run goes sideways at 2 AM: what NCCL’s collectives actually are, why it picks ring vs tree vs collnet algorithms, how NVLink and InfiniBand interact, how to tune it for your cluster, and how to debug the classes of failure that really happen in production.

What a collective operation even is

A collective is an operation that every GPU in a group performs together, producing a shared result:

  • AllReduce — every GPU starts with a tensor; each GPU ends with the sum (or max/min/prod) of all input tensors. This is the workhorse: every gradient synchronization step in data-parallel training is an AllReduce.
  • Broadcast — one GPU has a tensor; every GPU gets a copy.
  • Reduce — every GPU contributes a tensor; one GPU gets the sum.
  • AllGather — every GPU has a chunk; every GPU ends up with the full concatenation.
  • ReduceScatter — inverse of AllGather: every GPU contributes a tensor; each GPU gets a reduced shard. This is half of an AllReduce.
  • AlltoAll / AlltoAllv — every GPU sends a distinct message to every other GPU. Used in mixture-of-experts, sequence-parallel models, and certain sharded optimizers.

AllReduce can be decomposed as ReduceScatter + AllGather. Most NCCL AllReduce implementations literally do this — it is the theoretical minimum in terms of data movement on ring topologies.

The core algorithms

NCCL picks among a small set of collective algorithms based on the message size, the number of ranks, the topology, and explicit user hints. The two you have to understand are ring and tree.

Ring

Imagine the GPUs arranged in a circle. Each GPU has a left and right neighbor. For AllReduce:

  1. ReduceScatter phase: split each GPU’s tensor into N equal chunks (N = number of GPUs). In each of N-1 steps, every GPU sends one chunk to its right neighbor while receiving a chunk from its left, accumulating (summing) the received chunk with its own. After N-1 steps, every GPU holds one fully-reduced chunk.
  2. AllGather phase: in another N-1 steps, the fully-reduced chunks circulate all the way around the ring, so every GPU ends up with every chunk.

Total data moved per GPU: 2 × (N-1)/N × data_size. Very close to the lower bound of 2 × data_size (each GPU must send its data once and receive the reduced result once). This is why ring is the default for AllReduce — it is near-optimal in terms of bandwidth.

Trade-off: latency scales linearly with N (2(N-1) serial steps). For 8 GPUs on NVLink this is fine; for 1024 GPUs it becomes noticeable.

Tree

For small messages where latency dominates bandwidth, a tree collective shines. NCCL builds two trees — a “double binary tree” that halves latency compared to a naive binary tree.

  • In Reduce phase, each leaf sends data up the tree. Interior nodes sum and pass up.
  • In Broadcast phase, the reduced value descends the tree.

Latency scales with log(N), not N. For a million GPUs (which, okay, does not exist — but the principle scales), tree is 20× faster than ring at small message sizes.

NCCL picks automatically based on message size: small messages → tree, large messages → ring. You can override with NCCL_ALGO=Ring or NCCL_ALGO=Tree.

CollNet

On clusters with NVIDIA SHARP (Scalable Hierarchical Aggregation and Reduction Protocol) enabled InfiniBand switches — essentially switches with arithmetic units inside — NCCL can offload the reduction into the switch. This is NCCL_ALGO=CollNet. For large clusters this can outperform both ring and tree because the switch does the math once and sends one result down rather than everyone sending everything up.

SHARPv2 and v3 have gotten much better; on a Spectrum-X or Quantum-2-based cluster with SHARP enabled, it is worth benchmarking.

A newer variant specific to NVLink-connected GPUs on Hopper and later (H100, B100, GB200). Uses NVSwitch multicast and on-switch reduction inside a node. NCCL 2.17+ supports it; it outperforms ring for intra-node AllReduce on DGX H100 and GB200 NVL72 systems.

When two GPUs exchange a message, NCCL picks a transport based on topology:

  • NVLink / NVSwitch — up to 900 GB/s between GPUs on H100, ~1.8 TB/s on GB200. Direct P2P memory access between GPUs on the same node.
  • PCIe P2P — GPU-to-GPU over PCIe, no CPU memory involved. Limited by PCIe Gen5 x16 (~64 GB/s).
  • SHM (shared memory) — stage through pinned host memory. Used when P2P is unavailable (e.g., GPUs on different PCIe root complexes with no NVLink).
  • NET — inter-node traffic. Underneath, NCCL uses an InfiniBand/RoCE plugin (net_ib.so) over UCX, libfabric, or raw verbs. With GPUDirect RDMA enabled, the NIC DMA’s directly from GPU memory.

You can see the decisions NCCL makes at init:

NCCL INFO P2P/direct-pointer/[via-SHM]/NET : ...
NCCL INFO NET/IB : 8 IB devices found
NCCL INFO Channel 00/0 : 0[0] -> 1[1] via P2P/direct pointer
NCCL INFO Channel 00/0 : 7[7] -> 0[0] [receive] via NET/IB/0/GDRDMA

That is what healthy init looks like: NVLink P2P inside the node, IB with GPUDirect RDMA between nodes.

Channels: the secret to line-rate

On modern GPUs and multi-NIC systems, a single transfer ring cannot saturate the hardware. NCCL creates multiple channels — parallel rings running concurrently — to drive aggregate bandwidth. Default is ~12 on H100 systems; NCCL auto-tunes based on hardware.

Each channel pulls from a different section of the message. The more channels, the more parallel NIC queues and the higher the aggregate bandwidth. Too many channels, and you run out of NIC queues or GPU streams. The default is usually right; tune via NCCL_MAX_NCHANNELS and NCCL_MIN_NCHANNELS only if you have benchmarked.

Running a real NCCL collective

NCCL is used through its C API, but most users call it through framework wrappers. PyTorch’s torch.distributed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import os
import torch
import torch.distributed as dist

dist.init_process_group(backend="nccl")  # uses NCCL on NVIDIA GPUs

rank       = dist.get_rank()
world_size = dist.get_world_size()
device     = torch.device(f"cuda:{rank % torch.cuda.device_count()}")

x = torch.ones(1_000_000, device=device) * rank
dist.all_reduce(x, op=dist.ReduceOp.SUM)
# After this: x is sum(0..world_size-1) on every rank

Under the hood, PyTorch calls ncclAllReduce(...) with the right pointers, data type, reduction op, communicator, and CUDA stream. The communicator was built once at init_process_group time with all the rank info from the master node (via RANK, WORLD_SIZE, MASTER_ADDR, MASTER_PORT env vars).

The topology file and topology detection

NCCL discovers topology at init time using three sources:

  1. /sys/class/... — PCIe bridge topology, NVLink/NVSwitch layout, NIC positions.
  2. The NCCL topology plugin, which queries the NIC vendor library for details.
  3. A user-supplied topology file via NCCL_TOPO_FILE=/path/to/topo.xml.

The third is most relevant in containers, where /sys might not perfectly reflect the host topology. Dump the detected topology on a good run:

1
NCCL_DEBUG=INFO NCCL_TOPO_DUMP_FILE=topo.xml ./run.sh

Then pass that file to subsequent runs inside containers that see less of the host:

1
export NCCL_TOPO_FILE=/workspace/topo.xml

This can matter dramatically. An incorrect topology makes NCCL route NVLink-eligible traffic over PCIe or across NUMA nodes — a 10× bandwidth hit you will not understand without looking at the NCCL debug log.

Turning on the debug log

The single most useful environment variable:

1
export NCCL_DEBUG=INFO

This dumps the init phase in full: which transports were chosen, which channels were formed, whether GPUDirect RDMA was available, what tree/ring structure was built. Every NCCL problem starts with “what did the log say at init?”

Other levels: WARN (default, quieter), TRACE (overwhelmingly verbose). NCCL_DEBUG_SUBSYS=ALL also enables per-subsystem tracing. For really deep debugging: NCCL_DEBUG=INFO NCCL_DEBUG_SUBSYS=INIT,GRAPH,TUNING,NET.

The performance knobs you will actually turn

Most NCCL tuning comes down to a few env vars. Set them in your launcher; they read at init time.

NCCL_SOCKET_IFNAME

Selects which host NIC NCCL uses for its bootstrapping and, when no IB/RoCE is present, for data. On a multi-homed node (management plus data networks), NCCL might default to a slow management interface:

1
2
export NCCL_SOCKET_IFNAME=ens1f0,ens1f1   # comma-separated, prefix match
export NCCL_SOCKET_IFNAME=^lo,docker0     # exclude these

If your training jobs silently use the management VLAN, all inter-node throughput collapses. This is one of the most common misconfigurations.

NCCL_IB_HCA

Selects which IB/RoCE devices to use:

1
2
export NCCL_IB_HCA=mlx5_0,mlx5_1,mlx5_4,mlx5_5    # Use these NICs
export NCCL_IB_HCA=^mlx5_bond_0                    # Exclude

On 8-NIC DGX-class nodes, you usually want to use all 8 for maximum aggregate bandwidth. Setting this wrong leaves you at 1/8 of line rate.

NCCL_IB_GID_INDEX

For RoCE, which GID index to use (v1 vs v2, which VLAN, which IP). Run show_gids to see options. Must be consistent across every node in the run.

NCCL_IB_HCA and multi-rail

Modern systems have multiple NICs per node. NCCL natively handles this — it will stripe a single collective across multiple NICs. You want NCCL_IB_HCA to list every NIC. NCCL_IB_SPLIT_DATA_ON_QPS=1 (newer default) splits data across QPs on different NICs for better utilization.

NCCL_BUFFSIZE

Size of the buffer NCCL uses per channel to stage messages. Default is 4 MiB on modern versions. Rarely needs tuning, but if you have tiny messages and high latency, a smaller buffer can help cut memory footprint.

NCCL_P2P_LEVEL

Controls when P2P is allowed:

  • NVL — only over NVLink (safest default).
  • PIX — through at most one PCIe switch.
  • PHB — through the CPU PCIe host bridge.
  • SYS — across NUMA nodes (slow).

Default is SYS, but there are known cases on certain platforms where PCIe P2P across NUMA is slower than SHM-staged transfer. If you see unexpected intra-node slowdowns, try NCCL_P2P_LEVEL=NVL.

NCCL_ALGO and NCCL_PROTO

  • NCCL_ALGO=Ring | Tree | CollNet | NVLS — force a specific algorithm.
  • NCCL_PROTO=Simple | LL | LL128 — force a specific wire protocol. LL (low-latency) trades some throughput for latency at small messages; LL128 is similar but uses 128-byte quantization. Simple is the high-throughput default for larger messages.

For benchmarking, sweeping NCCL_ALGO=Ring|Tree against your actual workload is cheap and sometimes surprising.

NCCL_NVLS_ENABLE

On Hopper/Blackwell with NVSwitch, NVLink SHARP is enabled by default in recent NCCL versions. If you are on an older driver or want to disable for benchmarking: NCCL_NVLS_ENABLE=0.

NCCL_COLLNET_ENABLE

Enables SHARP on NVIDIA IB switches that support it. Requires the right switch firmware and SHARP daemon running. When it works, large-cluster AllReduce gets substantially faster. When it does not, the error messages at init are informative.

Benchmarking: nccl-tests

The nccl-tests repo is the canonical benchmark. Build it once per cluster and keep it around:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
git clone https://github.com/NVIDIA/nccl-tests
cd nccl-tests
make MPI=1 NCCL_HOME=/usr/local/cuda CUDA_HOME=/usr/local/cuda

# Single-node 8-GPU AllReduce:
./build/all_reduce_perf -b 8M -e 2G -f 2 -g 8

# Multi-node via MPI:
mpirun -np 16 -bind-to none -host n01:8,n02:8 \
    -x NCCL_DEBUG=INFO -x NCCL_IB_HCA=mlx5_0,mlx5_1,mlx5_4,mlx5_5 \
    ./build/all_reduce_perf -b 8M -e 2G -f 2 -g 1

The output shows bandwidth per size. On H100 nodes with 8x 400G NICs you should expect:

  • Intra-node AllReduce at 2 GB: 450+ GB/s busbw (NVLink saturated).
  • Inter-node AllReduce at 2 GB, 16 nodes: bandwidth close to the sum of NIC line rates divided by 2.

The two columns to focus on are algbw (algorithmic bandwidth: how much unique data was moved through the collective) and busbw (bus bandwidth: how much total data was moved on the NVLink/IB fabric). For AllReduce, busbw = algbw * 2 * (N-1)/N. It is busbw that you compare against the fabric’s physical capacity.

When bandwidth is lower than expected, look at the NCCL init log first:

  • Did every GPU find every NIC?
  • Is GPUDirect RDMA enabled? (NCCL INFO NET/IB : ... GDRDMA)
  • Is P2P between GPUs “via direct pointer” or “via SHM”? Direct pointer means NVLink is working.
  • Are the right number of channels being created?

Common failures and how to diagnose them

“NCCL hangs at init”

The process group gets stuck during init_process_group. Almost always one of:

  1. Firewall blocking the bootstrap TCP port. NCCL bootstraps over MASTER_ADDR:MASTER_PORT; every rank must be able to reach the master. Try nc -zv master_host 29500 from a worker.
  2. Mismatched MASTER_ADDR/MASTER_PORT/WORLD_SIZE/RANK between workers — classic Slurm-launcher bug.
  3. DNS — workers cannot resolve the master’s hostname. Use IPs in Kubernetes pods with no service mesh.

If you export NCCL_DEBUG=INFO, you will see “bootstrap listen” on the master and “bootstrap connect” on workers. If the connect fails, it is network.

“NCCL hangs mid-training”

The collective starts but never finishes. Causes:

  1. A rank crashed or exited early. One GPU OOMed, its process died, the remaining ranks spin forever waiting. Watch your job for any rank exits.
  2. A mismatched collective. If rank 0 calls all_reduce(tensor_a) but rank 1 calls all_reduce(tensor_b) with a different shape, NCCL’s newer versions detect this and print a warning, but the older versions just hang forever.
  3. A dropped packet on a RoCE fabric without good flow control. NCCL’s RC QPs retransmit, but under some failure modes the connection enters a state where it neither completes nor errors.

Set NCCL_ASYNC_ERROR_HANDLING=1 (PyTorch turns this on by default now) so that failures at least raise an exception instead of hanging silently. And a watchdog: PyTorch’s torch.distributed has a collective timeout (timeout=datetime.timedelta(minutes=30)) — keep it.

“Inter-node bandwidth is 1/8 of line rate”

Classic causes, in order of frequency:

  1. Wrong NIC selected. NCCL_SOCKET_IFNAME or NCCL_IB_HCA is defaulting to one interface. Pin them explicitly. Confirm in the init log that NCCL sees all NICs.
  2. Traffic on the management VLAN. The management 10 GbE network is serving the collective. Fix via NCCL_SOCKET_IFNAME.
  3. GPUDirect RDMA disabled. The nvidia-peermem module is not loaded; NCCL falls back to staging through host memory. lsmod | grep nvidia_peermem should show it.
  4. NUMA misalignment. Process pinned to wrong NUMA node, so GPU-to-NIC traffic crosses the CPU interconnect. Use numactl --cpunodebind=$numa --membind=$numa or Slurm’s --gpu-bind.
  5. PCIe switch or link running at the wrong generation. lspci -vv | grep -i 'LnkSta\|LnkCap' — should show Speed 32GT/s Width x16 for Gen5, 16GT/s Width x16 for Gen4.

“Intra-node is fine, multi-node plummets”

Either NIC selection, GPUDirect, or a fabric configuration problem (PFC/ECN, MTU mismatch). Run ib_write_bw between the two nodes — if that hits line rate and NCCL does not, the issue is NCCL config; if ib_write_bw is slow too, it is the fabric.

“NCCL reports fewer NICs than I have”

Usually container-related:

  • Missing --device or --cap-add=IPC_LOCK on docker run.
  • Missing /sys/class/infiniband mounted into the container.
  • Missing uverbs device nodes.

For Kubernetes, use the NVIDIA network operator, which handles the RDMA device plugin and pod-level resource requests.

“Training loss goes NaN after switching from 1 node to 16”

This is usually not NCCL. It is floating-point nondeterminism from AllReduce order. NCCL’s AllReduce is associative-mathematically but not bitwise: the order of additions depends on the ring order, and fp16 + fp16 is not fully associative. Consequences:

  • Gradient checkpointing and optimizer step become slightly different across scales.
  • Loss can NaN on a run that was fine with a different rank count — the divergence point just shifted.

For reproducibility: NCCL_ALGO=Tree (deterministic tree order) plus fp32 master weights plus gradient clipping on the optimizer side. There is no NCCL flag that makes fp16 AllReduce bitwise-deterministic across topologies.

ncclUnhandledCudaError: unhandled cuda error

A CUDA call failed on one of the ranks. NCCL is propagating the error. Look at the CUDA error itself — usually an OOM (rank did not have enough memory and the allocation NCCL made for internal buffers failed) or an invalid access (a kernel stomped on something it should not have). It is rarely a NCCL bug; it is usually a CUDA bug surfacing through NCCL.

unhandled system error, NCCL version X.Y.Z

Generic. Check dmesg — this often correlates with a NIC reset, a PCIe AER error, or a driver crash. If dmesg on every affected node shows a fatal hardware event, that is your answer. If it does not, look at the NCCL init log on every rank (not just rank 0) for the actual failing operation.

A debugging checklist for “my training run is slow”

In order:

  1. nvidia-smi — GPUs at high SM utilization? If < 90%, your bottleneck is not NCCL; it is data loading or kernel efficiency.
  2. nvidia-smi topo -m — confirm NVLink layout is what you expect.
  3. ibstat, ibstatus — ports Active, speed matches datasheet.
  4. show_gids, ibv_devinfo -v — IB/RoCE setup sane.
  5. lsmod | grep nvidia_peermem — GPUDirect RDMA module loaded.
  6. nccl-tests all_reduce_perf — does NCCL itself achieve good bandwidth outside training?
  7. NCCL_DEBUG=INFO — verify NCCL sees every NIC, picks expected transports, enables GDRDMA.
  8. Framework-level profiling — PyTorch Profiler, Nsight Systems. Does the overlap between compute and AllReduce look right? (Overlapping is the whole point of bucketed DDP.)
  9. iftop / ethtool -S on the NIC — is traffic flowing on the right interface at the right rate?
  10. Check the host’s scheduler, htop — CPU contention from a runaway process on the node.

A few opinionated defaults to start with

For a new H100/H200/B100 cluster with 8-NIC nodes on NDR InfiniBand, this set of environment variables is a solid starting point:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
export NCCL_DEBUG=WARN                       # INFO for debugging
export NCCL_IB_HCA=mlx5_0,mlx5_1,mlx5_2,mlx5_3,mlx5_4,mlx5_5,mlx5_6,mlx5_7
export NCCL_IB_GID_INDEX=3                   # for RoCE; omit for IB
export NCCL_SOCKET_IFNAME=ens1f0             # bootstrap interface
export NCCL_IB_TC=106                        # traffic class for PFC on RoCE
export NCCL_IB_SL=3                          # service level matching PFC priority
export NCCL_IB_TIMEOUT=22                    # in units of 4.096 µs * 2^n
export NCCL_IB_RETRY_CNT=7
export NCCL_ASYNC_ERROR_HANDLING=1           # fail fast on errors
export NCCL_NVLS_ENABLE=1                    # NVLink SHARP on Hopper/Blackwell

Then run nccl-tests and adjust from there. Every cluster is slightly different; the default is never quite right.

Recent and upcoming features worth knowing

  • NCCL 2.19+ added non-blocking communicators and the ncclConfig_t struct for per-communicator tuning.
  • NCCL 2.20+ improved NVLink SHARP support and added better multi-rail scheduling.
  • NCCL 2.21+ introduced user-defined reduction operations via plugin APIs.
  • GB200 / NVL72 — the 72-GPU NVLink-connected rack is a single “node” as far as NCCL is concerned; NVLink SHARP across the rack dramatically changes the scaling profile for models that fit in NVL72.

If you are on an old NCCL (<2.18), upgrade. Newer versions have meaningful bandwidth and latency improvements across the board, better error messages, and fewer hangs.

Wrapping up

NCCL is the thing between your training loop and the speed of light of your hardware. The default installation gets you close to datasheet numbers on a well-formed cluster; every notch below that comes from misconfiguration — wrong NIC, wrong interface, missing GPUDirect, bad topology, misaligned NUMA, flaky fabric.

Three habits will save you hours:

  1. Always run NCCL_DEBUG=INFO at least once on every cluster topology change. Read the init log; it tells you exactly what NCCL decided.
  2. Always run nccl-tests all_reduce_perf at cluster acceptance and after every firmware or driver update. Know your baseline bandwidth before you need to know it.
  3. Always pin NCCL_SOCKET_IFNAME and NCCL_IB_HCA explicitly. Letting NCCL auto-detect on a multi-homed node is a decision you make once, wrong, for every subsequent job.

Do that, and NCCL is very rarely the bottleneck — your model is.

Comments