Lesson 7 · Execution & operations arc

GPU Architecture · Execution & Operations

The launch path: from <<<>>> to a warp on an SM

One kernel launch, traced end to end — what your compiler baked in, what the driver does, and how the work actually crosses into the GPU.

You've built two maps: the physical hardware (SMs, warps, schedulers, the memory tiers) and the logical CUDA model (kernel, grid, block, thread). This lesson adds a third thing that sits between them — the plumbing that carries a launch from your host process, through the driver, into the hardware you already understand. When a kernel misbehaves, the bug is often in this layer, not in your math. So let's follow a single launch the whole way down.

The one win

By the end you can name every stage between myKernel<<<grid, block>>>(args) and a warp issuing on an SM — and say who does each step: the compiler, the runtime, the driver, or the GPU itself.

It starts at compile time, not run time

nvcc is not really a compiler — it's a compiler driver that runs your .cu file down two paths and staples the results together:1

Device path

Host path

Decoding the toolchain vocabulary

Those two paths sling a lot of acronyms. Pin them down once — this is the vocabulary the whole ecosystem, and every compiler error, is written in:

nvcc
The driver you invoke. It orchestrates the two paths and calls the tools below; it doesn't compile device code itself.
PTX
Parallel Thread Execution — a virtual instruction set: portable, text, human-readable assembly. The GPU never runs it directly; it's an intermediate stage, roughly "the GPU's LLVM IR." Targets a virtual architecture, compute_XX.
ptxas
The PTX assembler — the optimizing back-end that turns PTX into real machine code for one specific GPU. Register allocation and most device-code optimization happen here, not in the front end.
SASS
The real, binary machine instructions an SM executes (the name is historical — "Streaming ASSembler"). This is the "device machine code" referred to throughout. Targets a real architecture, sm_XX.
compute_XX
sm_XX
The target architecture. compute_XX = virtual (what PTX targets); sm_XX = real (what SASS targets). The number XX is the GPU's compute capability — its hardware feature/ISA generation.
cubin
A CUDA binary: the ELF file ptxas emits, holding the SASS plus metadata for one architecture.
cuobjdump
nvdisasm
The inspector tools — they crack open a binary or cubin to show you the embedded PTX and disassembled SASS. (You'll use cuobjdump in the exercise below.)
Why you type -arch=sm_120

Your RTX 5050 is Blackwell, compute capability 12.0sm_120. When you build with nvcc -arch=sm_120 …, you're telling ptxas "produce SASS for a real 12.0 GPU," and nvcc also embeds matching compute_120 PTX as the portability copy. Match the flag to your hardware and the kernel runs its native SASS with no load-time JIT; aim at an older arch and you leave the newest instructions on the table.1

The consequence is worth sitting with: your CPU executable literally contains GPU machine code inside it. On Linux/ELF it lands in a section named .nv_fatbin. That's the first piece of "metadata that travels with the kernel" — and there's more riding along inside each cubin: the SASS, plus a .nv.info section recording each kernel's resource needs (how many registers, how much shared memory, the parameter layout).2

One more thing nvcc plants in the host binary: a hidden registration constructor that runs before main(). It hands the fatbinary to the CUDA runtime and, for every kernel, records a mapping from a host-side stub function to the kernel's mangled device name. That table is how a launch later finds the right GPU code. (Documented that this happens; the exact symbol names — __cudaRegisterFatBinary, __cudaRegisterFunction — are visible in the generated stubs but are internal, not a public contract.)2

# See it for yourself — the GPU code is right there in your binary:
cuobjdump -sass ./mat2d          # the SASS for each embedded kernel
cuobjdump -lelf ./mat2d          # list the cubin images inside the fatbin
readelf -S ./mat2d | grep nv_fatbin   # the section holding the device code
nm ./mat2d | grep -i cudaRegister     # the (internal) registration symbols
PTX vs SASS — why carry both? SASS is locked to an exact architecture; a sm_120 cubin won't run on a different major generation. PTX is forward-compatible, so if a binary meets a newer GPU it lacks SASS for, the driver JIT-compiles the PTX to that GPU's SASS at load time (and caches it). PTX is the portability insurance; SASS is the ready-to-run copy.1

First touch wakes the device

Nothing GPU-side has happened yet. The first CUDA call in your program that needs the device triggers lazy initialization: the runtime creates the device's primary context — think of a context as a process on the GPU: its own address space, its allocations, its loaded modules, its streams. This is also when any PTX gets JIT-compiled and modules are loaded into device memory.3

Version note

Since CUDA 12.0, cudaSetDevice() initializes the context eagerly; before 12.0 it deferred until the first call that truly needed it. Either way, there is a distinct "wake the device" moment — and its cost (context setup + JIT) is why your first kernel often looks mysteriously slow in a naïve timing. That's a measurement artifact, not your kernel.

What <<<>>> actually compiles to

The triple-chevron is pure syntactic sugar. For myKernel<<<grid, block, shmem, stream>>>(a, b), nvcc emits roughly:4

// 1. push the launch config (grid, block, shared-mem bytes, stream) onto a TLS slot
__cudaPushCallConfiguration(grid, block, shmem, stream);
// 2. call the generated host STUB for this kernel, which:
//    - packs &a, &b into an array of argument pointers
//    - pops the config back
//    - makes the one real runtime call:
cudaLaunchKernel(func, grid, block, args, shmem, stream);

Two things here repay attention:

And critically: cudaLaunchKernel is asynchronous. It hands the work off and returns to your CPU thread immediately — usually before the kernel has even started running, let alone finished. (That single fact drives all of Lesson 8: completion, timing, and error reporting.)5

Crossing into the GPU

Now the work leaves your process. The user-mode driver writes the launch as GPU commands into a pushbuffer, links that into a per-context ring buffer (the GPFIFO), and rings a doorbell — a single write to a register mapped into your process's address space. No system call per launch; that's what keeps launch overhead down to microseconds. On the GPU, the host interface sees the doorbell, reads the ring, and the GigaThread Engine — the global work distributor — begins handing your blocks to SMs, exactly as Lesson 3 described. Here is the whole path on one page:

  1. host process
    Your executable — already carries the kernel's SASS in an embedded fatbinary, and registered every kernel before main().
  2. CUDA runtime
    First CUDA call creates the context, JIT-compiles PTX if needed, loads modules into device memory.
  3. runtime + UMD
    <<<>>> → stub → cudaLaunchKernel. Args copied into a parameter buffer. Returns immediately.
  4. user-mode driver
    Writes GPU commands to a pushbuffer, appends a GPFIFO ring entry, rings the doorbell — no syscall.
  5. PCIe
  6. host interface
    Sees the doorbell, DMA-fetches the commands from the ring.
  7. GigaThread engine
    Global work distributor assigns each block to an SM (indivisible, whole block to one SM).
  8. SM warp schedulers
    Issue the block's warps of 32 threads — the launch is now running (Lessons 2–3).

Notice the arc closes exactly where the compute lessons began. Everything you learned about occupancy, warps, and scheduling picks up at the last two rows; this lesson is the four rows above them that you'd never seen.

How the doorbell needs no syscall

"Rings a doorbell, no syscall" sounds like magic — it's really memory-mapped I/O. Not every physical address is RAM; some are wired to devices, so a plain store to such an address becomes a PCIe write to the GPU. The GPU's doorbell registers live in exactly such a window (a PCIe BAR). The trick: once, at context setup, the kernel-mode driver mmaps that doorbell page into your process — the single privileged step. After it returns, you hold an ordinary pointer that reaches hardware. From then on, submitting one launch is three writes, and only the last touches the device:

write GPU commands   → pushbuffer    // ordinary RAM store
advance ring PUT ptr → GPFIFO        // ordinary RAM store
write channel id     → doorbell reg  // MMIO store → pokes the GPU

Steps 1–2 are plain stores to memory the GPU can also read; only step 3 hits hardware, and its page is already mapped — so no kernel transition, which is what buys microsecond launches. It stays safe because your doorbell rings only your channel, and the GPU's own MMU keeps that channel inside its own address space (the per-context isolation from earlier). If you know how an NVMe SSD or a DPDK NIC is fed — a command ring in memory plus a doorbell register in a BAR — this is the identical pattern, applied to kernel launches.

Your box adds two hops

You're on Windows 11 + WSL2. There, the doorbell doesn't ring the hardware directly: your Linux libcuda.so is a stub that issues calls to /dev/dxg, which a Linux driver forwards over a VM bus to the real NVIDIA driver on the Windows host (this is GPU paravirtualization). The model is identical — pushbuffer, ring, doorbell, GigaThread — but the extra VM-bus hop is why per-launch and small-copy overheads are a touch higher in WSL2 than on native Linux. Worth knowing before you benchmark.6

The honesty seam: what's documented, what's reverse-engineered

This matters for troubleshooting — it tells you which behaviors you can rely on and which are implementation details that could shift. NVIDIA documents the model down to cudaLaunchKernel and the PTX parameter space precisely. Below that, the pushbuffer/doorbell mechanics are pieced together from NVIDIA's open-source kernel modules, published hardware manuals, and community work — real, but not an API contract:

StageStatus
Split compilation, PTX/SASS/cubin/fatbin, JITDocumented
Lazy context init, cudaLaunchKernel, the 32,764-byte param limitDocumented
PTX .param space carrying argumentsDocumented
GigaThread engine / global work distributor (named, overview depth)Documented
.nv_fatbin section, __cudaRegister* stubs, push/pop-configCommunity-verified
Pushbuffer, GPFIFO, doorbell register, args in constant bank 0Open-driver / RE
Which SM a given block lands on (placement policy)Reverse-engineered

Check yourself

From memory — one click locks each answer.

CompileWhen you compile a CUDA program, the GPU machine code (SASS) ends up —

MechanismThe <<<grid, block>>> syntax ultimately becomes —

PathWhich component assigns your thread blocks to specific SMs?

Primary source (≈15 min)

CUDA C++ Programming Guide — "Compilation with NVCC" (the two-path trajectory, PTX vs cubin, JIT) and "Execution Configuration." Compilation · Execution config. For the front end, the classic Fermi Compute Architecture whitepaper is where the "GigaThread / global work distribution engine" is named.

The launch is now running — but your CPU thread has already moved on. So: how does it learn the kernel finished? How do the input bytes get to the GPU and the results get back? And when a thread reads a bad address deep inside a kernel, why does the error surface on some later call? That's Lesson 8 — data movement, completion signaling, and the async "sticky" error model. Ask me anything unclear here first, or say the word and we'll continue.

Notes & citations

  1. CUDA Compiler Driver NVCC, "The CUDA Compilation Trajectory," and CUDA C++ Programming Guide §3.1 (PTX vs cubin, fatbinary, just-in-time compilation of PTX by the driver). docs.nvidia.com/cuda/cuda-compiler-driver-nvcc
  2. CUDA Binary Utilities (cubin is ELF; .text, .nv.info, symbol sections). That device code is embedded and inspected by the runtime is documented in the NVCC guide; the exact .nv_fatbin section name and the __cudaRegister* stub symbols are community-verified via nvcc --keep / readelf, not a public API. docs.nvidia.com/cuda/cuda-binary-utilities
  3. CUDA C++ Programming Guide, "Runtime Initialization" — the primary context is created lazily on the first runtime call needing it (eagerly at cudaSetDevice since CUDA 12.0); a context is "analogous to a CPU process." docs.nvidia.com/cuda/cuda-c-programming-guide
  4. CUDA Runtime API, "Execution Control" — cudaLaunchKernel(func, gridDim, blockDim, args, sharedMem, stream), where args is an array of pointers to the actual parameters. Large-parameter limit (32,764 bytes on sm_70+, CUDA 12.1 / R530): CUDA 12.1 Supports Large Kernel Parameters. The __cudaPush/PopCallConfiguration desugaring is visible in generated stubs (nvcc --keep), not separately documented.
  5. CUDA C++ Programming Guide, "Asynchronous Concurrent Execution" — kernel launches are asynchronous and return control to the host immediately. docs.nvidia.com
  6. NVIDIA CUDA on WSL User Guide and Microsoft "DirectX ❤ Linux" — the WSL2 path: stubbed libcuda.so/dev/dxg → Linux dxgkrnl → VM bus → Windows host driver (GPU paravirtualization). The pushbuffer/GPFIFO/doorbell model is drawn from NVIDIA's open-gpu-doc hardware manuals and open-gpu-kernel-modules. docs.nvidia.com/cuda/wsl-user-guide
← Lesson 6 ⌂ Home Lesson 8 →