Kernels look fine. The timeline doesn’t.
Nsight shows H→D, then a kernel, then D→H, then the same three bars again. SMs sit idle while the copy engine works. The copy engine sits idle while SMs work. Nothing is “wrong” with the kernel — the schedule never stacked independent engines.
A CUDA stream is an in-order queue of GPU work: kernels, memcpys, events. Same stream → serial. Different streams → the hardware may run phases together if they need different engines (or spare SM capacity).
Two numbers
1. Serial · wall ≈ 3N for N balanced chunks
One stream (or everything on the default stream): each chunk pays H→D + kernel + D→H end-to-end. Engines take turns.
2. Pipelined · wall ≈ N + 2 in steady state
With enough non-default streams, chunk k’s kernel overlaps chunk k+1’s H→D and chunk k−1’s D→H. Three engines busy; wall time drops toward the longest phase, not the sum.
Flip the tapes until that feels mechanical — not magical.
Pipeline: three engines, N streams
Same four chunks. Change stream count and watch H→D · kernel · D→H fill in.
// Async API + explicit stream — the building blocks cudaMemcpyAsync(dst, src, bytes, cudaMemcpyHostToDevice, stream); myKernel<<<grid, block, 0, stream>>>(args); cudaMemcpyAsync(host, dst, bytes, cudaMemcpyDeviceToHost, stream);
Kernel launch is already async w.r.t. the host. Overlap lives or dies on which stream you pass and whether the host buffer is pinned.
The default stream is a trap
Every process has a pre-created default stream (stream 0). Bare cudaMemcpy and myKernel<<<g,b>>> land there. Legacy default semantics: that stream implicitly fences every other stream in the process.
You can wire four beautiful non-default streams and still open a process-wide gap with one forgotten sync memcpy.
Synchronization without the sledgehammer
Eventually the host needs “is it done?” Use the lightest tool that fits:
| Call | Blocks |
|---|---|
cudaDeviceSynchronize() | Everything on the device |
cudaStreamSynchronize(s) | One stream’s queue |
cudaStreamQuery(s) | Nothing (poll) |
Events + cudaStreamWaitEvent | One stream waits on another — host free |
cudaEvent_t done; cudaEventCreate(&done); myKernel<<<grid, block, 0, streamA>>>(d_data); cudaEventRecord(done, streamA); // streamB waits on GPU work, not on the host thread cudaStreamWaitEvent(streamB, done, 0); consumerKernel<<<grid, block, 0, streamB>>>(d_data);
Events also measure GPU time: record before/after, then cudaEventElapsedTime — no CPU-clock skew.
Pitfalls that invent serialization
Same streams idea. Four ways people accidentally kill concurrency.
Stream priorities
int lo, hi; cudaDeviceGetStreamPriorityRange(&lo, &hi); cudaStream_t hot; cudaStreamCreateWithPriority(&hot, cudaStreamNonBlocking, hi);
High priority is not preemption mid-kernel — it wins at the next launch boundary. Useful for a latency-sensitive inference path beside a bulk training stream.
PyTorch
import torch s1 = torch.cuda.Stream() s2 = torch.cuda.Stream() with torch.cuda.stream(s1): a = torch.randn(1024, 1024, device='cuda') b = a @ a.T s2.wait_stream(s1) with torch.cuda.stream(s2): c = b.softmax(dim=-1) torch.cuda.synchronize()
The cache allocator tracks per-stream ownership and inserts waits so .to() across streams usually “just works.” Custom CUDA ops must still record on the current PyTorch stream or downstream ops will race.
What to do
- Never leave critical path work on the legacy default stream — explicit streams (or per-thread default) everywhere you care about overlap.
- Pin host memory for
cudaMemcpyAsync— pageable “async” is often a silent sync tax. - Pipeline H→D / kernel / D→H with 2–4 streams first; measure before inventing sixteen.
- Prefer events and stream waits over
cudaDeviceSynchronizein production loops. - Keep allocs out of the hot path — pool or
cudaMallocAsync; setup-time malloc only.
cudaStream_t streams[4]; for (int i = 0; i < 4; i++) cudaStreamCreateWithFlags(&streams[i], cudaStreamNonBlocking); for (int i = 0; i < nChunks; i++) { auto s = streams[i % 4]; cudaMemcpyAsync(d[i], h[i], bytes, cudaMemcpyHostToDevice, s); kernel<<<grid, block, 0, s>>>(d[i]); cudaMemcpyAsync(out[i], d[i], bytes, cudaMemcpyDeviceToHost, s); } for (int i = 0; i < 4; i++) cudaStreamSynchronize(streams[i]);
When streams are the wrong tool
- Multi-process GPU sharing → CUDA MPS, not more streams in one process.
- Cross-device work → NCCL / peer memcpy; streams do not cross devices.
- One long kernel, nothing to overlap → fix the kernel; streams only hide independent latency.
Streams keep the GPU fed. Get the compute right first, then stop wasting engines between phases.
Further reading
- CUDA C++ Programming Guide — Streams — official semantics
- CUDA Best Practices — Concurrent Execution — overlap patterns
- PyTorch CUDA semantics — streams, events, memory
Related concepts
Deep dive into the CUDA context object: control vs data plane, inventory (memory, modules, streams, events, graphs), push/pop/setCurrent stacks, primary retain/release, flags and limits, isolation, cost, and traps.
Decision map for CUDA: a context is per-process GPU state, a stream is an in-order queue inside a context, and MPS shares one context across processes. Pick the layer that matches the problem.
Why exclusive CUDA contexts leave SMs idle under multi-process load, how MPS multiplexes clients through a shared context, thread percentage caps, and when to pick exclusive, MPS, or MIG.
How /dev/nvidia*, nvidiactl, nvidia-uvm, and DRI nodes map major/minor numbers to the driver, what CUDA opens first, and the minimum mount set for containers.
NVIDIA vs AMD for deep learning compared at both layers: the CUDA vs ROCm software moat, the microarchitecture (warp vs wavefront, SM vs CU, Tensor vs Matrix Cores), and the datacenter accelerators (H100/H200/B200 vs MI300X/MI325X).
Flynn's Classification explained — SISD, SIMD, MISD, MIMD with interactive architecture explorer, SIMD evolution from MMX to AMX, branch divergence visualization, and workload-architecture throughput comparison.
