CUDA 101 · from silicon up

CUDA
on the Metal

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.

Reference GPU · NVIDIA L4 (Ada Lovelace) 58 SMs · 7,424 CUDA cores · 24 GB

Navigate with or Space · F for fullscreen

Part 1 · Physical layout

Inside the L4: The Whole Chip

The host talks to the GPU over PCIe. On-chip, data climbs a hierarchy: the further from the cores, the larger and slower.

CPU (Host) System RAM PCIe Gen4 NVIDIA L4 GPU · Ada Lovelace Global Memory / VRAM — 24 GB GDDR6 bandwidth ~300 GB/s L2 Cache — shared across all 58 SMs SM 1 SM 2 SM 3 SM 4 ··· SM 58 128 cores L1 + regs 128 cores L1 + regs 128 cores L1 + regs 128 cores L1 + regs GigaThread Engine hardware scheduler — distributes blocks across the 58 SMs memory cache compute (SM)
Schematic of the NVIDIA L4 (Ada Lovelace). Component counts per NVIDIA's L4 spec.
Part 1 · The core engine

Inside a Streaming Multiprocessor

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.

Streaming Multiprocessor (SM) 4 × Warp Scheduler — issues 1 instruction to 32 cores Register File 65,536 regs · 256 KB · ~1 cyc Shared Memory / L1 on-chip · per-block · ~20 cyc 128 CUDA Cores 4 Tensor Cores matrix MAC Constant Cache — broadcasts kernel args to every thread
One of 58 identical SMs on the L4.

Warp scheduler

Issues one instruction to a warp of 32 cores per cycle. Four per SM.

Registers vs cores

The core is a bare calculator with no memory; the thread owns its private registers. That split is what makes context switches free.

Shared memory

Fast on-chip SRAM the whole block cooperates through — the reason a block can't be split across two SMs.

Part 1 · The programming model

What You Write vs What Runs It

You program in logical terms. The hardware maps each one onto physical silicon. Understanding that mapping is the whole game.

Software · logical
  • Kernel — one C++ function, run thousands of times, each with a different ID
  • Grid — all the blocks in one kernel launch
  • Block — a cooperating team of up to 1024 threads
  • Thread — the smallest unit; you write from its point of view
Hardware · physical
  • GigaThread Engine — hands blocks out to SMs
  • SM — a physical processor; the L4 has 58
  • Warp32 threads run in lockstep
  • CUDA Core — one ALU, one thread's instruction
The mapping: a Block is pinned to exactly one SM; inside it, threads are executed as Warps of 32.
Part 2 · The execution unit

Warps & SIMT

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.

How 32 cores read 32 different values

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.

common instructions
  • LDG / STG — load / store global memory
  • FADD, FMUL — float add, multiply
  • FFMA — fused multiply-add (one rounded op)
Warp shuffle: because a warp runs in lockstep, __shfl_down_sync passes values directly between the 32 registers in ~5 cycles — no trip through shared memory. We'll use this in RMSNorm.
Part 2 · The execution unit

A Warp at Work: Adding a Vector of 64

64 elements need 64 threads — exactly 2 warps. Step through the six instructions the SM issues to compute C = A + B.

Step 0 / 6 · ready · 64 elements = 2 warps of 32

Each warp adds its 32 pairs in a single FADD — the whole vector is done in six warp-instructions.

Part 2 · Why so many threads

128 Cores, ~1,536 Threads: Latency Hiding

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.

Why the switch is free

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%.

the trade
  • Registers are cheap to switch but limited — 65,536 per SM
  • Use fewer registers per thread → more threads fit → more latency hidden
  • This is occupancy: the lever every CUDA optimizer tunes

Core = calculator (no memory). Thread = owns the registers.

Takeaway: you want to flood the SM with threads. Next: how CUDA lets you launch them — the block.
Part 2 · The programming model

The Block, and Why It Caps at 1024

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.

The deadlock the cap prevents

Suppose you could launch 4,096 threads on an SM that physically holds ~1,536:

  • The SM loads the first 1,536 threads; they run and hit __syncthreads()
  • All 1,536 now pause, waiting for the other 2,560
  • But those can't start — the SM is full of paused threads
  • Nobody can proceed. Permanent deadlock.
The fix: cap a block at 1024 threads — safely below any SM's physical limit. An entire block is then guaranteed to fit on the silicon at once, so the barrier can always be satisfied.

1024 threads/block · ~1,536 active threads/SM · one block always fits with room to spare.

Part 2 · The programming model

More Data Than Threads? Loop.

2048 elements but only 1024 threads. You don't ask for more threads — each thread processes several elements in sequence: a grid-stride loop.

index = threadIdx.x + n × blockDim.x  (blockDim = 1024) elements 0 – 1023 n = 0 elements 1024 – 2047 n = 1 2048 – 3071 n = 2 … 3072 – 4095 n = 3 … Thread 0 handles: element 0 → 1024 → 2048 → 3072 The other 1023 threads do the same for their offsets — a fixed thread count sweeps any size.
One block of 1024 threads covering 2048+ elements by striding.
Part 2 · The programming model

Kernel Scheduling: The Hardware Queue

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.

Grid: 2048 blocks Hardware queue 1990 blocks waiting GigaThread Engine 58 SMs — all busy
The first 58 blocks run; the instant an SM finishes, it pops the next block — no CPU involved.
A massive grid is the goal, not a problem: it keeps all 58 SMs saturated with zero scheduling overhead until the job is done.
Part 3 · Putting it together

Walkthrough: an RMSNorm Kernel

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.

yi  =  xi √( 1/d Σ xj² + ε )  ×  wi
32

rows (S) → launch 32 blocks, one per row

2048

embedding dim (d) → 1024 threads loop ×2

1

block per row — rows are independent

Each row's math is independent, so each row is its own block on its own SM — perfect parallelism, no cross-block communication.
Part 3 · The baseline

The Naive Version: Eager PyTorch

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

One line ≠ one op

Each of pow, mean, add, rsqrt, and the two muls launches its own kernel — ~6 launches for a single normalization.

Two taxes: launch overhead (the CPU commands the GPU once per op) and the memory wall (every intermediate is written to VRAM, then immediately read back). The math is trivial; the data movement is the cost.

Source: chapter_1/llama_inference.py · next, we fuse all six ops into one kernel — the four phases on the silicon.

Part 3 · Inside one block · one row of 2048

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.

1 · Local Sums square + accumulate → registers 2 · Reduction shuffle + shared mem → grand sum 3 · Final Math rsqrt, constants → RMS value 4 · Apply Weights x · RMS · w → VRAM Data climbs down the hierarchy and back up: VRAM → registers → shared → registers → VRAM.
Part 3 · Phase 1 of 4

Phase 1 — Local Sums

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

What actually runs

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.

End state: 1024 partial sums, one per thread, each in a private register. Nothing has been shared yet.

reads VRAM writes registers

Part 3 · Phase 2 of 4

Phase 2 — The Reduction

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);
012 345 67 0123 01 Σ off = 4off = 2off = 1
Shuffle-down reduction — shown as 8 lanes / 3 steps; a real warp is 32 lanes / 5 steps.

registers → shared → registers   shuffle is ~5 cyc/step and never touches shared memory; shared memory is only the cross-warp handoff.

Part 3 · Phase 3 of 4

Phase 3 — Final Math & Constant Memory

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

Why constants, not VRAM

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.

rsqrt is a single hardware instruction — one reciprocal-square-root, not a divide-then-sqrt. The __syncthreads() guarantees no thread races ahead to Phase 4 before RMS is written.

reads constant mem writes shared mem

Part 3 · Phase 4 of 4

Phase 4 — Applying the Weights

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

Three reads, one write

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.

Done. One row of 2048 values is normalized. Multiply by 32 independent blocks and the whole [32 × 2048] tensor is finished in parallel.

reads VRAM + shared + L1 writes VRAM

Part 3 · Systems nuances

Where Data Lives Is the Optimization

Two choices in the trace look lazy but are the real performance wins.

The weights w

Leave them in VRAM

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.

The constants d, ε

Broadcast from Constant Memory

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.

The pattern: read-only data shared across threads wants a cache or a broadcast, not a copy. The fastest memory access is the one you never make.
The mental model

You write threads.
The hardware runs warps.

01

Map logical → physical

Blocks pin to SMs; threads execute as warps of 32.

02

Hide latency

Flood the SM with threads so cores are never idle.

03

Keep data close

Registers > shared > L1 / constant > VRAM.

Every kernel is a negotiation between these three forces. That's CUDA on the metal.

The payoff · RMSNorm on the L4

From Theory to 7.2× Faster

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.

🐢 Native PyTorch (eager)
RMSNorm · Attn0.2794 ms
RMSNorm · FFN0.2754 ms
RMSNorm · Final0.0174 ms
Total / step~0.5722 ms
🚀 CUDA fused kernel
RMSNorm · Attn0.0386 ms
RMSNorm · FFN0.0389 ms
RMSNorm · Final0.0024 ms
Total / step~0.0799 ms
PyTorch
0.5722 ms
CUDA
0.0799 ms
Why kernel fusion wins: eager PyTorch launches a separate kernel per op (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.