Skip to content

GPU performance chains: bytes in flight, occupancy, tiles, waves and gaps

8 chains

These chains use the H100 SXM throughout: 132 SMs, 3.35 TB/s of HBM, 989 TFLOP/s dense BF16, 65,536 registers and 228 KB of shared memory per SM. A kernel question usually turns on one of them.

Bytes in flight to reach bandwidth

inputs   3.35 TB/s of HBM bandwidth, an assumed 600 ns of memory latency
formula  bytes in flight = bandwidth x latency (Little's law for memory)
GPU      3.35e12 B/s x 600e-9 s = 2.0 MB outstanding at all times
per SM   2.0 MB / 132 = 15 KB per SM
sanity   a kernel with fewer loads in flight than that cannot reach peak,
         however simple it is

Decode GEMVs fall short in three ways: too few thread blocks for 132 SMs, loads too narrow per thread, and matrices so small that launch gaps rival the kernel.

Drilled in: Decode GEMV at half of HBM bandwidth · Bytes in flight and Little's law

A coalesced warp load

inputs   row-major [N, K] BF16 weight, K = 4,096, so a row is 8,192 B
coalesced  32 lanes walk along one row, 16 B each: one instruction reads
         512 contiguous bytes, 16 full 32-byte sectors
row per lane  lanes sit 8,192 B apart: 32 separate sectors per instruction,
         each only half used
sanity   same bytes requested, twice the sectors fetched, and far fewer
         bytes per request in flight

Consecutive lanes must read consecutive addresses. For nn.Linear's layout that means lanes along K and a warp reduction at the end.

Drilled in: GEMV coalescing, row-major against transposed

Occupancy from three limits

inputs   256-thread blocks, 128 registers per thread, 48 KB of shared memory
         per block; H100 (compute capability 9.0)
registers  65,536 / (256 x 128) = 2 blocks per SM
shared   228 KB / (48 + 1) KB = 4 blocks (CUDA reserves 1 KB per block)
warps    64 warps per SM / 8 per block = 8 blocks
result   registers bind: 2 blocks, 16 of 64 warps, 25% occupancy
sanity   64 registers per thread doubles it to 50%, where shared memory binds

Higher occupancy helps a latency-bound kernel hide memory latency. A kernel that already keeps enough bytes in flight gains nothing from it.

Drilled in: Occupancy at 128 registers and 48 KB

Shared-memory bank conflicts

inputs   32 banks, 4 bytes wide; float tile[32][32]
rule     word w lives in bank w mod 32
column   lane r reads tile[r][c], word 32r + c: every lane hits bank c,
         a 32-way conflict, 32 wavefronts
padded   rows of 33 words: word 33r + c sits in bank (r + c) mod 32,
         distinct for every lane, one wavefront
sanity   one extra word per row removes a 32x slowdown on column reads

Padding stops working when rows must stay 16-byte aligned for vector or TMA stores. A swizzled layout is the fix then.

Drilled in: Bank conflicts and transpose padding

GEMM tile intensity

inputs   output tile BM x BN, K-step BK, BF16 operands
per step loads (BM + BN) x BK x 2 B, computes 2 x BM x BN x BK FLOPs
intensity  BM x BN / (BM + BN) FLOP per byte
128 x 128  64;  128 x 256  85
limits   a 128 x 256 FP32 accumulator is 32,768 registers, half the SM's
sanity   bigger tiles cut operand traffic until registers or the 227 KB
         shared-memory limit per block stops them

Tile shape sets how often operands are re-read from L2 and HBM. It is the first knob in a GEMM and the one the hardware bounds hardest.

Drilled in: Tensor-core GEMM tile sizes on Hopper

Wave quantization

inputs   140 output tiles, one resident per SM, 132 SMs
waves    ceil(140 / 132) = 2; the second runs 8 tiles while 124 SMs idle
efficiency  140 / 264 = 53%
time     about 143 us against the 76 us its FLOPs need at 989 TFLOP/s
sanity   a 512-token prefill chunk makes 112 tiles and fits in one wave

Stream-K splits the K loop across SMs to fill the second wave. The scheduler can often avoid it more cheaply by choosing chunk sizes that land on whole waves.

Drilled in: Wave quantization, 140 tiles on 132 SMs

Fused RMSNorm traffic

inputs   an 8,192-token prefill chunk, hidden 8,192, BF16
fused    one read and one write per element, 4 B: 268 MB, 80 us at 3.35 TB/s
eager    eight kernels with FP32 intermediates, about 36 B per element:
         2.42 GB, 0.72 ms
sanity   9x the bytes for the same arithmetic; at decode batch 1 the bytes
         are trivial and the eight launches are the cost

Fusion matters for memory-bound elementwise work in prefill and for launch count in decode, for different reasons.

Drilled in: Fused RMSNorm in Triton

Launch gaps in a decode step

inputs   an illustrative Llama 3.1 8B decode step on H100 SXM:
         325 kernels running for 5.2 ms in total, step time 7.8 ms
gaps     1.8 ms of small uniform gaps between kernels (launch-bound)
stall    0.8 ms in one gap at the step boundary (a host synchronization)
MBU      16.06 GB / 7.8 ms / 3.35 TB/s = 62%
sanity   the kernels ran at 92% of bandwidth; the step lost a third of its
         time between them

Many small gaps call for CUDA graphs. One large gap calls for finding the synchronization and the CPU work behind it.

Drilled in: Nsight Systems: launch gaps in a decode step · CUDA graphs for decode

Next card: Quantization Interview Chains