LAB / GPU PROGRAMMING

DAY 17 / 30 · WRITING CUDA · ~12 MIN READ

Streams & concurrency

Overlapping compute with memory transfer instead of doing them back to back.

01 · The default serialization

Your GPU has been idling between steps

Every program so far ran on the default stream, and the default stream does one thing religiously: operations execute in launch order, each waiting for the last. So the day-13 ritual — copy in, launch, copy back — executes as a strict relay: the GPU computes nothing while the copy engine fills memory, and PCIe idles while the kernel runs.

// default stream: 3 phases, strictly in order
H2D  ██████████          
KERN            ██████████████
D2H                        ██████████

// two chunks on two streams: copy(k+1) hides under kernel(k)
H2D  ██████████     ██████████
KERN           ██████████████     ██████████████
D2H                      ██████████        ██████████

The second timeline is the opportunity: split the work into chunks and alternate them across two streams, so chunk k+1's transfer overlaps chunk k's compute. If transfer and compute cost similar time, the pipeline approaches 2× throughput — for no change to the math, just the scheduling.

02 · The machinery

Streams, events, and pinned memory

A stream is an ordered queue of device work; work in different streams may overlap. An event is a marker you can record into a stream and wait on — the GPU-side stopwatch. And one prerequisite is easy to miss: async copies need pinned memory. Copying from ordinary pageable RAM forces a staging hop through a locked buffer that silently re-synchronizes the stream — your "async" copy blocks anyway. Allocate staging buffers with cudaMallocHost (page-locked) and the copy engine can truly run behind the CPU's back:

cudaStream_t s0, s1;
cudaStreamCreate(&s0);  cudaStreamCreate(&s1);
float *hbuf;
cudaMallocHost(&hbuf, chunk_bytes);   // pinned: async-capable

for (int k = 0; k < n_chunks; ++k){
    cudaStream_t s = streams[k % 2];
    cudaMemcpyAsync(d_in + off, hbuf, chunk_bytes,
                     cudaMemcpyHostToDevice, s);
    process<<<grid, block, 0, s>>>(d_in + off, d_out + off, m);
    cudaMemcpyAsync(hout + off, d_out + off, chunk_bytes,
                     cudaMemcpyDeviceToHost, s);
}
cudaStreamSynchronize(s0);  cudaStreamSynchronize(s1);

Note the kernel launch grew a third chevron argument: <<<grid, block, smem, stream>>>. Events give you the timing story: record an event before and after, then cudaEventElapsedTime — far more honest than host timers, which measure your CPU's opinion of GPU work.

03 · The honest footnote about copy engines

Consumer cards ship one copy engine

Data-center GPUs have dedicated copy engines that push and pull simultaneously; most GeForce cards — the 4070 included — have a single full-duplex engine, so two host-to-device transfers still queue behind each other. What overlaps reliably is transfer against compute, which is exactly the chunked pattern above. The other overlap that works everywhere: independent kernels on separate streams, when you have two jobs that don't touch the same data.

And the pipeline's shape matters. Overlap helps when transfer time ≈ compute time (day 11's thinking, applied to a timeline instead of a chart). If compute dwarfs transfer, just optimize the kernel; if transfer dwarfs compute, reduce the bytes — day 23's fusion — because no scheduling hides a transfer that's genuinely the critical path.

04 · Where this shows up in real systems

Every training loop is a stream pipeline

Once you see it, the pattern is everywhere: prefetching the next training batch to the GPU while the current one computes; overlapped gradient all-reduce in data-parallel training; inference servers double-buffering requests. The ideas are always the same trio from this chapter — chunk, alternate streams, pin the staging memory. Day 18's Nsight Systems exists precisely to show you these timelines, and once you've built one by hand, its Gantt-style rows will read like the diagram in section 01.

CHEATSHEET · DAY 17
  • 01The default stream is a strict relay. Copy, compute, copy home — in order. Any overlap you want, you must create with streams.
  • 02Async copies need pinned memory. cudaMallocHost (page-locked) or the copy silently synchronizes through a staging buffer and your overlap evaporates.
  • 03Chunk and alternate: copy k+1 under kernel k. Two streams round-robin over chunks approaches 2× when transfer ≈ compute. Events time it honestly.
  • 04Consumer cards overlap transfer-with-compute, not transfer-with-transfer. One copy engine on the 4070: multiple copies queue. Independent kernels on separate streams still overlap.

Next: day 18 — reading an Nsight trace: the two profilers, the four numbers to look at first, and the measure-one-change discipline that turns all this knowledge into speed.

get in touch