Welcome back. In our last lesson, we established a key principle of the CUDA execution model: the asynchronous relationship between the host (CPU) and the device (GPU). You learned that most CUDA calls, like kernel launches, are non-blocking, allowing the CPU to continue its work while the GPU computes. We also introduced cudaDeviceSynchronize() as a powerful but blunt instrument to force the host to wait for all device work to complete.
While this asynchronicity is great for overlapping CPU and GPU work, cudaDeviceSynchronize() is too coarse a tool for maximizing GPU utilization. It creates a hard synchronization point, waiting for everything. What we truly want is to overlap operations on the GPU itself—for instance, copying the next batch of data from the host while the current batch is being processed by a kernel.
This lesson introduces the tools for achieving this fine-grained concurrency: CUDA streams and events. You will learn how to use streams to create independent sequences of operations and how to use events to manage dependencies between them and perform accurate timing. Mastering this will allow you to structure your CUDA programs to keep the GPU's copy and compute engines busy, significantly improving throughput.
CUDA Streams: Pipelines for GPU Work
A CUDA stream is a sequence of operations that execute on the device in the order they are issued by the host. The fundamental rule is:
- Operations within a single stream are guaranteed to execute sequentially.
- Operations in different streams have no defined order and can execute concurrently if their resource requirements don't conflict.
By default, all CUDA operations are issued to a special default stream (also called the "null stream"). This stream has a unique, synchronizing behavior: an operation in the default stream will only begin after all preceding operations in any stream on the device have completed, and no subsequent operation in any stream will begin until the default stream operation finishes. This makes the default stream unsuitable for achieving concurrent execution between multiple GPU tasks.
To enable concurrency, we must explicitly create and use our own non-default streams.
Asynchrony and CUDA Streams | CUDA C++ Class Part 2
The NVIDIA Developer "CUDA C++ Class" video provides an excellent introduction to streams.
Please watch the segment on CUDA streams. It clearly explains the concept of a stream as an ordered sequence of operations and contrasts the sequential execution within the default stream with the potential for concurrent execution across multiple, non-default streams.
Implementing with Streams
To use non-default streams, you first declare and create them in your host code. Then, you use special versions of CUDA API calls that accept a stream identifier.
The key functions are:
cudaStream_t stream;: Declares a stream handle.cudaStreamCreate(&stream);: Creates a non-default stream.kernel_name<<<grid, block, 0, stream>>>(...);: Launches a kernel into the specified stream. Thestreamargument is the fourth execution configuration parameter.cudaMemcpyAsync(dst, src, count, kind, stream);: Initiates an asynchronous memory copy in the specified stream.cudaStreamDestroy(stream);: Destroys the stream once you are finished with it.
It is crucial to use cudaMemcpyAsync. The standard, synchronous cudaMemcpy cannot be used to achieve overlap as it blocks the host.
Let's consult the NVIDIA Developer blog for a detailed walkthrough of the API.
How to Overlap Data Transfers in CUDA C/C++ - NVIDIA Developer
This blog post is a classic guide to stream-based optimization. It provides the foundational knowledge for everything we're about to do.
Please read the first two sections: Start with CUDA Streams. This part formally defines streams and the special behavior of the default stream. Continue with Non-default streams. This section shows the syntax for creating streams and launching asynchronous memory copies and kernels into them.
The Prerequisite: Pinned Memory
There's a critical subtlety to using cudaMemcpyAsync. For a host-to-device or device-to-host memory transfer to be truly asynchronous and overlap with other operations, the host-side memory must be pinned (or page-locked).
Standard host memory allocated with malloc or new is pageable, meaning the operating system can move it from physical RAM to a swap file on disk at any time. The GPU's DMA (Direct Memory Access) engine cannot access data that isn't in physical RAM. If you issue an async copy from pageable memory, the CUDA driver must first implicitly create a temporary pinned buffer, copy your data there, and then initiate the transfer to the GPU. This staging process effectively serializes the copy, defeating the purpose of asynchrony.
By allocating host memory as pinned, you guarantee to the OS that this memory will always reside in physical RAM, allowing the GPU's DMA engine to access it directly without CPU intervention.
Asynchrony and CUDA Streams | CUDA C++ Class Part 2
The same NVIDIA Developer video explains this concept very clearly.
Please watch the segment on pinned memory. It explains why pageable memory prevents true overlap and how using pinned memory solves this by bypassing the hidden staging copy.
We will cover the specific APIs for allocating pinned memory in our next lesson. For now, understand that it is a mandatory prerequisite for overlapping data transfers with kernel execution.
The Overlap Pattern: Pipelining Work
With streams and pinned memory, we can implement a powerful optimization pattern. Imagine you need to process a very large array. Instead of copying the entire array, processing it, and copying it back, you can break it into smaller chunks and process them in a pipeline.
The strategy is as follows:
- Divide the large array into
Nchunks. - Create a small number of streams (e.g., 2 to 4).
- Loop through the chunks, assigning each to a stream in a round-robin fashion. For each chunk
iin streams:- Issue an asynchronous copy of chunk
ifrom host to device (H2D) in streams. - Issue the kernel launch to process chunk
iin streams. - Issue an asynchronous copy of the results for chunk
ifrom device to host (D2H) in streams.
- Issue an asynchronous copy of chunk
Because operations within a stream are ordered, the kernel for chunk i will correctly wait for its data to arrive. Because operations in different streams can run concurrently, the H2D copy for chunk i+1 (in another stream) can overlap with the kernel execution for chunk i.
This overlap is visualized perfectly in the following diagram, which shows four streams working in a staggered fashion to keep the H2D copy engine, the kernel execution engine (K), and the D2H copy engine all busy simultaneously.

The CUDA Best Practices Guide provides an excellent summary and a code skeleton for this pattern.
[PDF] CUDA C Best Practices Guide
This guide provides authoritative advice on CUDA programming patterns.
Please read the following subsections within the "Memory Optimizations" chapter: First, look at Concurrent copy and execute to see a minimal example of launching a copy and a kernel into different streams. Then, read Staged concurrent copy and execute. This section details the chunking strategy and includes a before-and-after timeline that illustrates the performance gain.
Fine-Grained Control with CUDA Events
Streams manage sequences, but what if you need to synchronize at specific points? For example, how do you make a kernel in Stream B wait for a data transfer in Stream A to finish? Or how do you accurately time a kernel's execution without the overhead of host-side timers? For this, we use CUDA events.
An event is essentially a marker that you can place into a stream. The GPU records a timestamp when it reaches the event.
The core Event API functions are:
cudaEvent_t start, stop;: Declares event handles.cudaEventCreate(&start);: Creates an event.cudaEventRecord(start, stream);: Records the event in the specified stream. The event is marked as "occurred" only when the GPU has processed all preceding work in that stream.cudaStreamWaitEvent(stream_to_wait, event_to_wait_for, 0);: Makes all future work instream_to_waitstall untilevent_to_wait_forhas been recorded. This is the key to creating cross-stream dependencies.cudaEventSynchronize(stop);: Blocks the host CPU thread until the specified event has been recorded. This is a much more targeted way to synchronize thancudaDeviceSynchronize().cudaEventElapsedTime(&milliseconds, start, stop);: Calculates the wall-clock time in milliseconds between the recording of astartandstopevent. This is the canonical way to accurately measure GPU performance.
[PDF] CUDA C Best Practices Guide
The Best Practices Guide explains how to use the event API for timing.
In the "Performance Metrics" chapter, please read the section Using CUDA GPU Timers. It provides a clear code example and explanation for using cudaEventRecord, cudaEventSynchronize, and cudaEventElapsedTime.
Case Study: Overlap Strategies
Let's return to the How to Overlap Data Transfers blog post to see how these concepts are applied in a complete example. The author demonstrates two different ways to structure the loops for the chunking pattern and analyzes the performance implications, which depend on the GPU's hardware capabilities (specifically, the number of copy engines). Given your background in computing, this analysis of how software strategy interacts with hardware architecture will be particularly relevant.
How to Overlap Data Transfers in CUDA C/C++ - NVIDIA Developer
This final reading puts all the pieces together into a working example and analyzes the results.
Please read the final two sections of the article: First, review Synchronization with streams, which introduces the various event- and stream-based synchronization functions. Then, read the main section Overlapping Kernel Execution and Data Transfers. Focus on understanding the two different code structures presented. Pay close attention to the explanation of why their performance differs on a C1060 (1 copy engine) versus a C2050 (2 copy engines) and why modern GPUs with Hyper-Q are more flexible.
Conclusion
In this lesson, you have moved beyond simple asynchronous launching to a sophisticated model of GPU concurrency. You now have the tools to orchestrate multiple streams of work to maximize hardware utilization.
Key Takeaways:
- CUDA Streams are sequences of commands that execute in order. Operations in different non-default streams can execute concurrently.
- The default stream is a synchronizing stream and should be avoided when trying to achieve overlap between GPU tasks.
- Pinned (Page-Locked) Memory is a prerequisite for
cudaMemcpyAsyncto be truly non-blocking and enable overlap between data transfers and computation. - A common and effective pattern is to chunk a large problem and process the chunks in a pipeline across multiple streams.
- CUDA Events are markers used for fine-grained synchronization (e.g., creating dependencies between streams with
cudaStreamWaitEvent) and for accurate, low-overhead performance measurement on the device (cudaEventElapsedTime).
In our next lesson, we will dive deeper into GPU memory management. We will look at the explicit APIs for allocating pinned memory and introduce Unified Memory, a higher-level abstraction that simplifies memory management by providing a single memory space for both the CPU and GPU.