Skip to main content
CUDA C++ · lesson 4 of 4
CUDA C++ advanced Lesson 4 of 4

CUDA C++: Streams, Async Copies and Overlap

Overlap copies and kernels with CUDA streams on a Tesla T4: pinned vs pageable memory, a 4-stream pipeline in nsys, the default-stream trap, events.

By SkillByExample · Updated

A stream is a queue of GPU work. Copies and kernels in one stream run in order; work in different streams can run at the same time, so a copy can move the next chunk of data while a kernel computes on the last one. This lesson shows the calls returning early, the memory that quietly makes them synchronous, a pipeline that overlaps copies with compute, and the two ways streams wait for each other when you didn’t ask them to. Every program ran on a Tesla T4 with CUDA 13.0 (driver 580.82.07, nvcc V13.0.88).

Asynchronous calls return before the work is done

Here a 64 MB copy to the GPU, a kernel and a copy back are queued on one stream, and the CPU checks the clock before and after waiting for them.

// async_return.cu - a copy, a kernel and a copy back on one stream return before the GPU is done
#include <chrono>
#include <cstdio>
#include <cuda_runtime.h>

__global__ void work(float* x, int n, int iters) {
  int i = blockIdx.x * blockDim.x + threadIdx.x;
  if (i < n) { float v = x[i]; for (int k = 0; k < iters; k++) v = v * 0.5f + 1.0f; x[i] = v; }
}

double ms_since(std::chrono::steady_clock::time_point t0) {
  return std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
}

int main() {
  const int n = 1 << 24;  // 16M floats, 64 MB
  float *h, *d;
  cudaMallocHost(&h, n * 4);  // page-locked host memory (more on this below)
  cudaMalloc(&d, n * 4);
  for (int i = 0; i < n; i++) h[i] = i % 100;
  cudaStream_t s;
  cudaStreamCreate(&s);
  work<<<1, 1, 0, s>>>(d, 1, 1);  // warm-up: loads the kernel (CUDA loads kernels lazily, see below)
  cudaStreamSynchronize(s);

  auto t0 = std::chrono::steady_clock::now();
  cudaMemcpyAsync(d, h, n * 4, cudaMemcpyHostToDevice, s);
  work<<<n / 256, 256, 0, s>>>(d, n, 1000);
  cudaMemcpyAsync(h, d, n * 4, cudaMemcpyDeviceToHost, s);
  printf("all three calls returned after %6.2f ms\n", ms_since(t0));
  printf("cudaStreamQuery right away:    %s\n", cudaGetErrorName(cudaStreamQuery(s)));
  cudaStreamSynchronize(s);
  printf("cudaStreamSynchronize done at  %6.2f ms\n", ms_since(t0));
  printf("cudaStreamQuery after sync:    %s\n", cudaGetErrorName(cudaStreamQuery(s)));

  int wrong = 0;  // CPU reference: v = v * 0.5 + 1 converges to exactly 2.0
  for (int i = 0; i < n; i++) wrong += h[i] != 2.0f;
  printf("elements that differ from the CPU answer: %d of %d\n", wrong, n);
  cudaStreamDestroy(s); cudaFreeHost(h); cudaFree(d);
}
$ nvcc -o async_return async_return.cu && ./async_return
all three calls returned after   0.02 ms
cudaStreamQuery right away:    cudaErrorNotReady
cudaStreamSynchronize done at   17.85 ms
cudaStreamQuery after sync:    cudaSuccess
elements that differ from the CPU answer: 0 of 16777216

All three calls returned in 0.02 ms, but the work took 17.9 ms (three runs: 0.02 to 0.03 ms, and 14.8 to 17.9 ms). cudaMemcpyAsync and a launch with a stream argument (<<<grid, block, 0, s>>>) only put work in the stream’s queue. The GPU runs that queue in order: the kernel can’t start until the copy in has finished, and the copy back waits for the kernel.

cudaStreamQuery asks whether the queue is empty without waiting. cudaErrorNotReady isn’t a failure, it means “still running”. cudaStreamSynchronize blocks the CPU until the queue is empty, and only after it is the result safe to read: every element matches the CPU’s answer of 2.0, which v * 0.5 + 1 reaches exactly from any starting value here.

Pageable memory makes an async copy synchronous

The example above used cudaMallocHost. This one copies the same 256 MB twice, from ordinary malloc memory and from cudaMallocHost memory, and times both the call and the copy.

// pageable_trap.cu - cudaMemcpyAsync from malloc'd memory blocks the CPU; from pinned memory it doesn't
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cuda_runtime.h>

void copy_from(const char* label, float* h, float* d, size_t bytes, cudaStream_t s) {
  cudaEvent_t a, b; cudaEventCreate(&a); cudaEventCreate(&b);
  auto t0 = std::chrono::steady_clock::now();
  cudaEventRecord(a, s);
  cudaMemcpyAsync(d, h, bytes, cudaMemcpyHostToDevice, s);
  cudaEventRecord(b, s);
  double ret = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
  cudaStreamSynchronize(s);
  float gpu; cudaEventElapsedTime(&gpu, a, b);
  printf("%-8s call returned after %7.2f ms, copy took %6.2f ms (%5.2f GB/s)\n",
         label, ret, gpu, bytes / gpu / 1e6);
}

int main() {
  const size_t n = 1 << 26, bytes = n * 4;  // 256 MB
  float *pageable = (float*)malloc(bytes), *pinned, *d, *back = (float*)malloc(bytes);
  cudaMallocHost(&pinned, bytes);
  cudaMalloc(&d, bytes);
  for (size_t i = 0; i < n; i++) pageable[i] = pinned[i] = i % 1000;
  cudaStream_t s; cudaStreamCreate(&s);
  copy_from("warm-up", pinned, d, bytes, s);
  for (int run = 0; run < 2; run++) {
    copy_from("malloc", pageable, d, bytes, s);
    copy_from("pinned", pinned, d, bytes, s);
  }
  cudaMemcpy(back, d, bytes, cudaMemcpyDeviceToHost);
  printf("device copy matches the host data: %s\n", memcmp(back, pageable, bytes) == 0 ? "yes" : "no");
}
$ nvcc -o pageable_trap pageable_trap.cu && ./pageable_trap
warm-up  call returned after    0.06 ms, copy took  21.71 ms (12.37 GB/s)
malloc   call returned after   58.21 ms, copy took  58.28 ms ( 4.61 GB/s)
pinned   call returned after    0.01 ms, copy took  21.70 ms (12.37 GB/s)
malloc   call returned after   58.42 ms, copy took  58.46 ms ( 4.59 GB/s)
pinned   call returned after    0.02 ms, copy took  21.70 ms (12.37 GB/s)
device copy matches the host data: yes

From malloc memory, cudaMemcpyAsync held the CPU for the whole copy, 57 to 61 ms in four runs, and moved data at 4.4 to 4.7 GB/s. From pinned memory the call returned in 0.01 or 0.02 ms and the copy ran at 12.35 to 12.37 GB/s.

The GPU’s copy engines read host memory directly, which needs page-locked (pinned) memory: pages the operating system promises not to move or swap out. malloc memory isn’t pinned, so the driver copies it into a pinned staging buffer first, using the CPU, and the CUDA runtime docs say the call returns only once the data has reached that staging buffer. For a large copy that’s almost all of it. The extra pass through the staging buffer is also why it’s slower.

The fix is to allocate the buffers you copy from with cudaMallocHost (or cudaHostAlloc, or pin existing memory with cudaHostRegister). Pinned memory is taken away from the operating system, so pin the buffers you stream through, not everything. The Python version of this comparison is in CUDA Python: memory transfers.

Overlapping copies and compute across streams

With pinned memory, the copies and kernels of different streams can run at the same time. This program splits 64 MB into 8 chunks; each chunk gets a copy in, a kernel and a copy out, and chunk c goes to stream c % nstreams.

// pipeline.cu - copy in, compute, copy out in 8 chunks, spread over 1, 2 or 4 streams
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cuda_profiler_api.h>
#include <cuda_runtime.h>

__global__ void work(float* x, int n, int iters) {
  int i = blockIdx.x * blockDim.x + threadIdx.x;
  if (i < n) {
    float v = x[i];
    for (int k = 0; k < iters; k++) v = v * 0.5f + 1.0f;  // converges to 2.0
    x[i] = v + x[i];                                       // so the answer is 2 + input
  }
}

const int N = 1 << 24, CHUNKS = 8, C = N / CHUNKS;

double run(int nstreams, float* in, float* out, float* d, cudaStream_t* s) {
  memset(out, 0, N * 4);
  auto t0 = std::chrono::steady_clock::now();
  for (int c = 0; c < CHUNKS; c++) {
    cudaStream_t st = s[c % nstreams];  // chunk c goes to stream c mod nstreams
    size_t off = (size_t)c * C;
    cudaMemcpyAsync(d + off, in + off, C * 4, cudaMemcpyHostToDevice, st);
    work<<<C / 256, 256, 0, st>>>(d + off, C, 1000);
    cudaMemcpyAsync(out + off, d + off, C * 4, cudaMemcpyDeviceToHost, st);
  }
  cudaDeviceSynchronize();
  return std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
}

int main(int argc, char** argv) {
  int only = argc > 1 ? atoi(argv[1]) : 0;  // ./pipeline 4: one warm-up, then one profiled run
  cudaDeviceProp p; cudaGetDeviceProperties(&p, 0);
  if (!only) printf("%s, asyncEngineCount = %d\n", p.name, p.asyncEngineCount);
  float *in, *out, *d;
  cudaMallocHost(&in, N * 4); cudaMallocHost(&out, N * 4); cudaMalloc(&d, N * 4);
  for (int i = 0; i < N; i++) in[i] = i % 100;
  cudaStream_t s[4];
  for (int i = 0; i < 4; i++) cudaStreamCreate(&s[i]);
  if (only) {
    run(only, in, out, d, s);
    cudaProfilerStart(); run(only, in, out, d, s); cudaProfilerStop();  // nsys records only this
    return 0;
  }
  run(4, in, out, d, s);  // warm-up
  for (int ns : {1, 2, 4}) {
    double total = 0;
    for (int rep = 0; rep < 5; rep++) total += run(ns, in, out, d, s);
    int wrong = 0;
    for (int i = 0; i < N; i++) wrong += out[i] != 2.0f + i % 100;
    printf("%d stream(s): %6.2f ms (mean of 5), wrong elements: %d\n", ns, total / 5, wrong);
  }
}
$ nvcc -o pipeline pipeline.cu && ./pipeline
Tesla T4, asyncEngineCount = 3
1 stream(s):  19.05 ms (mean of 5), wrong elements: 0
2 stream(s):  10.63 ms (mean of 5), wrong elements: 0
4 stream(s):   9.19 ms (mean of 5), wrong elements: 0

The same 24 operations took about half as long on two or four streams. Across four runs, one stream took 16.7 to 20.9 ms, two streams 9.6 to 11.7 ms and four streams 7.9 to 11.3 ms; every run had 0 wrong elements. On one stream each chunk waits for the previous chunk’s copy out. With more streams, chunk 1’s copy in can run while chunk 0’s kernel computes.

asyncEngineCount is the number of asynchronous copy engines. With 2 or more, the GPU can copy in, copy out and run a kernel all at once. Two streams already capture most of the gain here, because the kernels are the longest stage: once the copies are hidden behind them, more streams have nothing left to hide.

The cudaProfilerStart and ./pipeline 4 lines are for the next section.

Seeing the overlap with Nsight Systems

Timings only say that the pipeline got faster. A trace shows what ran at the same time. nsys can record every copy and kernel; --capture-range=cudaProfilerApi records only what happens between cudaProfilerStart and cudaProfilerStop, so the warm-up run is left out. This script turns the trace’s CSV into a timeline and adds up how long a copy and a kernel ran together.

# trace.py - read nsys's CUDA GPU trace (CSV) and show what ran when, on which stream
import csv
import sys


def merged(spans):  # join overlapping (start, end) spans into one list of busy periods
    out = []
    for s, e in sorted(spans):
        if out and s <= out[-1][1]:
            out[-1][1] = max(out[-1][1], e)
        else:
            out.append([s, e])
    return out


def shared_time(a, b):  # how long both lists of busy periods are busy at once
    return sum(max(0, min(e1, e2) - max(s1, s2)) for s1, e1 in a for s2, e2 in b)


rows = [r for r in csv.DictReader(sys.stdin) if r.get("Duration (ns)")]
t0 = min(int(r["Start (ns)"]) for r in rows)
copies, kernels = [], []
for r in rows:
    start = (int(r["Start (ns)"]) - t0) / 1e6
    end = start + int(r["Duration (ns)"]) / 1e6
    name = r["Name"]
    kind = "copy in" if "Host-to-Device" in name else "copy out" if "Device-to-Host" in name else "kernel"
    (kernels if kind == "kernel" else copies).append((start, end))
    if "-v" in sys.argv:
        print(f"{start:6.2f} -> {end:6.2f} ms  stream {r['Strm']}  {kind}")
span = max(e for _, e in copies + kernels)
print(f"first operation to last: {span:.2f} ms")
print(f"a copy and a kernel running at the same time: {shared_time(merged(copies), merged(kernels)):.2f} ms")
$ nsys profile --capture-range=cudaProfilerApi -o p1 --force-overwrite true ./pipeline 1 > /dev/null 2>&1
$ nsys stats -q --force-export=true -r cuda_gpu_trace -f csv p1.nsys-rep | python3 trace.py
first operation to last: 18.61 ms
a copy and a kernel running at the same time: 0.00 ms
$ nsys profile --capture-range=cudaProfilerApi -o p4 --force-overwrite true ./pipeline 4 > /dev/null 2>&1
$ nsys stats -q --force-export=true -r cuda_gpu_trace -f csv p4.nsys-rep | python3 trace.py -v
  0.00 ->   0.74 ms  stream 13  copy in
  0.74 ->   1.46 ms  stream 14  copy in
  0.75 ->   1.99 ms  stream 13  kernel
  1.46 ->   2.23 ms  stream 15  copy in
  1.96 ->   3.22 ms  stream 14  kernel
  1.99 ->   2.85 ms  stream 13  copy out
  2.23 ->   3.04 ms  stream 16  copy in
  3.04 ->   3.86 ms  stream 13  copy in
  3.20 ->   4.45 ms  stream 15  kernel
  3.23 ->   4.03 ms  stream 14  copy out
  4.04 ->   4.79 ms  stream 14  copy in
  4.43 ->   5.69 ms  stream 16  kernel
  4.46 ->   5.19 ms  stream 15  copy out
  5.19 ->   5.92 ms  stream 15  copy in
  5.66 ->   6.91 ms  stream 13  kernel
  5.69 ->   6.39 ms  stream 16  copy out
  6.41 ->   7.13 ms  stream 16  copy in
  6.89 ->   8.15 ms  stream 14  kernel
  6.92 ->   7.61 ms  stream 13  copy out
  8.12 ->   9.38 ms  stream 15  kernel
  8.15 ->   8.79 ms  stream 14  copy out
  9.35 ->  10.61 ms  stream 16  kernel
  9.38 ->  10.02 ms  stream 15  copy out
 10.61 ->  11.25 ms  stream 16  copy out
first operation to last: 11.25 ms
a copy and a kernel running at the same time: 8.11 ms

On one stream nothing overlapped: 0.00 ms. On four, a copy ran beside a kernel for 8.11 of the 11.25 ms (four traces: 17.7 to 19.9 ms and no overlap on one stream; 10.1 to 12.2 ms with 7.5 to 8.7 ms of overlap on four). The numbers 13 to 16 are the IDs nsys gave the four streams.

Read the first lines of the four-stream timeline. Stream 13’s kernel starts at 0.75 ms, right after its copy in, while stream 14’s copy in runs from 0.74 to 1.46 ms. Copies in run one at a time, never two at once, because one engine does them all; copies out run on another engine at the same time as the next kernel. The kernels themselves run almost back to back, overlapping only at their tails: each kernel’s 8,192 blocks of 256 threads fill the GPU, so there’s no room for a second kernel. The kernel stage is the floor, which is why four streams couldn’t do much better than two.

The traced run is slower than the timed one: profiling adds work, and the T4’s clock speed varies from run to run. Compare traces with traces, not with the timings above.

The legacy default stream serialises everything

A launch without a stream argument goes to stream 0, the default stream. Here three small kernels, one block each, are launched on stream a, stream 0 and stream b.

// default_stream.cu - one launch on stream 0 makes two other streams wait for each other
#include <chrono>
#include <cstdio>
#include <cuda_runtime.h>

const int ITERS = 1 << 23;

__global__ void spin(float* x) {  // one small block that just burns time
  float v = x[threadIdx.x];
  for (int k = 0; k < ITERS; k++) v = v * 0.5f + 1.0f;
  x[threadIdx.x] = v;
}

double timed(cudaStream_t a, cudaStream_t b, float* x, bool use_stream_0) {
  cudaDeviceSynchronize();
  auto t0 = std::chrono::steady_clock::now();
  spin<<<1, 32, 0, a>>>(x);
  if (use_stream_0) spin<<<1, 32>>>(x + 32);  // no stream argument: the default stream
  spin<<<1, 32, 0, b>>>(x + 64);
  cudaDeviceSynchronize();
  return std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
}

int main() {
  float* x; cudaMalloc(&x, 96 * 4); cudaMemset(x, 0, 96 * 4);
  cudaStream_t a, b, na, nb;
  cudaStreamCreate(&a); cudaStreamCreate(&b);
  cudaStreamCreateWithFlags(&na, cudaStreamNonBlocking);
  cudaStreamCreateWithFlags(&nb, cudaStreamNonBlocking);
  for (int i = 0; i < 3; i++) timed(a, b, x, true);  // warm-up: lets the GPU clock ramp up
  printf("streams a and b only:              %6.1f ms\n", timed(a, b, x, false));
  printf("a, then stream 0, then b:          %6.1f ms\n", timed(a, b, x, true));
  printf("same, a and b made NonBlocking:    %6.1f ms\n", timed(na, nb, x, true));
}
$ nvcc -o default_stream default_stream.cu && ./default_stream
streams a and b only:                21.5 ms
a, then stream 0, then b:            64.4 ms
same, a and b made NonBlocking:      21.5 ms
$ nvcc --default-stream per-thread -o default_stream_pt default_stream.cu && ./default_stream_pt
streams a and b only:                21.5 ms
a, then stream 0, then b:            21.5 ms
same, a and b made NonBlocking:      21.5 ms

Each kernel runs for about 21.5 ms, and kernels on a and b alone run side by side. Adding one launch on stream 0 tripled the time to 64.4 ms (64.3 to 64.4 in three runs): the three kernels ran one after another.

By default stream 0 is the legacy default stream, and it synchronises with every stream made by cudaStreamCreate. Work issued to it waits for everything already queued in those streams, and work issued to them afterwards waits for it. So b’s kernel waited for stream 0’s kernel, which waited for a’s. The kernels here use one block each on purpose; overlap needs free room on the GPU, which the small kernels leave.

There are two fixes, and both brought the time back to 21.5 ms:

  • cudaStreamCreateWithFlags(&s, cudaStreamNonBlocking): the stream doesn’t synchronise with the legacy default stream at all.
  • nvcc --default-stream per-thread: stream 0 becomes a per-thread default stream that behaves like any other stream, for every launch and copy in the files compiled with the flag. A library built without it still uses the legacy default stream unless you pass it a stream.

Stream 0 is easy to use without noticing: cudaMemcpy, cudaMemset and a launch with no stream argument all go there, and so do library calls you haven’t given a stream.

Making one stream wait for another with an event

Sometimes one stream really must wait: a kernel can’t read data that another stream is still copying in. Stream a copies 256 MB of 1.0s into d_in, and stream b adds 1 to every element, first with no dependency between them, then with an event.

// wait_event.cu - stream b reads data that stream a is still copying in, then the fix
#include <cstdio>
#include <cstring>
#include <cuda_runtime.h>

__global__ void add_one(const float* in, float* out, int n) {
  int i = blockIdx.x * blockDim.x + threadIdx.x;
  if (i < n) out[i] = in[i] + 1.0f;
}

int main(int argc, char** argv) {
  const int n = 1 << 26;  // 256 MB, so the copy in takes about 20 ms
  float *h_in, *h_out, *d_in, *d_out;
  cudaMallocHost(&h_in, n * 4); cudaMallocHost(&h_out, n * 4);
  cudaMalloc(&d_in, n * 4); cudaMalloc(&d_out, n * 4);
  for (int i = 0; i < n; i++) h_in[i] = 1.0f;  // so every output should be 2.0
  cudaStream_t a, b;
  cudaStreamCreateWithFlags(&a, cudaStreamNonBlocking);
  cudaStreamCreateWithFlags(&b, cudaStreamNonBlocking);
  cudaEvent_t copied;
  cudaEventCreateWithFlags(&copied, cudaEventDisableTiming);
  if (argc < 2 || strcmp(argv[1], "nowarm") != 0)  // ./wait_event nowarm skips the warm-up
    add_one<<<1, 1, 0, b>>>(d_in, d_out, 1);       // load the kernel now (see "the race that hid")

  for (int fixed = 0; fixed < 2; fixed++) {
    cudaMemset(d_in, 0, n * 4);
    cudaDeviceSynchronize();
    cudaMemcpyAsync(d_in, h_in, n * 4, cudaMemcpyHostToDevice, a);
    if (fixed) {
      cudaEventRecord(copied, a);         // a marker after the copy in stream a
      cudaStreamWaitEvent(b, copied, 0);  // b's later work waits for it; the CPU doesn't
    }
    add_one<<<n / 256, 256, 0, b>>>(d_in, d_out, n);
    cudaMemcpyAsync(h_out, d_out, n * 4, cudaMemcpyDeviceToHost, b);
    cudaStreamSynchronize(b);
    int wrong = 0;
    for (int i = 0; i < n; i++) wrong += h_out[i] != 2.0f;
    printf("%-26s %8d of %d elements wrong\n", fixed ? "with cudaStreamWaitEvent:" : "no dependency:", wrong, n);
  }
}
$ nvcc -o wait_event wait_event.cu && ./wait_event
no dependency:             67099520 of 67108864 elements wrong
with cudaStreamWaitEvent:         0 of 67108864 elements wrong

Without a dependency, 99.99% of the outputs were wrong (67,098,240 to 67,100,032 in three runs): b’s kernel read d_in while a’s 21.7 ms copy had barely started, so it saw the zeros left by cudaMemset and wrote 1.0. Two streams make no promise about order between them.

cudaEventRecord(copied, a) puts a marker in stream a after the copy, and cudaStreamWaitEvent(b, copied, 0) makes everything queued on b after it wait until the marker is reached. Both calls return to the CPU at once, and stream a and any other stream keep running. With them, 0 elements were wrong. cudaDeviceSynchronize() between the copy and the kernel would also have been correct, but it stops the CPU and waits for every stream on the GPU.

The race that hid

The warm-up launch before the loop is there for a reason. Run the same program with nowarm, which skips it, then again with every kernel loaded at startup.

$ ./wait_event nowarm
no dependency:                    0 of 67108864 elements wrong
with cudaStreamWaitEvent:         0 of 67108864 elements wrong
$ CUDA_MODULE_LOADING=EAGER ./wait_event nowarm
no dependency:             66999168 of 67108864 elements wrong
with cudaStreamWaitEvent:         0 of 67108864 elements wrong

Without the warm-up, the program with no dependency came out right in all three runs. Recent CUDA versions load each kernel lazily, the first time it’s launched. NVIDIA’s lazy-loading notes warn that loading a kernel can synchronise, and here it waited for the copy already running, which happened to put the kernel after it. With CUDA_MODULE_LOADING=EAGER, which loads every kernel at startup, the race came back: 66,964,224 to 67,051,200 elements wrong in three runs.

A missing dependency can pass every test that runs a kernel for the first time. Run stream code with CUDA_MODULE_LOADING=EAGER, or warm up each kernel, before you trust that it’s correct.

Choosing how to wait

CallWho waitsFor what
cudaStreamSynchronize(s)the CPUeverything queued on stream s
cudaEventSynchronize(e)the CPUthe point where e was recorded
cudaDeviceSynchronize()the CPUevery stream on the GPU
cudaStreamQuery(s)nobodyreturns cudaSuccess or cudaErrorNotReady
cudaStreamWaitEvent(s, e, 0)stream s, not the CPUthe point where e was recorded

Wait for as little as you need, as rarely as you can. Practice 4 measures what waiting too often costs.

Common gotchas

  • cudaMemcpyAsync from pageable memory isn’t asynchronous. The 256 MB copy above held the CPU for about 58 ms. Pin the buffers you copy from and to.
  • Stream 0 is a synchronisation point. One cudaMemcpy, cudaMemset or launch with no stream serialises every stream made with cudaStreamCreate. Use cudaStreamNonBlocking or --default-stream per-thread.
  • Streams don’t make a big kernel share the GPU. Kernels overlap only when the first leaves free room. The pipeline’s kernels each filled the T4 and ran one after another.
  • The first launch can hide a race. Lazy loading made the missing dependency above invisible until CUDA_MODULE_LOADING=EAGER exposed it.
  • Don’t touch a buffer an async copy is still using. Writing new data into a pinned host buffer before its copy has finished changes what gets copied. Wait on the stream or an event first.
  • The first run is slower. With a single warm-up round, the first timing in default_stream.cu was sometimes 33 ms instead of 21.5 ms while the GPU clock ramped up; three rounds fixed it. Time several runs and warm up first.
  • CUDA graphs can capture a whole stream pipeline and replay it with one call; see Practice 2 of CUDA performance and profiling. The Python version of this lesson’s pipeline is CUDA Python: streams and overlap.

Practice

  1. Keep 4 streams but vary the number of chunks from 1 to 4096. Where does chunking stop helping?
Solution
// p1_chunks.cu - the 4-stream pipeline with 1 to 4096 chunks: where does chunking stop paying?
#include <chrono>
#include <cstdio>
#include <cstring>
#include <cuda_runtime.h>

__global__ void work(float* x, int n, int iters) {
  int i = blockIdx.x * blockDim.x + threadIdx.x;
  if (i < n) {
    float v = x[i];
    for (int k = 0; k < iters; k++) v = v * 0.5f + 1.0f;
    x[i] = v + x[i];
  }
}

const int N = 1 << 24;

double run(int chunks, float* in, float* out, float* d, cudaStream_t* s) {
  int c_len = N / chunks;
  memset(out, 0, N * 4);
  auto t0 = std::chrono::steady_clock::now();
  for (int c = 0; c < chunks; c++) {
    cudaStream_t st = s[c % 4];
    size_t off = (size_t)c * c_len;
    cudaMemcpyAsync(d + off, in + off, c_len * 4, cudaMemcpyHostToDevice, st);
    work<<<(c_len + 255) / 256, 256, 0, st>>>(d + off, c_len, 1000);
    cudaMemcpyAsync(out + off, d + off, c_len * 4, cudaMemcpyDeviceToHost, st);
  }
  cudaDeviceSynchronize();
  return std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
}

int main() {
  float *in, *out, *d;
  cudaMallocHost(&in, N * 4); cudaMallocHost(&out, N * 4); cudaMalloc(&d, N * 4);
  for (int i = 0; i < N; i++) in[i] = i % 100;
  cudaStream_t s[4];
  for (int i = 0; i < 4; i++) cudaStreamCreate(&s[i]);
  run(8, in, out, d, s);  // warm-up
  for (int chunks : {1, 2, 4, 8, 16, 32, 64, 256, 1024, 4096}) {
    double best = 1e9;
    for (int rep = 0; rep < 5; rep++) { double t = run(chunks, in, out, d, s); best = t < best ? t : best; }
    int wrong = 0;
    for (int i = 0; i < N; i++) wrong += out[i] != 2.0f + i % 100;
    printf("%5d chunks of %8d floats: %6.2f ms (best of 5), wrong: %d\n", chunks, N / chunks, best, wrong);
  }
}
$ nvcc -o p1_chunks p1_chunks.cu && ./p1_chunks
    1 chunks of 16777216 floats:  16.96 ms (best of 5), wrong: 0
    2 chunks of  8388608 floats:  11.59 ms (best of 5), wrong: 0
    4 chunks of  4194304 floats:   8.97 ms (best of 5), wrong: 0
    8 chunks of  2097152 floats:   7.91 ms (best of 5), wrong: 0
   16 chunks of  1048576 floats:   7.44 ms (best of 5), wrong: 0
   32 chunks of   524288 floats:   7.39 ms (best of 5), wrong: 0
   64 chunks of   262144 floats:   7.51 ms (best of 5), wrong: 0
  256 chunks of    65536 floats:   8.36 ms (best of 5), wrong: 0
 1024 chunks of    16384 floats:  13.44 ms (best of 5), wrong: 0
 4096 chunks of     4096 floats:  42.28 ms (best of 5), wrong: 0

One chunk can’t overlap anything, whatever the number of streams. The time falls until 16 to 32 chunks, 7.4 to 7.5 ms in all three runs, and is flat to 64. After that each chunk is too small to pay for its three API calls and a launch: 1024 chunks of 64 KB took 13.4 ms, and 4096 chunks of 16 KB took 41 to 42 ms. run clears out before each pass, so wrong: 0 means every chunk really came back.

  1. Allocate a third buffer with cudaHostAlloc(..., cudaHostAllocWriteCombined) and compare its copy speed to the GPU, and how fast the CPU can read it, with malloc and cudaMallocHost.
Solution
// p2_wc.cu - host-to-device copies from pageable, pinned and write-combined pinned memory
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cuda_runtime.h>

float h2d_ms(float* h, float* d, size_t bytes) {
  cudaEvent_t a, b; cudaEventCreate(&a); cudaEventCreate(&b);
  float best = 1e9f;
  for (int rep = 0; rep < 5; rep++) {
    cudaEventRecord(a);
    cudaMemcpyAsync(d, h, bytes, cudaMemcpyHostToDevice);
    cudaEventRecord(b);
    cudaEventSynchronize(b);
    float ms; cudaEventElapsedTime(&ms, a, b);
    best = ms < best ? ms : best;
  }
  return best;
}

double cpu_read_ms(const float* h, size_t n) {  // the CPU reads every 16th float (one per 64-byte line)
  auto t0 = std::chrono::steady_clock::now();
  volatile double sum = 0;
  for (size_t i = 0; i < n; i += 16) sum = sum + h[i];
  return std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
}

int main() {
  const size_t n = 1 << 26, bytes = n * 4;  // 256 MB
  float *pageable = (float*)malloc(bytes), *pinned, *wc, *d;
  cudaMallocHost(&pinned, bytes);
  cudaHostAlloc(&wc, bytes, cudaHostAllocWriteCombined);
  cudaMalloc(&d, bytes);
  for (size_t i = 0; i < n; i++) pageable[i] = pinned[i] = wc[i] = 1.0f;
  const char* names[] = {"malloc", "cudaMallocHost", "WriteCombined"};
  float* bufs[] = {pageable, pinned, wc};
  for (int k = 0; k < 3; k++) {
    float ms = h2d_ms(bufs[k], d, bytes);
    printf("%-15s copy to GPU %6.2f ms (%5.2f GB/s), CPU read %8.2f ms\n",
           names[k], ms, bytes / ms / 1e6, cpu_read_ms(bufs[k], n));
  }
}
$ nvcc -o p2_wc p2_wc.cu && ./p2_wc
malloc          copy to GPU  57.53 ms ( 4.67 GB/s), CPU read    22.94 ms
cudaMallocHost  copy to GPU  21.71 ms (12.36 GB/s), CPU read    23.58 ms
WriteCombined   copy to GPU  21.69 ms (12.38 GB/s), CPU read    22.51 ms

On this machine write-combined memory made no difference: both pinned buffers copied at 12.36 to 12.38 GB/s in four runs, and the CPU read all three in 22.5 to 26.8 ms. The CUDA documentation describes write-combined memory as faster to copy over PCIe on some systems and very slow for the CPU to read; neither showed up on this Colab VM, and this run can’t tell why. Measure it on the machine you deploy to, and use it only for buffers the CPU writes and never reads.

  1. Run a short urgent kernel behind a large one, first on a stream of the same priority, then on a stream created with cudaStreamCreateWithPriority at the highest priority.
Solution
// p3_priority.cu - a short urgent kernel behind a big busy one: does a high-priority stream help?
#include <cstdio>
#include <cuda_runtime.h>

__global__ void work(float* x, int n, int iters) {
  int i = blockIdx.x * blockDim.x + threadIdx.x;
  if (i < n) { float v = x[i]; for (int k = 0; k < iters; k++) v = v * 0.5f + 1.0f; x[i] = v; }
}

float urgent_latency(cudaStream_t big, cudaStream_t urgent, float* x, float* y) {
  cudaEvent_t a, b; cudaEventCreate(&a); cudaEventCreate(&b);
  work<<<1 << 16, 256, 0, big>>>(x, 1 << 24, 4000);  // fills every SM for a while
  cudaEventRecord(a, urgent);
  work<<<4, 256, 0, urgent>>>(y, 1024, 100);           // tiny, launched right after
  cudaEventRecord(b, urgent);
  cudaDeviceSynchronize();
  float ms; cudaEventElapsedTime(&ms, a, b);
  return ms;
}

int main() {
  int least, greatest;
  cudaDeviceGetStreamPriorityRange(&least, &greatest);
  printf("priority range: %d (lowest) to %d (highest)\n", least, greatest);
  float *x, *y; cudaMalloc(&x, (1 << 24) * 4); cudaMalloc(&y, 1024 * 4);
  cudaStream_t big, normal, high;
  cudaStreamCreateWithPriority(&big, cudaStreamNonBlocking, least);
  cudaStreamCreateWithPriority(&normal, cudaStreamNonBlocking, least);
  cudaStreamCreateWithPriority(&high, cudaStreamNonBlocking, greatest);
  urgent_latency(big, normal, x, y);  // warm-up
  for (int rep = 0; rep < 3; rep++) {
    float same = urgent_latency(big, normal, x, y);
    float higher = urgent_latency(big, high, x, y);
    printf("urgent kernel finished after: same priority %6.2f ms, high priority %6.2f ms\n", same, higher);
  }
}
$ nvcc -o p3_priority p3_priority.cu && ./p3_priority
priority range: 0 (lowest) to -5 (highest)
urgent kernel finished after: same priority  16.43 ms, high priority   0.03 ms
urgent kernel finished after: same priority  16.42 ms, high priority   0.03 ms
urgent kernel finished after: same priority  16.54 ms, high priority   0.03 ms

Lower numbers are higher priority, from 0 down to -5 on the T4. At the same priority, the urgent kernel waited 16.4 to 17.6 ms in three runs, most likely until all of the big kernel’s 65,536 blocks had been started. At high priority it finished in 0.03 or 0.04 ms: whenever a block of the big kernel finished, the scheduler gave the free space to the waiting high-priority blocks first. Priority doesn’t stop blocks that are already running, so it works this well only because each block of the big kernel is short. The two measurements are stored in same and higher before printf, because C++ doesn’t fix the order in which a call’s arguments are evaluated.

  1. How often you wait matters. Put a cudaDeviceSynchronize() after each chunk of the 4-stream pipeline, “just to be safe”, and compare it with waiting once at the end, on one event per stream.
Solution
// p4_sync.cu - the 4-stream pipeline with a cudaDeviceSynchronize after every chunk, then one event per stream
#include <chrono>
#include <cstdio>
#include <cstring>
#include <cuda_runtime.h>

__global__ void work(float* x, int n, int iters) {
  int i = blockIdx.x * blockDim.x + threadIdx.x;
  if (i < n) {
    float v = x[i];
    for (int k = 0; k < iters; k++) v = v * 0.5f + 1.0f;
    x[i] = v + x[i];
  }
}

const int N = 1 << 24, CHUNKS = 8, C = N / CHUNKS;

double run(bool sync_every_chunk, float* in, float* out, float* d, cudaStream_t* s, cudaEvent_t* done) {
  memset(out, 0, N * 4);
  auto t0 = std::chrono::steady_clock::now();
  for (int c = 0; c < CHUNKS; c++) {
    cudaStream_t st = s[c % 4];
    size_t off = (size_t)c * C;
    cudaMemcpyAsync(d + off, in + off, C * 4, cudaMemcpyHostToDevice, st);
    work<<<C / 256, 256, 0, st>>>(d + off, C, 1000);
    cudaMemcpyAsync(out + off, d + off, C * 4, cudaMemcpyDeviceToHost, st);
    if (sync_every_chunk) cudaDeviceSynchronize();  // "just to be safe"
  }
  if (!sync_every_chunk) {
    for (int i = 0; i < 4; i++) cudaEventRecord(done[i], s[i]);
    for (int i = 0; i < 4; i++) cudaEventSynchronize(done[i]);  // wait for each stream's last chunk
  }
  return std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
}

int main() {
  float *in, *out, *d;
  cudaMallocHost(&in, N * 4); cudaMallocHost(&out, N * 4); cudaMalloc(&d, N * 4);
  for (int i = 0; i < N; i++) in[i] = i % 100;
  cudaStream_t s[4]; cudaEvent_t done[4];
  for (int i = 0; i < 4; i++) { cudaStreamCreate(&s[i]); cudaEventCreate(&done[i]); }
  run(false, in, out, d, s, done);  // warm-up
  for (bool sync : {true, false}) {
    double total = 0;
    for (int rep = 0; rep < 5; rep++) total += run(sync, in, out, d, s, done);
    int wrong = 0;
    for (int i = 0; i < N; i++) wrong += out[i] != 2.0f + i % 100;
    printf("%-32s %6.2f ms (mean of 5), wrong: %d\n",
           sync ? "cudaDeviceSynchronize per chunk:" : "one event per stream at the end:", total / 5, wrong);
  }
}
$ nvcc -o p4_sync p4_sync.cu && ./p4_sync
cudaDeviceSynchronize per chunk:  15.03 ms (mean of 5), wrong: 0
one event per stream at the end:   7.73 ms (mean of 5), wrong: 0

Both versions are correct, but waiting after every chunk took 15.03 to 15.39 ms against 7.73 to 7.84 ms for waiting once at the end, in three runs. What costs the time is how often the CPU waits, not which call it uses: waiting after each chunk means the next chunk isn’t queued until the last one is done, so the four streams never have more than one chunk to work on, and the pipeline runs about as slowly as a single stream. Waiting once at the end lets all 24 operations be queued first; one cudaDeviceSynchronize() there would do as well as the four events, which only matter when you need to wait for some streams and not others.

Frequently Asked Questions

What is a CUDA stream?
A stream is a queue of GPU work: copies and kernel launches in one stream run in the order they were issued, while work in different streams may run at the same time. Calls such as cudaMemcpyAsync and a kernel launch with a stream argument only add work to the queue and return to the CPU at once. On a Tesla T4, a 64 MB copy in, a kernel and a copy back were all queued in about 0.03 ms and finished about 17 ms later.
Why is my cudaMemcpyAsync not asynchronous?
The host buffer is probably ordinary pageable memory from malloc or new. The GPU's copy engines can only read page-locked memory, so the driver stages pageable data through its own buffer and the call doesn't return until that's done. On a Tesla T4, a 256 MB cudaMemcpyAsync from malloc'd memory blocked the CPU for about 58 ms at 4.6 GB/s; from cudaMallocHost memory it returned in 0.01 ms and the copy ran at 12.4 GB/s.
How many CUDA streams do I need to overlap copies and kernels?
Two or three are often enough, because a GPU can usually run one copy in, one copy out and kernels at the same time. Split the data into chunks and give each chunk its own copy in, kernel and copy out on a stream. On a Tesla T4, an 8-chunk pipeline took about 16 to 21 ms on one stream, 9 to 12 ms on two and 8 to 11 ms on four. More chunks helped up to about 16 to 32, then per-chunk overhead took over.
What is the legacy default stream problem in CUDA?
Work launched without a stream goes to the legacy default stream, stream 0, which waits for all earlier work in ordinary streams and makes all later work in them wait for it. One stray launch on stream 0 can serialise streams that would otherwise overlap. Create streams with cudaStreamNonBlocking, or compile with nvcc --default-stream per-thread; on a Tesla T4 either fix took three small kernels from 64 ms back to 21.5 ms.
Should I use cudaStreamWaitEvent or cudaDeviceSynchronize between streams?
Use cudaEventRecord on the producing stream and cudaStreamWaitEvent on the consuming one when only one stream has to wait. It's queued like any other operation, so the CPU and the other streams keep going. cudaDeviceSynchronize stops the CPU until every stream is idle, so it's best kept for the end of a batch of work. How often you wait matters too: syncing the whole device after every chunk of a 4-stream pipeline on a Tesla T4 doubled its time, 15 ms against 7.7 ms for waiting once at the end.