What a GPU is made of, how its programming model maps onto that silicon, and a line-by-line trace of a real LLM kernel — RMSNorm.
Navigate with ← → or Space · F for fullscreen
The host talks to the GPU over PCIe. On-chip, data climbs a hierarchy: the further from the cores, the larger and slower.
Every SM is a self-contained processor: its own register file, its own shared memory, and the cores that do the math. A block lives entirely inside one SM.
Issues one instruction to a warp of 32 cores per cycle. Four per SM.
The core is a bare calculator with no memory; the thread owns its private registers. That split is what makes context switches free.
Fast on-chip SRAM the whole block cooperates through — the reason a block can't be split across two SMs.
You program in logical terms. The hardware maps each one onto physical silicon. Understanding that mapping is the whole game.
Zoom inside one SM. You write code for a single thread, but the hardware executes a warp — 32 threads — together. One instruction, 32 threads: Single Instruction, Multiple Threads.
Each thread has a hardwired register holding its own threadIdx.x.
When you write A[threadIdx.x], the SM issues one LDG
instruction — but core 0 computes address A[0], core 1 computes A[1],
and so on. One instruction, 32 addresses.
LDG / STG — load / store global memoryFADD, FMUL — float add, multiplyFFMA — fused multiply-add (one rounded op)__shfl_down_sync passes values directly between the 32 registers in ~5
cycles — no trip through shared memory. We'll use this in RMSNorm.
64 elements need 64 threads — exactly 2 warps. Step through the six
instructions the SM issues to compute C = A + B.
Each warp adds its 32 pairs in a single
FADD — the whole vector is done in six warp-instructions.
An SM has only 128 physical cores, yet keeps up to ~1,536 threads resident at once. Why so many? A memory load costs ~200 cycles — so instead of stalling, the SM switches the cores to another ready thread.
Every thread's registers already sit in the SM's register file. Nothing has to be saved or loaded to switch threads — so a context switch costs zero cycles.
Thread 0 issues a load and goes to wait; the cores immediately run Thread 1, then Thread 2… By the time Thread 0's data arrives, the cores come back to it. The 128 ALUs stay busy ~100%.
Core = calculator (no memory). Thread = owns the registers.
CUDA's way to launch threads is the block — a team that cooperates
through shared memory and __syncthreads(). The whole team must fit on one SM
at once, and that constraint is exactly why blocks cap at 1024.
Suppose you could launch 4,096 threads on an SM that physically holds ~1,536:
__syncthreads()1024 threads/block · ~1,536 active threads/SM · one block always fits with room to spare.
2048 elements but only 1024 threads. You don't ask for more threads — each thread processes several elements in sequence: a grid-stride loop.
Launch a grid of 2,048 blocks on 58 SMs. The GigaThread Engine fills all 58, and the remaining blocks wait in a hardware queue.
The normalization at the heart of modern LLMs. Compute the root-mean-square of each row, scale by it, then multiply by a learned weight.
rows (S) → launch 32 blocks, one per row
embedding dim (d) → 1024 threads loop ×2
block per row — rows are independent
This is correct, readable, and slow. Every operation on the input is a separate CUDA kernel launch that reads from and writes back to VRAM.
class RMSNorm(nn.Module): def forward(self, x): # fp32 for numerical stability v = x.float().pow(2) \ .mean(-1, keepdim=True) xn = x * torch.rsqrt(v + self.eps) return self.weight * xn
Each of pow, mean,
add, rsqrt, and the two
muls launches its own kernel — ~6 launches for a
single normalization.
Source: chapter_1/llama_inference.py · next, we
fuse all six ops into one kernel — the four phases on the silicon.
One block of 1024 threads turns one row of 2048 numbers into its normalized output. It happens in four phases — the next four slides walk each one line by line.
Every thread squares its own elements and accumulates them in a private register. With d = 2048 and 1024 threads, each thread handles two elements via the grid-stride loop.
// per thread · tid = threadIdx.x float local = 0.0f; // private register for (int i = tid; i < d; i += blockDim.x) { float v = x[row*d + i]; // LDG from VRAM local += v * v; // FFMA, stays in reg } // i = tid, then tid+1024 → 2 passes
All 32 warps issue the same LDG and FFMA in
SIMT lockstep. Thread 0 reads x[0] then x[1024]; thread 1 reads x[1] then x[1025], and so on
— 1024 independent running sums.
reads VRAM writes registers
1024 partial sums must become one. Registers are private, so we reduce within each warp by shuffle, hand off through shared memory, then reduce once more.
// 1 · collapse 32 lanes → lane 0 (5 steps) for (int off = 16; off > 0; off >>= 1) local += __shfl_down_sync(FULL, local, off); // 2 · each warp's lane 0 → shared memory if (lane == 0) smem[warpId] = local; __syncthreads(); // 3 · warp 0 reduces the 32 partial sums float total = (tid < 32) ? smem[tid] : 0; if (warpId == 0) for (int off = 16; off > 0; off >>= 1) total += __shfl_down_sync(FULL, total, off);
registers → shared → registers shuffle is ~5 cyc/step and never touches shared memory; shared memory is only the cross-warp handoff.
Thread 0 now holds the grand sum of squares. It computes the single RMS scalar and publishes it so all 1024 threads can use it.
if (tid == 0) { float mean = total / d; // d ← constant mem smem_rms = rsqrtf(mean + eps); // eps ← constant mem } __syncthreads(); // RMS now visible to all
d and eps arrived as kernel arguments and live in
Constant Memory — wired to broadcast one value to every thread's
register at once. Thread 0 reads them with zero latency; no VRAM trip.
__syncthreads() guarantees no thread races ahead
to Phase 4 before RMS is written.
reads constant mem writes shared mem
All 1024 threads wake up, read the shared RMS value, and write the normalized, weighted output back to VRAM — again two elements each.
float rms = smem_rms; // from shared mem for (int i = tid; i < d; i += blockDim.x) { y[row*d + i] = x[row*d + i] * rms * w[i]; // STG to VRAM } // w[i]: left in VRAM, hot in L1 cache
Each thread reads x[i] (VRAM), rms (shared), and w[i]
(VRAM → L1), does a multiply-multiply, and stores y[i]. The weights stay in
VRAM on purpose — L1 keeps them hot across every block.
reads VRAM + shared + L1 writes VRAM
Two choices in the trace look lazy but are the real performance wins.
Staging w into shared memory would cost a whole extra loop. Instead, fetch
w[i] when needed. The invisible L1 cache keeps it: once
block 0 pulls w[0], it's on-chip — and w is identical for every row,
so every other block reads it instantly.
Kernel arguments like ndim and epsilon land in
Constant Memory — silicon wired to broadcast one value to every
thread's register at once. Read-only, zero-cycle, no VRAM trip.
Blocks pin to SMs; threads execute as warps of 32.
Flood the SM with threads so cores are never idle.
Registers > shared > L1 / constant > VRAM.
Every kernel is a negotiation between these three forces. That's CUDA on the metal.
Same math, same GPU. A single fused CUDA kernel replaces PyTorch's chain of eager ops — measured across the three RMSNorm calls per transformer step.
pow, mean, rsqrt, mul…), each writing a temp
tensor back to VRAM — launch overhead plus the memory wall. The fused kernel reads VRAM
once into registers & shared memory, computes the whole RMSNorm on-chip, and writes
once. Exactly the four phases we just traced.