Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Communication-Computation Overlap with CUDA Streams and Asynchronous Collectives

Introduction

Welcome to our next lesson on optimizing large-scale training. In our previous session, we examined how to manage communication overhead by reducing its frequency with gradient accumulation and its volume with gradient compression. We noted that in modern systems, the utility of gradient compression can be limited because its latency is often hidden by another powerful optimization.

Today, we will explore that very optimization: communication-computation overlap. This is a fundamental technique used by all major deep learning frameworks to hide the time spent on network communication, thereby maximizing GPU utilization and training throughput.

This lesson will cover:

  • The core concept of communication-computation overlap as a form of software pipelining.
  • The underlying system mechanisms that enable it, namely CUDA streams and asynchronous collective operations.
  • How these components are orchestrated during the backward pass of distributed data-parallel training.

By the end, you will understand how frameworks keep the GPU busy with useful computation, even while large amounts of data are being transferred across the network.

The Core Concept: Software Pipelining

At its heart, communication-computation overlap is an application of a classic computer science technique: software pipelining. Given your background, you'll recognize this as a method to overlap asynchronous operations that have data dependencies, preventing hardware from stalling while waiting for data.

In the context of GPU programming, the primary goal is to overlap data transfers (from high-latency main memory or network) with computation (executed by the ALUs). To understand this conceptually, let's first review the memory hierarchy.

Software Pipelining - JAX documentation

This article from the JAX documentation provides an excellent conceptual introduction to software pipelining for hardware accelerators. It explains the motivation by starting with the memory hierarchy.

Please read the sections 'Memory Hierarchies', 'Pipelining Basics', and 'Deriving a Double-Buffered Pipeline'. Focus on: The trade-offs between different memory types (SRAM vs. DRAM/HBM). The visual diagram that breaks a problem into 'copy_in', 'compute', and 'copy_out' stages and shows how they can be overlapped in time. The pseudocode walkthrough that transforms a synchronous, blocking loop into an asynchronous, pipelined one using multiple buffers. This illustrates the logic of pre-fetching.

As the article explains, a naive approach executes these stages sequentially: copy data, compute, copy results back. This leaves the compute units idle during data transfers. The key insight of pipelining is that while the GPU is computing on data chunk i, it can simultaneously fetch data chunk i+1 from memory and write back the results from chunk i-1.

This creates a "steady state" where the expensive data transfers are effectively hidden behind computation, maximizing the utilization of the processor's floating-point units (FLOP/s). The initial and final phases, where the pipeline is "filling" and "draining," are known as "bubbles." The goal is to make the steady-state phase as long as possible.

The Mechanism: Asynchrony and CUDA Streams

Now, let's move from the conceptual "what" to the implementation "how". The ability to execute operations concurrently on the GPU is managed by CUDA streams.

A CUDA stream is a sequence of operations (like kernel launches or memory copies) that execute on the GPU in the order they are issued by the host (CPU).

  • Operations within a single stream are serialized.
  • Operations in different streams can execute concurrently, provided there are no resource dependencies.

This is the fundamental mechanism for achieving overlap on a GPU. You can issue a memory copy on one stream and a compute kernel on another; if the GPU has the necessary hardware resources, it will execute them in parallel.

CUDA Stream Execution and Communication-Computation Overlap
This diagram from the NVIDIA CUDA documentation illustrates how asynchronous API calls on the CPU can enqueue work onto different CUDA streams. 'Stream 1' handles a memory copy (Memcpy H2D) while 'Stream 2' executes a kernel. The operations overlap in time on the GPU, while the CPU is free to perform other work.

To achieve this, operations must be launched asynchronously. For example, instead of cudaMemcpy, which blocks the CPU until the transfer is complete, you use cudaMemcpyAsync, which returns control to the CPU immediately after the transfer is initiated. This function, along with kernel launches, takes a stream identifier as an argument to direct the work to the correct queue on the GPU.

Asynchrony and CUDA Streams | CUDA C++ Class Part 2

This NVIDIA Developer video provides a clear, practical explanation of asynchrony and CUDA streams.

Please watch the segment from 25:23 to 30:22. It explains how to use multiple streams to enable concurrent execution of memory copies and computations, and differentiates between synchronizing the whole device versus a single stream.

A crucial detail for achieving true asynchrony with host-to-device memory transfers is the use of pinned (or page-locked) memory. The GPU's memory controller can only transfer data from physical RAM pages that are guaranteed not to be swapped to disk by the operating system. By allocating host memory as "pinned," you allow the CUDA driver to initiate a direct memory access (DMA) transfer without needing an intermediate staging buffer, which would otherwise serialize the copy.

Application: Overlap in Distributed Training

We can now connect these concepts directly to distributed data-parallel training. The backward pass of a neural network offers a perfect opportunity for pipelining. Gradients are computed layer by layer, starting from the last layer and moving backward to the first.

The Naive (Synchronous) Approach:

  1. Execute the entire backward pass, computing gradients for all model parameters.
  2. Wait for all gradients to be ready.
  3. Initiate a single, large AllReduce operation to sum the gradients across all GPUs.
  4. Wait for the AllReduce to complete.
  5. Update the model weights.

In this scenario, the GPU compute units are idle during step 3 and 4, waiting for the network communication to finish.

The Pipelined (Asynchronous) Approach:
This is where the magic happens. Modern frameworks like PyTorch (using its DDP module) and Horovod partition the model's parameters into buckets. As soon as the backward pass has computed the gradients for all parameters in a bucket, the framework can immediately initiate an asynchronous AllReduce for that bucket on a dedicated communication stream.

While this communication is happening in the background, the GPU continues the backward pass on its main computation stream, calculating gradients for the next bucket of parameters.

Lecture 17: NCCL

This video from the GPU MODE channel provides an excellent visualization and explanation of how this overlap is implemented in PyTorch DDP with a NCCL backend.

Please watch from 09:37 to 23:05. Pay close attention to: The profiler trace, which clearly shows two different CUDA streams: one for computation kernels (like gemm for matrix multiplication) and another for nccl:all_reduce kernels. The explanation of why it's faster to start the AllReduce for the last layer's gradients as soon as they are ready, rather than waiting for the entire backward pass to finish. This is the core of the pipelining logic.

Asynchronous Collectives and NCCL

The video highlights the role of asynchronous collectives. Communication libraries like the NVIDIA Collective Communications Library (NCCL) are fundamental to this process. NCCL provides highly optimized implementations of collective operations like AllReduce, AllGather, and Broadcast.

Crucially, these NCCL operations are stream-aware. When you call ncclAllReduce, you provide a CUDA stream as an argument. This enqueues the collective operation on the specified stream and returns control to the host immediately, allowing other computations to proceed on different streams. This non-blocking behavior is what makes the entire pipeline possible.

Communication-Computation Overlap in a Transformer Block with Tensor Parallelism
This diagram from NVIDIA's Megatron-LM documentation shows the same principle applied to tensor parallelism. Communication operations (All-Gather 'AG' and Reduce-Scatter 'RS' in green) are overlapped with computation kernels (blue) during both the forward and backward passes of a Transformer block.

Conclusion

In this lesson, we demystified the concept of communication-computation overlap. Far from being a minor tweak, it is a cornerstone of high-performance distributed training, allowing systems to hide the otherwise significant latency of network communication.

Key Takeaways:

  • Communication-computation overlap is a software pipelining technique that executes network transfers concurrently with GPU computation to hide communication latency.
  • It is implemented using CUDA streams, which act as independent work queues on the GPU, allowing operations on different streams to run in parallel.
  • The key enablers are asynchronous operations, such as non-blocking memory copies and, most importantly, stream-aware asynchronous collectives provided by libraries like NCCL.
  • In distributed data parallelism, frameworks overlap the AllReduce of gradients from earlier parts of the backward pass with the gradient computation for later parts, dramatically improving GPU utilization.

Preview of the Next Lesson:

Our discussion has centered on decentralized communication patterns where all workers participate equally in an AllReduce operation. However, this is not the only architecture for distributed training. In our next lesson, we will explore an alternative: the parameter server architecture. We will contrast this centralized approach with the decentralized methods we've studied and analyze their respective trade-offs in terms of communication patterns, scalability, and fault tolerance.

Can't find a good explanation? Sign up and we'll make it for you

Sign up