Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
The last chapter examined one GPU's matmul. Now suppose four GPUs each spend 8 ms computing local gradients. The step then waits 6 ms to combine a 256 MiB (mebibyte) gradient buffer from each GPU. These are illustrative timings, not a hardware benchmark.
A standalone communication test may call that transfer fast, while a real training step exposes more or less time depending on whether the move overlaps other work. If one GPU enters a different communication operation, the other three can wait at a call that looks like a network failure.
NCCL (NVIDIA Collective Communications Library, pronounced "nickel") is the library that coordinates that movement on NVIDIA systems. It provides collective operations such as all-reduce, reduce-scatter, all-gather, broadcast, and all-to-all, plus point-to-point send and receive. NCCL isn't a training framework or scheduler. PyTorch, DeepSpeed, Megatron, and serving runtimes decide what to communicate; NCCL decides how to move it across available GPU links.[1]

The collective contract
A rank is one participant in a communication group. A communicator records that group and maps ranks to devices. Each rank calls the same collective in the same order, with compatible counts and data types, the same reduction operation, and the same root when the operation has one. A root is a rank index, not a GPU device number. Breaking that agreement can cause a hang, crash, or corrupted result.[2]
For sum all-reduce over ranks, rank contributes a vector with elements. Every rank receives:
Here, is rank count, is elements per input vector, and selects one element. This example requests ncclSum, not an average. NCCL also provides ncclAvg. The caller must choose the reduction and scaling needed by its training objective, without dividing twice.[3]
Rank 0 calls all-reduce, while rank 1 calls broadcast on the same communicator. Why can neither operation finish?
Answer
Collectives are ordered group agreements. Each rank is waiting for peers to join the same operation with compatible arguments, but the two ranks entered different protocols.
Choose the operation from desired ownership
The output placement matters as much as the arithmetic. Use all-reduce when every rank needs the whole result. Reduce-scatter fits one reduced shard per rank. Existing shards become a complete tensor through all-gather.
| Operation | Input on each rank | Output ownership | Common AI use |
|---|---|---|---|
| All-reduce | Same-shaped tensor | Full reduced tensor on every rank | Replicated data-parallel gradients |
| Reduce-scatter | Same-shaped tensor | One reduced chunk per rank | Sharded gradients in FSDP or ZeRO |
| All-gather | One local chunk | Concatenated chunks on every rank | Parameter or activation reconstruction |
| Broadcast | Full tensor on root | Root tensor copied to all ranks | Seeds, metadata, or initial weights |
| All-to-all | Destination chunks | One chunk from every source | MoE token routing |
| Send / receive | Point-to-point buffer | Named peer only | Pipeline stage activations |
Broadcast is the simple copy in that table: rank 0 keeps a buffer, and the others receive it. The animations below follow the operations whose ownership is easier to mix up, using the same four-rank numbers you'll compute by hand next.



NCCL's all-to-all uses equal-sized chunks. MoE routing may produce unequal token counts, so a runtime needs padding, matched point-to-point transfers, or another variable-size dispatch implementation. Don't assume the equal-chunk picture alone implements a complete MoE router. The guide also documents Gather (collect at a root) and Scatter (distribute from a root).[2]
An all-reduce can be implemented as reduce-scatter followed by all-gather. The first half adds corresponding chunks and leaves one finished chunk on each rank. The second half circulates those finished chunks until every rank has the full result. This is an equivalence of operations, not a claim that every NCCL algorithm executes these two API calls. Floating-point summation order can also change the last bits.[2]
Work one all-reduce by hand
Use four ranks with four-element vectors:
| Rank | Input vector |
|---|---|
| 0 | [1, 2, 3, 4] |
| 1 | [10, 20, 30, 40] |
| 2 | [100, 200, 300, 400] |
| 3 | [1000, 2000, 3000, 4000] |
Elementwise addition gives [1111, 2222, 3333, 4444]. NCCL's reduce-scatter contract places that result by rank index: rank 0 keeps 1111, rank 1 keeps 2222, rank 2 keeps 3333, and rank 3 keeps 4444. All-gather then shares those four owned chunks so every rank reconstructs the same vector.[2]
In fully sharded training, such as FSDP or ZeRO stage 3, each rank can keep a reduced gradient shard, update its optimizer and parameter shards, then gather parameters for later computation. That later gather is of updated parameters, not necessarily the same gradient buffer. Earlier ZeRO stages have different ownership rules.
After reduce-scatter in the four-rank example, rank 2 owns 3333. What extra operation is needed if rank 2 alone will update only that shard?
Answer
None for gradient ownership. All-gather is needed only when later computation requires every rank to reconstruct the full tensor or a full parameter bucket.
The sum is easy on paper. Moving those bytes without a central reducer that would serialize every link is the actual constraint. A ring is the bandwidth-friendly way to do that.
Why rings work well for large messages
A ring orders ranks so each sends to one neighbor and receives from another. Split a message into chunks. Reduce-scatter circulates and combines chunks for steps. All-gather circulates the completed chunks for another steps.
Follow only chunk x2 (the third slot: 3, 30, 300, 3000) so the hops stay readable. The partial sum starts on rank 3 and walks clockwise until rank 2, which is the owner of index 2:
| Neighbor hop | Rank that adds | Running sum for x2 |
|---|---|---|
| start | 3 | 3000 |
| 1 | 0 | 3000 + 3 = 3003 |
| 2 | 1 | 3003 + 30 = 3033 |
| 3 | 2 | 3033 + 300 = 3333 |
After those three reduce-scatter hops, rank 2 owns 3333. Three all-gather hops then copy that finished shard to ranks 3, 0, and 1. Other chunks move on the same ring at the same time, offset by one slot, which is why a large buffer can keep every link busy.

The next snippet is a CPU, standard-library model of that schedule, not a GPU kernel. It represents each chunk by one integer, so it accepts exactly values from each of ranks. It checks ownership and reconstruction without modeling CUDA streams, network progress, or floating-point error.
1inputs = [
2 [1, 2, 3, 4],
3 [10, 20, 30, 40],
4 [100, 200, 300, 400],
5 [1000, 2000, 3000, 4000],
6]
7k = len(inputs)
8expected = [sum(row[i] for row in inputs) for i in range(k)]
9
10def ring_reduce_scatter(bufs):
11 k = len(bufs)
12 if not k or any(len(row) != k for row in bufs):
13 raise ValueError("expected k nonempty ranks with k scalar chunks each")
14 state = [list(row) for row in bufs]
15 for step in range(k - 1):
16 send = [state[r][(r - step - 1) % k] for r in range(k)]
17 for r in range(k):
18 recv_idx = (r - step - 2) % k
19 state[r][recv_idx] += send[(r - 1) % k]
20 return [state[r][r] for r in range(k)]
21
22def ring_all_gather(owned):
23 k = len(owned)
24 if not k:
25 raise ValueError("expected at least one owned shard")
26 result = [[None] * k for _ in range(k)]
27 for r, value in enumerate(owned):
28 result[r][r] = value
29 for step in range(k - 1):
30 snapshot = [row[:] for row in result]
31 for r in range(k):
32 src = (r - 1) % k
33 chunk = (src - step) % k
34 result[r][chunk] = snapshot[src][chunk]
35 return result
36
37owned = ring_reduce_scatter(inputs)
38gathered = ring_all_gather(owned)
39
40rank = 3
41total = inputs[rank][2]
42print("x2 path")
43print(f" rank {rank} holds {total}")
44while rank != 2:
45 rank = (rank + 1) % k
46 added = inputs[rank][2]
47 total += added
48 owned_note = " (owns chunk 2)" if rank == 2 else ""
49 print(f" rank {rank} adds {added} -> {total}{owned_note}")
50
51print(f"owned shards: {owned}")
52print(f"rank 0 after all-gather: {gathered[0]}")
53print(f"send volume factor 2*(k-1)/k = {2 * (k - 1) / k:.2f}")
54
55assert owned == expected == [1111, 2222, 3333, 4444]
56assert all(row == expected for row in gathered)
57
58# The functions derive rank count from their arguments, not the example above.
59for ranks in (1, 2, 3, 5):
60 rows = [[10 * r + i for i in range(ranks)] for r in range(ranks)]
61 reduced = [sum(row[i] for row in rows) for i in range(ranks)]
62 assert ring_reduce_scatter(rows) == reduced
63 assert ring_all_gather(reduced) == [reduced] * ranks
64for invalid in ([], [[1, 2]], [[1], [2, 3]]):
65 try:
66 ring_reduce_scatter(invalid)
67 except ValueError:
68 pass
69 else:
70 raise AssertionError("invalid chunk layout accepted")
71print("rank-count and shape checks passed")1x2 path
2 rank 3 holds 3000
3 rank 0 adds 3 -> 3003
4 rank 1 adds 30 -> 3033
5 rank 2 adds 300 -> 3333 (owns chunk 2)
6owned shards: [1111, 2222, 3333, 4444]
7rank 0 after all-gather: [1111, 2222, 3333, 4444]
8send volume factor 2*(k-1)/k = 1.50
9rank-count and shape checks passedFor message size , each rank in a ring all-reduce sends about
Each rank receives the same volume. As grows, that factor approaches , so the traffic looks like per rank, not . The optimal-bandwidth result is for a point-to-point communication model, as analyzed by Patarasuk and Yuan. It isn't a universal byte lower bound for hardware with in-network reduction or multicast offload.[4]
The same factor reappears in the nccl-tests busbw column. For this four-rank toy, the factor is ; the measurement section turns that traffic factor into a concrete receipt and separates it from application time.[5]
Rings pay in latency. The algorithm needs neighbor steps, so a tiny message on thousands of ranks spends too much time starting rounds. Pipelining many chunks hides link delay for large buffers, but it can't erase startup cost for small ones.
Why trees help small and medium messages
A binary tree reduces values toward a root and broadcasts the answer back down. Tree depth grows roughly with , which cuts round count compared with a ring. One plain tree can overload internal ranks, so NCCL added complementary double binary trees that split data and swap leaf versus internal-node roles.[6]
No algorithm wins for every tensor and topology. Rings favor sustained bandwidth. Trees favor fewer rounds. The NCCL 2.31 documentation names Ring, Tree, CollnetChain, CollnetDirect, NVLS, NVLSTree, and PAT as selectable algorithm families, plus Simple, LL, and LL128 protocols.[7]
Availability depends on the operation, hardware, network plugins, registration, and runtime tuning. Treat the names as candidates that explain a log, not as a menu that every host can run.
NCCL normally chooses algorithms and protocols from its topology and performance model. Forcing NCCL_ALGO or NCCL_PROTO can be useful for an experiment, but a fixed override can become wrong after hardware, message shapes, or NCCL versions change. Forcing LL128 on a path that doesn't support it can corrupt data, which is why the docs discourage protocol overrides except for diagnosis.[7]
| Workload shape | Likely pressure | What to inspect |
|---|---|---|
| Large gradient buckets | Link bandwidth | Ring or fabric-offload path, channel count, bus bandwidth |
| Tiny tensor-parallel reductions | Startup latency | Tree-like path, protocol, launch gaps |
| MoE all-to-all | Imbalance and network injection | Per-peer bytes, hot experts, NIC rails |
| Pipeline send/receive | Bubble and ordering | Stage timeline, peer pairing, stream waits |
| FSDP all-gather | Burst overlap | Bucket timing, parameter prefetch, compute coverage |
A communicator is an ordered program
NCCL doesn't launch processes. A launcher such as MPI, Slurm, or torchrun starts them; application or framework bootstrap code exchanges a unique communicator ID and agrees on rank-to-device mapping. In a single process, ncclCommInitAll can handle this setup for a device list. Each rank within a communicator must use a distinct CUDA device.[8]
Each process then binds a rank to a CUDA device and creates its communicator. Only after that agreement can the ranks enqueue matching work.
After communicator creation, each rank can enqueue this C++ collective with its own buffers and CUDA stream, an ordered GPU work queue. This is an integration fragment, not a standalone executable: the caller supplies valid device buffers, a communicator, a stream, and an error-checking NCCLCHECK macro. It requires a CUDA/NCCL system and wasn't executed by the CPU examples.
1NCCLCHECK(ncclAllReduce(
2 local_gradient,
3 global_gradient,
4 element_count,
5 ncclFloat32,
6 ncclSum,
7 communicator,
8 communication_stream));
For a successful call on a default blocking communicator outside a group, host return means work was enqueued, not completed. The producer must make local_gradient ready before communication reads it. A later CUDA event, stream dependency, or synchronization establishes when another kernel may consume global_gradient. Keep both buffers alive and avoid conflicting writes until the relevant work completes.[9]
Grouped calls can defer enqueue until ncclGroupEnd. A nonblocking communicator can return ncclInProgress; follow the documented completion/error polling before treating its stream work as enqueued. These are different boundaries from GPU completion.[10][3]
When one thread manages several GPUs, group their matching calls with ncclGroupStart and ncclGroupEnd. A plain loop can block on its first device before the thread reaches the other ranks. Grouping doesn't relax collective order, including the documented order across communicators within a group.[10]
Ordering stays global even when work is asynchronous. If one rank enters collective A then B while another enters B then A, stream scheduling can't repair the mismatch. Framework process groups and bucket schedulers exist partly to keep this distributed instruction stream identical across ranks.
💡 Key insight: An NCCL hang often begins before NCCL. One rank took a different branch, hit an out-of-memory error, loaded data slowly, or skipped a bucket. Peers then wait inside the next collective they all expected to share.
Topology turns one API into different routes
Two calls to ncclAllReduce can exercise very different hardware. Inside one server, bytes may travel through direct GPU peer access, NVLink, or NVSwitch. Across servers, the path can include PCIe, a network interface card (NIC), InfiniBand or RoCE, switches, and a remote host's reverse path.

Read the matrix before changing a setting
On an NVIDIA host with the driver tools installed, start with host evidence before setting NCCL overrides. Run the topology query:
1nvidia-smi topo -mnvidia-smi topo -m prints connections among GPUs and NICs, along with CPU and memory affinities. Its labels classify path relationships; they aren't achieved-bandwidth measurements.[11]
The exact matrix depends on the host. This excerpt is an illustrative shape, not a measurement:
1 GPU0 GPU1 GPU2 GPU3 NIC0 NIC1
2GPU0 X NV4 SYS SYS PIX SYS
3GPU1 NV4 X SYS SYS PIX SYS
4GPU2 SYS SYS X NV4 SYS PIX
5GPU3 SYS SYS NV4 X SYS PIX
6NIC0 PIX PIX SYS SYS X SYS
7NIC1 SYS SYS PIX PIX SYS XRead the common cells as path hints:
| Cell | Path class | First hypothesis to test |
|---|---|---|
NV4 | A bonded set of four NVLinks | GPU traffic stays on the NVLink fabric |
PIX | At most one PCIe switch | GPU and NIC share a short PCIe route |
PXB | Multiple PCIe switches | Extra PCIe hops may add contention |
PHB | A PCIe host bridge | The CPU or root complex sits on the route |
NODE | PCIe host bridges within one NUMA node | Placement may cross a host-bridge boundary |
SYS | PCIe plus an interconnect between NUMA nodes | Remote NUMA or fabric traffic may dominate |
NV4 means four bonded links, not four times a measured rate. The matrix also doesn't draw switch nodes.
Separate the local GPU fabric from NIC access. A GPU may reach another GPU through NVSwitch, while reaching a NIC through PCIe. NVSwitch isn't a PCIe-to-network bridge. NCCL's PXN path can relay through an intermediate GPU with better NIC access; verify that distinct route in platform topology and logs. A host bridge on the route doesn't by itself mean the payload was copied through CPU memory.[7]
NCCL turns that topology hypothesis into a schedule. It discovers GPU, CPU, PCIe, NVLink, and network relationships, searches candidate graph layouts, and tunes a plan. GPUDirect remote direct memory access (RDMA) can let a NIC access GPU memory without staging the payload through ordinary CPU copies. Shared-memory and host-staged paths still matter when direct peer access isn't available.
Topology awareness can't create bandwidth that hardware lacks. A GPU behind the wrong PCIe root, a disabled link, a slow network interface, or oversubscribed switches still cap the collective. The library can select a better route only among paths it can discover and use.
The same all-reduce is fast within one server but slows sharply across two servers. What changed if tensor size and rank count stayed fixed?
Answer
The physical route changed. Cross-node traffic adds PCIe, network interfaces, and fabric links, so placement, rail balance, or network bandwidth can become the limit even though the collective API is unchanged.
Read one call through the source tree
The pinned source snapshot makes the runtime boundary concrete.[12] Start with src/collectives.cc, where public functions such as ncclAllReduce package operation metadata.
The snapshot is commit 5067397c2676d5aed50042fc39e5c8ee96eb0027. The diagram is a responsibility map, not a literal stack trace: transport connections can be prepared before a device kernel runs, and not every path needs host proxy progress.

Use this source map instead of searching for one giant all-reduce loop:
| Source area | Responsibility | Question to carry |
|---|---|---|
src/init.cc, bootstrap.cc | Communicator setup and peer discovery | Which ranks and devices joined? |
src/graph/topo.cc, search.cc | Physical topology and graph search | Which links can form rings or trees? |
src/graph/tuning.cc | Algorithm, protocol, and channel model | Why did this message get this plan? |
src/enqueue.cc, collectives.cc | Host API validation and work creation | What exactly was enqueued? |
src/device/*.h, primitives.h | Device-side copy and reduction steps | Which rank sends, receives, or reduces next? |
src/transport/* | P2P, shared memory, sockets, InfiniBand, plugins | Which physical route carries bytes? |
src/proxy.cc | Host proxy progress for transports that need it | Is progress waiting on host or network work? |
src/debug.cc, src/ras/ | Logs and reliability signals | What evidence identifies the failed rank or link? |
src/nccl_device/, src/gin/ | Device-initiated collectives and GPU-side networking | Did a host enqueue start this, or did a device kernel? |
The conventional host all-reduce path and the device-initiated interfaces aren't the same entry point. Reading only ncclAllReduce shows the contract, not the chosen schedule. Reading only a CUDA primitive shows local mechanics, not why the runtime picked that path.
How higher-level systems use NCCL
NCCL is shared infrastructure across training and inference. Its importance grows as model code partitions more tensors, because each partition boundary creates a communication event.
| System pattern | NCCL traffic | Scaling failure when communication wins |
|---|---|---|
| Data-parallel training | Gradient all-reduce | More replicas yield diminishing tokens/sec |
| Fully sharded FSDP or ZeRO-3 | Reduce-scatter plus parameter all-gather | Memory fits, but layer gathers stall compute |
| Tensor parallelism | Frequent activation reductions or gathers | Wide TP across slow links hurts every layer |
| Pipeline parallelism | Send/receive activations and gradients | Stage bubbles or peer waits grow |
| Expert parallelism | All-to-all token dispatch and combine | Hot experts create uneven peer traffic |
| Multi-GPU inference | Tensor-parallel reductions, KV or state exchange | Inter-token latency rises despite idle FLOPs |
DeepSpeed and Megatron build groups, buckets, overlap schedules, and sharding policies above NCCL. PyTorch exposes NCCL through torch.distributed. Specialized expert-routing libraries can use other communication implementations; don't infer that every GPU collective is an NCCL call. None of those layers remove the need to understand ownership and physical paths.
Choose the optimization target from operation shape. If tensor parallelism emits a small all-reduce in every transformer layer, lowering its launch latency may matter more than peak bandwidth. If data parallelism emits a few huge buckets, saturating network rails and overlapping buckets with backward compute matter more.
Measure bytes, rounds, and exposed time
Suppose six rounds each add 2 microseconds of startup, while 1 MB traverses a 50 GB/s path. The rough estimate is 12 microseconds of startup plus 20 microseconds of payload time, or 32 microseconds total. These decimal units and invented inputs illustrate a model, not an NCCL prediction.
Writing that calculation generally separates startup latency from payload time:
where is communication rounds, is per-round startup cost, is bytes crossing the measured path, and is achieved bandwidth. Real NCCL schedules add channels, protocol overhead, topology contention, and synchronization, but this equation tells you whether reducing rounds or moving bytes faster is the bigger target.
Small payloads make visible. Large payloads make visible. Keep that split in mind when you vary message size, rank count, or topology.
Turn one all_reduce_perf row into evidence
nccl-tests is a separate NVIDIA repository that checks both NCCL correctness and performance. First build it against your installed CUDA and NCCL (make, with CUDA_HOME and NCCL_HOME when needed); multi-process tests also require an MPI-enabled build. On one host with four visible GPUs, pin the scan to one payload size before comparing routes. This hardware-dependent command wasn't run for the lesson's CPU checks:
1./build/all_reduce_perf -b 256M -e 256M -g 4 -n 20 -w 5 -c 1 -T 60Here, -g 4 uses four GPUs per thread (one thread and process in this launch), -b and -e select 256 MiB, -n 20 requests 20 timed iterations, -w 5 adds five untimed warmups, and -c 1 requests one correctness-check iteration. -T 60 sets the test timeout in seconds. Check flags against your checkout: this description follows the public README consulted in September 2026.[13]
The following receipt has the shape of current all_reduce_perf output. Its 6.00 ms value is chosen for arithmetic, not measured from a named GPU, driver, NCCL build, or network:
1# 4 ranks · float32 sum · 5 warmups · 20 timed iterations · check=1
2# out-of-place in-place
3# size (B) count (elements) type redop root time algbw busbw #wrong time algbw busbw #wrong
4# (us) (GB/s) (GB/s) (us) (GB/s) (GB/s)
5 268435456 67108864 float sum -1 6000.0 44.74 67.11 0 6000.0 44.74 67.11 0Start with units. size is 268,435,456 bytes, and count is 67,108,864 float32 elements. Since 6000 microseconds is 0.006 seconds, algorithmic bandwidth is GB/s. It answers, "How quickly did this logical payload complete?"[5]
For a four-rank all-reduce, the test's operation-based correction is . Thus busbw is GB/s.[5]
Read that as a normalized link-oriented score for comparing the same operation across systems. It isn't a packet counter or a promise that every physical mechanism moved exactly 1.5 payloads. The performance guide derives this factor for point-to-point collective traffic and lists different factors for reduce-scatter, all-gather, broadcast, reduce, and all-to-all.[5]
The two halves of the row also answer a buffer question. Out-of-place uses separate send and receive buffers. In-place aliases them for all-reduce, which NCCL defines as sendBuff == recvBuff.[14]
#wrong reports elements that failed the requested correctness check. A zero count is necessary evidence for this row, but it doesn't cover a later stream or buffer-lifetime race in the application.[13]

In checkouts supporting -I 1, it adds per-iteration CUDA-event timing summaries. That shows timing spread, not proof of which rank caused a delay. Inspect per-rank traces for attribution. With only 20 samples, the reported nearest-rank p99 can equal the maximum; don't treat it as a stable tail estimate. Keep timing separate from correctness checks.[13]
Attach five pieces of evidence to a real performance claim:
| Evidence | Record beside the row |
|---|---|
| Hardware and software | GPU model and count, host count, topology, CUDA, NCCL, framework, and nccl-tests versions |
| Workload | Payload bytes, element count, rank count, and any aggregation or batch shape |
| Precision and path | Datatype, reduction op, in-place mode, and any forced algorithm or protocol |
| Baseline | Exact comparison command and equivalent tuning on the alternate route or build |
| Correctness | Check mode, tolerance or status, and #wrong for each buffer mode |
The receipt isolates one collective. In an idealized training schedule, hiding 4 ms of a 6 ms collective leaves 2 ms exposed. Real overlap can slow compute or communication through resource contention, so establish the actual critical path from a trace.
Both schedules can produce the same receipt, so compare it with a profiler timeline and the stream event that makes the reduced buffer readable. algbw or busbw alone isn't a tokens-per-second or step-time result.
Run tests on the same hosts, GPUs, container settings, and network rails as the workload. A clean row establishes an isolated fabric baseline; it doesn't prove that model code, data loading, bucket timing, or stream dependencies expose the same time.
🎯 Production tip: Keep a small performance envelope by message size and rank count. One headline number hides the exact region where tensor-parallel latency or giant gradient buckets fail.
A clean receipt answers whether the fabric can move isolated bytes. It doesn't tell you why a live step exposed time or stopped, so the next question is causal: did ranks disagree, did a process fail, or did the route break?
Diagnose a hang without guessing
First classify whether ranks disagree, a process failed, or the network path is broken. A timeout stack that ends in NCCL doesn't distinguish those causes.
| Symptom | Likely class | First evidence |
|---|---|---|
| One rank exited or OOMed | Missing participant | Earliest per-rank error, not peer timeouts |
| Same step hangs every run | Collective order or shape mismatch | Per-rank operation sequence and tensor metadata |
| Multi-node only | Interface, firewall, NIC, or RDMA path | Bootstrap and NET logs, host connectivity |
| Slow after scale-out | Topology or straggler | Timeline by rank, link counters, nccl-tests |
| Container init failure | Shared memory or memlock | /dev/shm, limits, NCCL warning |
| Wrong data without hang | Buffer lifetime or stream dependency | CUDA events, producer and consumer streams |
Set NCCL_DEBUG=WARN for explicit errors. Escalate briefly to NCCL_DEBUG=INFO with focused subsystems such as INIT,NET,GRAPH, and write one log per host and process when needed. NCCL documentation also covers shared-memory limits, network-interface selection, container configuration, and asynchronous error handling.[15]
Then compare all ranks at the first divergence. Check world size, rank-to-device mapping, collective name, count, dtype, root, communicator, and sequence number. If those match, test the path outside the application with nccl-tests and inspect the topology NCCL discovered.
Finally, restore automatic tuning after the experiment. Variables such as NCCL_SOCKET_IFNAME and NCCL_IB_HCA can be stable cluster configuration. Algorithm, protocol, and channel overrides are usually diagnostic controls, not permanent cargo cult.
Strengths and limits
NCCL is a small collective API over many NVIDIA GPU and network topologies. It fuses communication and reduction on device paths, picks algorithms at runtime, works from one process or many, and exposes plugins for network, tuner, profiler, and newer device-side interfaces.
Its limits are equally important:
- NVIDIA GPU focus makes NCCL the wrong backend for CPU-only or non-NVIDIA collectives.
- Collective correctness is distributed. NCCL can't repair mismatched control flow above it.
- Topology selection can't fix bad cabling, oversubscription, NUMA placement, or unhealthy links.
- Communication kernels consume GPU resources and can interfere with compute when overlap is poorly scheduled.
- Environment overrides can hide one machine issue while hurting another.
- Fast transport doesn't choose a good model-parallel strategy. A framework still owns groups, tensor layouts, and bucket timing.
Call NCCL when the job is on NVIDIA GPUs and bytes have to move inside a node or across nodes. Stay on the framework's process-group API unless you're building a runtime, fused kernel, or communication library that needs NCCL directly.
NVIDIA project, public source
NVIDIA develops NCCL and publishes the source in the NVIDIA/nccl repository. The repo accepts signed-off contributions under a Developer Certificate of Origin, asks large changes to begin with an issue and design discussion, and keeps tests in the separate NVIDIA/nccl-tests project.[12]
The public contributor history records individual work; it isn't a permanent ownership roster or support contract.[16]
The pinned snapshot's license file needs a careful reading. Most project source uses Apache License 2.0, some parts retain original BSD terms, and borrowed files can carry their own licenses. "NCCL is BSD" or "every NCCL file is Apache-2.0" both erase that file-level boundary.[17]
Research roots rather than one NCCL paper
NCCL doesn't have one canonical paper that defines the whole current implementation. Its core ideas come from collective-communication research plus years of topology, kernel, and transport engineering.
Patarasuk and Yuan derived a bandwidth lower bound for all-reduce and described a ring algorithm that reaches it for large messages.[4] NVIDIA's NCCL 2.4 engineering account explains why double binary trees reduce latency growth while retaining high aggregate bandwidth, and why NCCL switches between tree and ring paths.[6]
Later hardware paths add NVSwitch collectives, in-network reduction, registration, multiple rails, and device-initiated communication. Those features change how an algorithm maps to hardware, but not the basic questions: who owns each chunk, how many rounds run, how many bytes cross each bottleneck, and when the consumer stream may read the result.
Source-reading exercise
Pick one all-reduce from a PyTorch profiler trace. Record tensor bytes, communicator size, duration, and whether compute overlaps it. Capture nvidia-smi topo -m beside the trace, then run nccl-tests near that message size on the same ranks.
Use the pinned source map to answer four questions:
- Which public function packages the operation?
- Which algorithm and protocol names appear in logs?
- Which graph and transport files correspond to the physical path?
- Which rank first diverges in the CPU operation-log example below?
Produce a small evidence table rather than a screenshot of a green benchmark. Connect logical operation, source path, topology labels, payload bytes, algbw, busbw, overlap time, correctness status, and first failure evidence. If the standalone row looks strong but the application still stalls, the table should make that boundary visible.
Without GPUs, compare planned collective logs instead. Each tuple below is (operation, count, dtype, reduction, root) for one communicator; None means an argument doesn't apply. This teaching checker compares completed logs. It neither runs NCCL nor detects a live network failure. A missing call is exercised locally, without leaving distributed processes hanging.
1from itertools import zip_longest
2
3def first_divergence(logs):
4 if not logs:
5 raise ValueError("at least one rank log is required")
6 missing = object()
7 for step, calls in enumerate(zip_longest(*logs, fillvalue=missing)):
8 if any(call != calls[0] for call in calls[1:]):
9 return step
10 return None
11
12reduce = ("all_reduce", 4, "float32", "sum", None)
13broadcast = ("broadcast", 4, "float32", None, 0)
14baseline = [reduce, broadcast]
15assert first_divergence([baseline] * 4) is None
16cases = {
17 "missing call": [reduce],
18 "reordered calls": [broadcast, reduce],
19 "different count": [("all_reduce", 3, "float32", "sum", None), broadcast],
20 "different root": [reduce, ("broadcast", 4, "float32", None, 1)],
21}
22for name, changed in cases.items():
23 logs = [baseline, baseline, changed, baseline]
24 step = first_divergence(logs)
25 assert step == (1 if name in ("missing call", "different root") else 0)
26 print(f"{name}: first disagreement at step {step}")
27
28payload_bytes, seconds, ranks = 256 * 2**20, 0.006, 4
29algbw = payload_bytes / seconds / 1e9
30busbw = algbw * 2 * (ranks - 1) / ranks
31assert round(algbw, 2) == 44.74 and round(busbw, 2) == 67.11
32print(f"illustrative receipt: algbw={algbw:.2f}, busbw={busbw:.2f} GB/s")1missing call: first disagreement at step 1
2reordered calls: first disagreement at step 0
3different count: first disagreement at step 0
4different root: first disagreement at step 1
5illustrative receipt: algbw=44.74, busbw=67.11 GB/sAgreement doesn't establish correctness: all ranks could agree on the wrong operation, and a correct log says nothing about buffer lifetime. Compare these contracts with the intended model computation and actual stream dependencies.
Takeaways
- A collective is an ordered agreement across ranks, not a remote function call.
- All-reduce decomposes into reduce-scatter plus all-gather, which explains sharded-training traffic.
- Rings achieve efficient point-to-point byte counts; trees cut rounds. Hardware offload and actual topology can change the best choice.
nvidia-smi topo -mgives path classes and affinities; it doesn't give achieved bandwidth.- For point-to-point all-reduce accounting,
nccl-testsbusbw is algbw times , the same factor as ring send volume. algbwdescribes an isolated logical payload. It isn't a substitute for an application's exposed step time after overlap.- CUDA streams make collectives asynchronous, but explicit dependencies still protect buffer correctness.
- Debug the earliest rank divergence, then validate the fabric separately.