The Machine: threads, warps, and SMs
Before you can reason about a matmul or an attention kernel, you need an accurate mental model of the machine underneath. Not a metaphor — the actual scheduling unit, the actual register file, the actual reason latency hiding works.
Almost every performance mistake in a GPU kernel comes from one of two wrong beliefs: that a thread is like a CPU thread, or that "more parallelism" is automatically better. Both are wrong in specific, fixable ways. This part replaces them.
We are going to build the model bottom-up, and every claim will be tied to a number you can look up. By the end you should be able to answer, for any kernel you write: how many warps are resident, what are they waiting on, and what is the hardware doing while they wait?
1.1 A GPU is a latency-hiding machine, not a fast machine
A single CUDA thread is slow. On an H100 the clock is around 1.98 GHz — slower than a laptop CPU — and there is no deep out-of-order machinery, no aggressive branch prediction, no big private cache to hide behind. A dependent chain of fused multiply-adds retires at roughly one every 4 cycles, same as it did a decade ago.
What the GPU has instead is thousands of independent instruction streams that cost nothing to switch between. When one stalls on memory, the hardware issues from another in the very next cycle. The CUDA Programming Guide is blunt about why this is cheap:
“The execution context (program counters, registers, and so on) for each warp processed by a multiprocessor is maintained on-chip during the entire lifetime of the warp. Therefore, switching from one execution context to another has no cost.”
A CPU hides latency with caches and speculation, and it does so per instruction stream. A GPU hides latency with other instruction streams. That single sentence explains most of the design: the enormous register file, the small caches, the wide memory bus, and the fact that occupancy is a thing you have to think about at all.
The consequence for you: your job as a kernel author is almost never to make one thread faster. It is to make sure that at every cycle, some warp on every SM has work it can issue.
Keep the actual latencies in view, because every design decision later in the series is a negotiation with this table. These are measured cycle counts from published microbenchmarks, not marketing numbers:
| Where the data is | A100 | H100-class | What that means |
|---|---|---|---|
| Register | ~4 cyc | ~4 cyc | Dependent-FMA latency. Free if you have ILP. |
| Shared memory (SMEM) | 29 | 29 | Hideable by ~8 warps of independent work. |
| L1 hit | 38 | 41 | Same physical array as SMEM since Volta. |
| L2 hit | 262 | 263 | L2 is split in two partitions; crossing costs more. |
| HBM (global) | 466 | 479 | ~240–330 ns. This is the number that shapes kernels. |
Source: Luo et al., Benchmarking and Dissecting the NVIDIA Hopper GPU Architecture, Table IV. The Hopper column is an H800 PCIe measurement; the H100/H200 time-domain extrapolation used below is labelled separately rather than presented as a measurement of those cards.
An HBM access costs roughly 120× a register access. If a warp issues a load and then immediately needs the result, that warp is dead for ~479 cycles. Whether the SM is dead too depends entirely on how many other warps are resident — which is what the rest of this part is about.
1.2 The thread hierarchy, and which parts of it are real
CUDA gives you five levels of grouping. They are not equally real: some are hardware, some are pure software convention, and confusing the two is a classic source of wrong intuitions.
| Level | Size | Real? | What it actually buys you |
|---|---|---|---|
| Thread | 1 | Software | A lane index and a private slice of the register file. Since Volta it also has its own program counter. |
| Warp | 32 threads | Hardware | The scheduling unit. One instruction is issued for the whole warp. Everything about performance happens here. |
| Warpgroup | 4 warps = 128 threads | Hardware (Hopper+) | The unit that issues wgmma tensor-core instructions. First warp's rank must be a multiple of 4. |
| Block (CTA) | ≤ 1024 threads | Hardware | The allocation unit. Guaranteed co-resident on one SM, so it can share SMEM and use __syncthreads(). |
| Cluster (CGA) | ≤ 8 blocks (16 non-portable) | Hardware (Hopper+) | Blocks co-scheduled on one GPC that can read each other's shared memory directly. See Part 2. |
| Grid | x ≤ 231−1; y,z ≤ 65,535 | Software | A work list. Blocks run in no defined order, with no forward-progress guarantee between them. |
1.2.1 The warp is the only unit that matters for correctness intuition
Threads do not execute independently. A warp fetches one instruction and applies it across 32 lanes. That is why the warp partitioning rule is worth memorising exactly:
warp_id = linear_tid / 32 lane_id = linear_tid % 32
Warp 0 always contains threads 0–31 of the block, in order. This means a 2D block of (32, 8) gives you warps that are rows — consecutive x, fixed y. A block of (8, 32) gives you warps that straddle 4 rows of y each. That choice silently determines whether your global loads coalesce, which is the single biggest factor in Part 2.
1.2.2 Block size, and the warps you waste
Because warps are cut at 32, a block size that is not a multiple of 32 permanently wastes lanes. A block of 100 threads occupies 4 warps — 128 lanes — and 28 of them are inactive for the entire lifetime of the block. The hardware will not reclaim them.
1.3 Inside one SM
An H100 has 132 Streaming Multiprocessors. Every one of them, on every architecture from Maxwell to Blackwell, is built the same way at the top level: four sub-partitions, each with its own warp scheduler, its own slice of the register file, and its own execution units — including memory load/store units. The four sub-partitions converge on shared SM-wide resources such as the instruction cache and the L1/shared-memory array.
“Each SM is partitioned into four processing blocks, called SM sub partitions… A warp is allocated to a sub partition and resides on the sub partition from launch to completion.”
That last clause matters: a warp is pinned to one sub-partition. Its registers come from that sub-partition's 64 KB slice, and its instructions issue from that sub-partition's scheduler at a maximum of one per cycle. On Hopper, four schedulers × 1 instruction gives a peak of 4 warp instructions per clock per SM.
1.3.1 Why the FP32 lane count is a 2× story
An A100 sub-partition has 16 FP32 lanes; a warp is 32 threads. So a single FP32 multiply occupies the datapath for two cycles. Hopper doubled it to 32 lanes, so the same instruction retires in one. This is directly readable from the Programming Guide's per-clock throughput table:
| Operation (results/clock/SM) | A100 (8.0) | H100 (9.0) | Note |
|---|---|---|---|
| FP32 add / mul / FMA | 64 | 128 | 4 warp-instructions/clk on Hopper, 2 on Ampere |
| FP16 add / mul / FMA | 256 | 256 | Non-tensor-core path |
| FP64 add / mul / FMA | 32 | 64 | Datacenter parts only |
| INT32 add | 64 | 64 | Still 16 lanes — index math is not free |
| Warp shuffle | 32 | 32 | 1 warp/clk. Reductions live here (Part 4) |
exp2, rsqrt, sin (SFU) | 16 | 16 | Half a warp per clock — this is why softmax is expensive |
Two rows deserve a bookmark. INT32 throughput is half of FP32 on Hopper. Address arithmetic, loop counters and predicate math all run on that path, and in a heavily unrolled kernel they can become the bottleneck — which is exactly why Hopper added TMA to move address generation into fixed-function hardware (Part 6).
And the special-function unit does 16 results per clock per SM. On an H100 that works out to about 3.9 Tops/s of exp against 989 TFLOP/s of tensor-core matmul — FlashAttention-3 puts the gap at 256×, computed at a 1.83 GHz sustained clock rather than the 1.98 GHz boost. Hold that number; it is the entire reason FlashAttention-3 needs a ping-pong schedule (Part 7).
1.4 SIMT: one instruction, 32 lanes, and what happens at a branch
When threads in a warp take different paths through an if, the warp does not split into two warps. It executes both sides, one after the other, with the non-participating lanes masked off.
“If threads of a warp diverge via a data-dependent conditional branch, the warp executes each branch path taken, disabling threads that are not on that path… Branch divergence occurs only within a warp; different warps execute independently regardless of whether they are executing common or disjoint code paths.”
The cost model follows mechanically: a two-way branch where both sides are taken costs the sum of both sides, not the max. A 32-way switch where every lane picks a different case costs 32×.
if (lane < 16). Time runs downward. Both arms execute; half the lanes are masked in each. Nothing here is a stall — the SM is issuing at full rate — but half the issued work is thrown away, so effective throughput halves.1.4.1 Divergence that costs nothing
Divergence is only expensive when it happens inside a warp. This is fine:
// Every warp takes exactly one side: warp_id is uniform within a warp.
int warp_id = threadIdx.x / 32;
if (warp_id < 4) { produce(); } // warps 0-3
else { consume(); } // warps 4-7
And this is not:
// Lanes within one warp disagree -> both arms run, masked.
if (x[threadIdx.x] > 0.0f) y[threadIdx.x] = fast_path();
else y[threadIdx.x] = slow_path();
A useful rule: ask whether the predicate is constant across each group of 32 consecutive threads. If yes, the branch is free. If no, budget for both arms.
1.4.2 Independent thread scheduling, and why __syncwarp() exists
Before Volta, all 32 threads shared one program counter and an active mask. Volta gave each thread its own PC and call stack, which fixed a class of deadlocks (a lane holding a lock while its warp-mates spin) but broke a very common optimization: warp-synchronous programming, where people omitted synchronization inside a warp because "warps are lockstep anyway."
They are no longer guaranteed to be. The convergence optimizer may group lanes back together, but you cannot rely on it. Any intra-warp data exchange must name its participants explicitly:
// WRONG on Volta and later: assumes implicit lockstep.
sdata[tid] += sdata[tid + 16];
sdata[tid] += sdata[tid + 8];
// RIGHT: explicit mask, explicit sync.
float v = sdata[tid];
for (int off = 16; off > 0; off >>= 1)
v += __shfl_down_sync(0xffffffff, v, off); // mask names all 32 lanes
_sync suffix is not decoration. It is the contract that makes the exchange defined.Do not build the mask from __activemask(). It returns whatever happens to be converged at that moment, which is a race, not a specification. Write the mask you require — usually 0xffffffff — and make sure those lanes actually reach the instruction.
1.5 Latency hiding, quantitatively
Now we can put the pieces together. Consider the simplest possible memory-bound kernel: load a value, do a little arithmetic, store it. One warp's timeline is mostly dead air. The question is how many warps you need before the dead air is completely covered.
The governing relation is Little's Law, which Vasily Volkov put at the centre of GPU performance analysis in his 2010 GTC talk:
For memory: to saturate HBM you must keep bandwidth × latency bytes in flight at all times. Plug in real numbers:
| GPU | HBM BW | Latency | Bytes in flight | Per SM | Per thread @ 2048/SM |
|---|---|---|---|---|---|
| A100 80GB SXM | 2.04 TB/s | 331 ns | 675 kB | 6.25 kB | 3.1 B |
| H100 SXM5 | 3.35 TB/s | 273 ns* | 915 kB | 6.93 kB | 3.4 B |
| H200 SXM | 4.80 TB/s | 273 ns* | 1.31 MB | 9.93 kB | 4.8 B |
| B200 (HGX) | 7.70 TB/s | higher still | > 4 MB | > 30 kB | > 15 B |
Luo et al. measured 466 cycles on A100 (331 ns at 1.41 GHz) and 479 cycles on H800 PCIe (273 ns at 1.755 GHz). The starred H100/H200 entries reuse that H800 time as an explicit same-GH100-family estimate; they are neither direct measurements nor conversions at those cards' own clocks. Bytes in flight = bandwidth × latency, with decimal SI units: 914,550 B = 915 kB = 893 KiB. B200 latency is unpublished; the original measurement behind the directional row finds it higher than H100's.
Read the last column carefully. On an H100 at full occupancy, one 4-byte load per thread is roughly enough to saturate HBM. On a B200 it is not — you need something closer to a 16-byte vector load per thread in flight. Each generation raises the amount of memory-level parallelism a kernel must expose. That is the structural reason modern kernels use float4 loads, deep software pipelines, and TMA.
1.6 Occupancy: what it is, what limits it
Occupancy is resident warps per SM divided by the architectural maximum (64 on Hopper and datacenter Blackwell; 48 on consumer Blackwell and Ampere GA10x). It is an upper bound on how much latency you can hide, and it is decided at launch by three independent constraints:
- Registers. Each SM has 65,536 32-bit registers. A thread using R registers means a warp uses 32R, so the SM fits ⌊65536 / (32R)⌋ warps. At 32 registers/thread you get all 64 warps; at 64 you get 32; at the 255 maximum you get 8.
- Shared memory. An SM has up to 228 KB on Hopper, but CUDA reserves 1 KB per resident block. Two blocks asking for 113 KB each consume 228 KB including those reservations; two asking for 114 KB would need 230 KB and do not fit. A block asking for the 227 KB user maximum fits once. A high-performance GEMM or attention kernel often chooses to sit at 1–2 blocks per SM.
- Block granularity. At most 32 blocks per SM — the same ceiling on datacenter and consumer Blackwell, per NVIDIA's Blackwell Tuning Guide — and at most 2048 threads (1536 on consumer parts). So 64-thread blocks cap you at 32 × 2 = 64 warps only in the best case, and any register or SMEM pressure drops you below that immediately.
The binding constraint is whichever of the three is smallest. Try it:
Two practical notes. First, registers are allocated in granules, so the real allocation rounds up; use cudaOccupancyMaxActiveBlocksPerMultiprocessor rather than doing it by hand when it matters. Second, CUDA reserves 1 KB of shared memory per block, which is why the per-block maximum is 227 KB while the per-SM maximum is 228 KB. A block that needs more than 48 KB must put the excess in dynamic shared memory and explicitly opt in:
// Anything above 48 KB of shared memory must be dynamic AND opted into.
cudaFuncSetAttribute(my_kernel,
cudaFuncAttributeMaxDynamicSharedMemorySize, 200 * 1024);
my_kernel<<<grid, block, 200 * 1024>>>(...);
// Ask the runtime what occupancy you actually got.
int blocks;
cudaOccupancyMaxActiveBlocksPerMultiprocessor(&blocks, my_kernel, 256, 200*1024);
1.6.1 Why the calculator disagrees with your profiler
The figure above computes theoretical occupancy: the share of warp slots that could be filled if every selected block stayed resident for the whole kernel. Nsight Compute reports a second number next to it — achieved occupancy, actual resident warps averaged over time — and on perfectly healthy kernels the two differ by 20–30%. That gap has three ordinary causes:
- The compiler knows things the calculator does not. Registers are allocated per warp in 256-register granules and rounded up, and
nvccroutinely reserves more than the source implies, to keep spills off the critical path.__launch_bounds__trades that headroom back for residency, andcudaOccupancyMaxActiveBlocksPerMultiprocessorreads the post-compilation truth rather than your guess. - Blocks do not retire in lockstep. Occupancy touches its ceiling only while every scheduled block is alive. Blocks finish at different times and replacements trickle in, so averaged over the whole kernel the resident count sits below the peak — worst near the tail of the grid.
- Resident does not mean issuing. A block parked at
__syncthreads()keeps all of its warps resident and none of them useful. Barrier-heavy kernels show high theoretical occupancy and low issue rates, and no occupancy number will tell you which is happening.
So treat the calculator as a bound on the possible, not a prediction. Whether those resident warps have anything useful to issue is a different question — the subject of the rest of this part.
1.7 Occupancy is a means, not the goal
Here is where a lot of tuning advice goes wrong. Little's Law says you need parallelism, and warps are only one source of it. The other is instruction-level parallelism: independent work within a single thread. A thread that has eight independent loads in flight contributes as much memory-level parallelism as eight threads with one each — while using a fraction of the warp slots.
Volkov's measurements are still the cleanest demonstration:
| Kernel | Threads/SM | Occupancy | Result |
|---|---|---|---|
| memcpy, 1 float per thread | max | 100% | baseline |
memcpy, 14 float4 per thread | 64 | 4% | 84% of peak bandwidth |
| SGEMM, 1 output per thread | 1024 | 67% | 242 GFLOP/s |
| SGEMM, 36 outputs per thread | 512 | 33% | 838 GFLOP/s |
Vasily Volkov, Better Performance at Lower Occupancy, GTC 2010. Measured on a GTX 480; the principle has aged better than the hardware.
Fewer threads, more work per thread — up to the point where registers spill. Many register-heavy GEMM and attention kernels you will read in Parts 5–7 deliberately run at low occupancy, sometimes 12–25%, because the accumulator tile has to live in registers, and registers are the only storage tier fast enough to feed a tensor core.
The honest framing: occupancy is a budget for hiding latency you cannot otherwise avoid. If your kernel has enough independent work per thread, you need less of it. If it is a pointer-chasing, low-ILP kernel, you need a lot. Measure the stall reasons, not the occupancy number.
1.8 Launching work: grid-stride loops and wave quantization
The naive launch shape — one thread per element — is fine for teaching and rarely optimal in practice. The idiom you will see in real code is the grid-stride loop:
__global__ void saxpy(int n, float a, const float* x, float* y) {
int stride = blockDim.x * gridDim.x;
for (int i = blockIdx.x * blockDim.x + threadIdx.x; i < n; i += stride)
y[i] = a * x[i] + y[i];
}
// Launch with a grid sized to the DEVICE, not to the data:
int blocks; cudaOccupancyMaxActiveBlocksPerMultiprocessor(&blocks, saxpy, 256, 0);
saxpy<<<blocks * numSMs, 256>>>(n, a, x, y);
import triton, triton.language as tl
@triton.jit
def saxpy(x_ptr, y_ptr, a, n, BLOCK: tl.constexpr):
pid = tl.program_id(0)
offs = pid * BLOCK + tl.arange(0, BLOCK)
mask = offs < n
x = tl.load(x_ptr + offs, mask=mask)
y = tl.load(y_ptr + offs, mask=mask)
tl.store(y_ptr + offs, a * x + y, mask=mask)
grid = lambda m: (triton.cdiv(n, m['BLOCK']),)
saxpy[grid](x, y, a, n, BLOCK=1024)
# Ground truth to check against.
y = a * x + y
tl.arange expresses its contiguous offsets, and the compiler distributes that blocked program across hardware threads.Grid-stride loops decouple the grid size from the problem size, which lets the launch be sized to the machine. That matters because of wave quantization: blocks execute in waves of roughly SMs × blocks_per_SM, and a grid of 133 blocks on 132 SMs takes two waves — the second one 99% empty.
1.9 What this buys you for ML kernels
Everything above turns into three questions you will ask of every kernel in this series:
- How many warps are resident, and what are they waiting for? Waiting on HBM is a bandwidth problem (Parts 2–3). Waiting on SMEM is a bank-conflict or layout problem (Parts 2, 6). Waiting on the SFU is a softmax problem (Parts 4, 7). Waiting on nothing is a launch problem.
- Is the per-thread work independent? If each thread does one dependent chain, you need occupancy. If each thread owns an 8×8 accumulator tile, you do not — and you should spend the registers instead.
- Does the grid fill the machine, in whole waves? This is the difference between 76% and 99% utilization on a decode-shaped matmul, and no amount of inner-loop tuning fixes it.
H100: 132 SMs, 64 warps and 65,536 registers and 228 KB SMEM per SM, 4 instructions/clock per SM, ~479 cycles to HBM, 16 SFU results/clock, 32 shared-memory banks. Almost every design decision in Parts 2–9 is a consequence of one of these.
Next: where the data actually lives, why a 4-byte load can cost you 8× the bandwidth you meant to use, and the bank-conflict pattern that every tiled kernel has to solve.