GPU Architecture · Execution & Operations
<<<>>> to a warp on an SMOne 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.
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.
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
__global__ code → PTX (a virtual, forward-compatible assembly) → ptxas → SASS (real machine code for sm_120) in a cubin (an ELF file).main() and CPU code → ordinary C++; the <<<>>> syntax is lowered to plain function calls.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:
compute_XX.sm_XX.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.ptxas emits, holding the SASS plus metadata for one architecture.cuobjdump in the exercise below.)-arch=sm_120
Your RTX 5050 is Blackwell, compute capability 12.0 → sm_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
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
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
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.
<<<>>> actually compiles toThe 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:
func is a host address used only as a lookup key — it indexes the registration table from earlier to find the actual device code. It is not a pointer to GPU memory..param space you met in Lesson 5). There's a hard size cap on this buffer: 32,764 bytes on your GPU (Volta and newer, CUDA 12.1+); it was just 4 KB before. Pass a giant struct by value and you'll hit it.4And 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
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:
main().<<<>>> → stub → cudaLaunchKernel. Args copied into a parameter buffer. Returns immediately.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.
"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.
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
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:
| Stage | Status |
|---|---|
| Split compilation, PTX/SASS/cubin/fatbin, JIT | Documented |
Lazy context init, cudaLaunchKernel, the 32,764-byte param limit | Documented |
PTX .param space carrying arguments | Documented |
| GigaThread engine / global work distributor (named, overview depth) | Documented |
.nv_fatbin section, __cudaRegister* stubs, push/pop-config | Community-verified |
| Pushbuffer, GPFIFO, doorbell register, args in constant bank 0 | Open-driver / RE |
| Which SM a given block lands on (placement policy) | Reverse-engineered |
From memory — one click locks each answer.
CompileWhen you compile a CUDA program, the GPU machine code (SASS) ends up —
nvcc runs a device path (→ SASS in a cubin) and a host path, then embeds the fatbinary into the host binary (the .nv_fatbin section). PTX inside it is JIT-compiled only if the binary meets a GPU it lacks SASS for.
MechanismThe <<<grid, block>>> syntax ultimately becomes —
nvcc lowers the chevrons to push-config → host stub → cudaLaunchKernel. The launch is asynchronous: it enqueues the work and returns to the CPU right away, typically before the kernel even starts.
PathWhich component assigns your thread blocks to specific SMs?
The host interface reads the submitted commands; the GigaThread engine (global work distributor) hands whole blocks to SMs. The warp schedulers then issue warps within a block — they don't decide block placement.
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.
.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-utilitiescudaSetDevice since CUDA 12.0); a context is "analogous to a CPU process." docs.nvidia.com/cuda/cuda-c-programming-guidecudaLaunchKernel(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.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