← Chapter 27: The step-by-step anatomy of a kernel launch Chapter 29: Coalesced vs strided memory access →
date 16 / jul / 26
mtwtfss
SCALE & PERFORMANCE — Chapter 28 index home

Why NVIDIA made CUDA asynchronous

*worked out from first principles*

The Assembly Line

The obvious way to run a big job on the GPU is three steps in a row. Copy the data over, run the work, copy the results back. It works, and it wastes most of your machine. Because while the data is copying in, the compute cores sit idle. While the cores compute, the copy machinery sit idle. While the results copy out, everything else sits idle. You paid for all these parts and you use them one at a time, taking turns, like a factory where only one station is ever running. The fix is to stop thinking in steps and start thinking like an assembly line, and understanding how that works reveals a surprising limit hiding underneath the word asynchronous.

THE PROBLEM: TAKING TURNSOne chunk at a time. Everyone else waits.1. COPY INChunk 02. COMPUTEChunk 03. COPY OUTChunk 0THENnext chunk...Copy In EngineidleidleidleCompute EngineidleidleidleCopy Out Engineidleidleidle2/3 of your expensivehardware sits idleall the time.Total time per chunk = T1 + T2 + T3 (everyone takes turns).

↳ Start with the hardware fact that makes it possible. A GPU does not have one worker, it has three independent ones that can all run at the same moment. There is an engine that copies data from the CPU's memory into the GPU's memory. There is the compute machinery that actually runs your work. And there is a third engine that copies results back from the GPU to the CPU. These are separate circuits. That means, at a single instant, data can be flowing in, other data can be computing, and still other results can be flowing out, all at once, if you arrange the work right.

THE THREE ENGINESA GPU has 3 independent engines that can run at the SAME time.COPY IN ENGINE(HtoD)COMPUTE ENGINECOPY OUT ENGINE(DtoH)Copies data:CPU mem → GPU memRuns your kernels(the math)Copies results:GPU mem → CPU memAll three can run simultaneously.

So here's the objection. How can compute and copy-in run at the same time? Doesn't the work need its data before it can compute on it? Yes, but not the same data. The trick is to break the job into chunks. While the compute engine is chewing on chunk one, the copy-in engine is already fetching chunk two, and the copy-out engine is shipping chunk zero's finished result back home. Each chunk still flows through all three stages in order: copy in, then compute, then copy out. But at any given moment the three engines are each working on a different chunk. It's exactly a factory line. As one car moves to painting, the next moves to assembly, and a third rolls in the door. Nobody waits.

And the payoff is large. In the naive take-turns version, if each of the three stages takes some time, every chunk costs you all three added together. In the overlapped line, once the pipeline is full, the three stages happen in parallel, so each chunk effectively costs only the single longest stage, not the sum. That is close to a three-fold speedup, and it comes not from faster math but purely from refusing to let expensive hardware sit idle.

THE SOLUTION: THE ASSEMBLY LINEBreak the job into CHUNKS. Different chunks sit at different stages.Copy InComputeCopy OutTime →Chunk 0CIN 0COMP 0COUT 0Chunk 1CIN 1COMP 1COUT 1Chunk 2CIN 2COMP 2COUT 2Chunk 3CIN 3COMP 3COUT 3...At any one moment:• Copy In → Chunk 2• Compute → Chunk 1• Copy Out → Chunk 0Pipeline full → each chunk costs the LONGEST stage, not the sum.≈ max(T1, T2, T3) — up to ~3x speedup.

Fire, Forget, and the Saturated Queue

Now who orchestrates this? You might think the CPU has to babysit every handoff, but it doesn't, and this is where the earlier idea of fire-and-forget comes back. The CPU uses queues, in-order lines of GPU commands. It dumps a sequence of orders into a queue: copy this chunk, compute this chunk, copy this result back, and immediately moves on without waiting. It can throw dozens of commands into the queues in a flash, and the GPU drains them at its own pace, keeping all three engines fed. The CPU is a manager rapidly scribbling tickets, not a worker standing over each machine.

FIRE AND FORGETThe CPU does NOT wait — it writes commands into a queue and moves on.CPU (Host)CPUManager writesorders & walks away.GPU Command Queue (in order)CIN 0COMP 0COUT 0CIN 1COMP 1COUT 1CIN 2COMP 2COUT 2...GPU (Device)GPUGPU pulls & executesat its own pace.The CPU submits many commands in a flash — the GPU drains them when ready.

But here is the twist that catches people who fall in love with the word asynchronous. If the CPU never waits, does that mean a loop launching a billion pieces of work would never stall the CPU at all? It feels like it should. But no, the CPU will eventually freeze. Because the command queue is not infinite. It is a bounded buffer that holds only so many pending commands. The CPU can fire commands faster than the GPU can complete them, so the queue fills up. And the moment it is full, the next launch has nowhere to go, so it blocks, and the CPU stalls right there, waiting for the GPU to finish one item and free a slot. So asynchronous does not mean unlimited. It means the CPU runs ahead freely only up to the depth of the queue. Once the buffer saturates, the backpressure quietly forces the CPU and GPU back into lockstep. The decoupling is real, but bounded.

ASYNCHRONOUS IS NOT INFINITEThe command queue is a bounded buffer.Queue Capacity = N0123...N-1CPU producescommands (fast)GPU consumescommands (slower)If the queue is FULL, thenext launch has nowhereto go.The CPU BLOCKS and waitsfor the GPU to free a slot.Asynchronous = the CPU runs ahead only up to the depth of the queue.Real decoupling — but bounded, not unlimited.

The Traps of Overlap

So you understand the pipeline, and you understand the queues. But getting them to actually overlap requires navigating two specific traps.

The first trap is the single queue. CUDA calls its queues streams. If you just launch copy, compute, copy in a loop without specifying a stream, everything goes into Stream 0, the default queue. And a single queue is strictly in-order. Command two cannot start until command one finishes. If chunk one's compute is in the queue behind chunk zero's copy-out, the GPU will not start computing chunk one, even though the compute engine is idle, because it must finish chunk zero's copy first. A single queue completely destroys the assembly line, collapsing everything back into taking turns. To get overlap, you must create multiple streams and put chunk zero in stream A, chunk one in stream B, and so on. Only then will the GPU scheduler see independent lines of work and schedule them on separate engines at the same time. Independent lines are what let independent engines run in parallel.

TWO TRAPS THAT KILL THE PIPELINEA) ONE QUEUE = NO OVERLAPSame queue → strict order.Single QueueCIN 0COMP 0COUT 0Compute waits for Copy In.Copy Out waits for Compute.→ back to taking turns.B) MULTIPLE QUEUES (STREAMS)Each stream runs independently.Stream 0:CIN 0COMP 0COUT 0Stream 1:CIN 1COMP 1COUT 1Stream 2:CIN 2COMP 2COUT 2GPU scheduler runs them onseparate engines in parallel.Independent queues are what let the three engines run at once.

Second, the copy engine can only run truly independently if the CPU's memory is locked in place. Normally the operating system is free to quietly relocate your data in memory whenever it likes. But the copy engine reaches directly into CPU memory on its own, without the CPU's help, and if the data moved out from under it, that would break. So for the async copy to work, you have to pin the memory, telling the system to lock this data at a fixed spot and never move it. Only then can the copy engine pull from it directly and independently while the CPU goes off doing other things. Without pinning, the copy quietly falls back to the slow, blocking behavior and your overlap silently disappears.

PIN YOUR MEMORYFor true async copies, lock the host memory in place.The Copy Engine (DMA) reads &writes CPU memory on its own.Copy EnginePinned (Locked)Host MemoryIf memory can move, the DMAmight read the WRONG place.OS moves it!Pin (page-lock) the memory so theOS cannot relocate it.Then the copy engine runs fullyindependently of the CPU.No pinning → the copy silently falls back to slow, blocking behaviour.

↳ The General Takeaway

So the whole picture. A GPU has three independent engines: copy-in, compute, copy-out, and running a job as three sequential steps wastes two of them at all times. By splitting the work into chunks and pipelining them, you keep all three busy on different chunks at once, approaching a three-fold speedup from overlap alone. The CPU drives this by firing commands into queues and walking away, but asynchronous has a ceiling, because the queue is finite and a saturated queue forces the CPU to wait. And the overlap only materializes if you use separate queues for separate chunks and lock your memory in place so the copy engine can work on its own.

The deepest lesson is one this whole world keeps teaching:
Peak performance is rarely about the math being fast.
It's about never letting any piece of expensive hardware stand idle while another one works.