2 · Just enough GPU to read this code
This chapter explains the handful of hardware facts the kernels are built around. You don't need the whole CUDA programming guide: just how work is split into threads, how bytes reach those threads, and how a tensor core multiplies. Each idea is tied to the line of submission.py that depends on it.
- 2.1 The machine
- 2.2 Grid, CTA, warp, lane
- 2.3 The memory hierarchy
- 2.4 Feeding a memory-bound kernel
- 2.5 Tensor cores and mma.sync.m16n8k16
- 2.6 Reductions inside a CTA and across CTAs
- 2.7 Templates: one compiled kernel per configuration
- 2.8 PTX, asm volatile and SASS
- 2.9 CUDA graphs and launch cost
- Check yourself
2.1 The machine
| H200 part | number | where it matters here |
|---|---|---|
| Streaming multiprocessors (SMs) | 132 | Each SM runs one or more thread blocks. A grid with fewer than 132 blocks leaves SMs idle. |
| Clock under load | about 1.98 GHz | So 1 µs ≈ 2,000 cycles. |
| Registers per SM | 65,536 × 32 bit (256 KB) | Our GEMVs keep weights, x values and sums in registers. |
| Shared memory per SM | up to 228 KB | Used only for the final reductions in our GEMVs. |
| L2 cache, shared by all SMs | 50 MB | Cold at the start of each timed call. x (at most 0.5 MB) is read from it many times. |
| HBM3e main memory | 141 GB at 4.8 TB/s | Every weight byte comes from here once per call. |
| Tensor-core peak (BF16, dense) | 989 TFLOP/s | Only matters for m = 128/256. |
2.2 Grid, CTA, warp, lane
A kernel launch creates a grid of thread blocks. NVIDIA's documents also call a thread block a CTA (cooperative thread array); this guide uses "CTA" because the code does.
Each CTA is a group of threads that:
- runs on one SM;
- shares that SM's shared memory;
- can synchronize with
__syncthreads().
The threads of a CTA come in warps of 32. A warp executes one instruction at a time for all 32 of its threads ("lanes"). The SM keeps many warps resident and switches between them every cycle. When one warp is waiting for memory, another warp issues instructions. That is how a GPU hides memory latency: lots of independent warps, each with lots of loads in flight.
In our GEMV kernels:
| CUDA concept | in gemv_kernel / gemvz_kernel |
|---|---|
| grid | (N/16, S): one CTA per block of 16 weight rows, times S slices of K |
| CTA | NW warps (NW + 1 in gemvz), so NW × 32 threads |
| warp | streams its own slice of K for the CTA's 16 rows |
| lane | lane = tid & 31, split into g = lane >> 2 (0–7) and t = lane & 3 (0–3); see 2.5 |
For example, qkv_gate at m = 1 launches 2304/16 = 144 CTAs of 17 warps each.
2.3 The memory hierarchy
| level | size | typical latency | who sees it |
|---|---|---|---|
| registers | up to 255 per thread | 0 cycles | one thread |
| shared memory / L1 | up to 228 KB per SM | about 30 cycles | one CTA (shared memory), one SM (L1) |
| L2 | 50 MB | about 200–300 cycles | the whole GPU |
| HBM | 141 GB | about 0.5–1 µs under load | the whole GPU |
Our GEMVs read weights straight from HBM into registers. They never stage weights through shared memory, because every weight byte is used by exactly one warp, exactly once. Shared memory appears only at the end, to add up the warps' partial sums.
2.4 Feeding a memory-bound kernel
To keep 4.8 TB/s flowing, the GPU must have enough bytes "in the air" at every moment. This is Little's law: bytes in flight = bandwidth × latency.
4.8 TB/s × ~0.7 µs ≈ 3.4 MB in flight ≈ 25 KB per SM, all the time
A thread's load takes about a microsecond to come back. So each warp must issue several loads before it needs the first result, and each SM must host enough warps.
Three habits in our code serve this goal:
- Wide loads. Each lane loads 16 bytes at once (
uint4, four 32-bit words). 32 lanes × 16 B = 512 bytes per warp instruction. - Coalescing. When the 32 lanes of a warp read 512 consecutive bytes, the memory system serves them as one big transaction. Scattered addresses would cost many transactions. Chapter 5 shows how we reorder the weights so that every load is perfectly contiguous.
- Loads in flight. The template parameters
U(plain GEMV) andUG(compressed GEMV) say how many K blocks a warp loads before using them.PIPEchooses whether the next batch is loaded while the current one is computed. More warps per CTA (NW) means more loads in flight per SM.
The load instruction itself is special:
ld.global.nc.L1::no_allocate.v4.u32
| part | meaning |
|---|---|
global |
read from global memory (HBM, through L2) |
nc |
non-coherent: the data is read-only during the kernel, so the fast read-only path can be used |
L1::no_allocate |
don't keep it in L1: each weight byte is used once, so caching it would only evict useful data |
v4.u32 |
four 32-bit words (16 bytes) per lane |
2.5 Tensor cores and mma.sync.m16n8k16
A tensor core multiplies small matrix tiles in one instruction. The variant our GEMVs use is:
mma.sync.aligned.m16n8k16.row.col.f32.bf16.bf16.f32 D = A · B + C
| operand | shape | type | registers per lane |
|---|---|---|---|
| A | 16 × 16 (m × k) | BF16 | 4 registers × 2 values = 8 values |
| B | 16 × 8 (k × n) | BF16 | 2 registers × 2 values = 4 values |
| C, D | 16 × 8 (m × n) | FP32 | 4 values |
Together, the 32 lanes of a warp hold the three tiles. Each lane holds a few elements, called its fragment. That one warp instruction performs 16 × 8 × 16 = 2,048 multiply-adds. Which lane holds which element is fixed by the hardware, with lane = 4·g + t:
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 1 | 1 | 2 | 2 | 3 | 3 | 0 | 0 | 1 | 1 | 2 | 2 | 3 | 3 |
| 1 | 4 | 4 | 5 | 5 | 6 | 6 | 7 | 7 | 4 | 4 | 5 | 5 | 6 | 6 | 7 | 7 |
| 2 | 8 | 8 | 9 | 9 | 10 | 10 | 11 | 11 | 8 | 8 | 9 | 9 | 10 | 10 | 11 | 11 |
| 3 | 12 | 12 | 13 | 13 | 14 | 14 | 15 | 15 | 12 | 12 | 13 | 13 | 14 | 14 | 15 | 15 |
| 4 | 16 | 16 | 17 | 17 | 18 | 18 | 19 | 19 | 16 | 16 | 17 | 17 | 18 | 18 | 19 | 19 |
| 5 | 20 | 20 | 21 | 21 | 22 | 22 | 23 | 23 | 20 | 20 | 21 | 21 | 22 | 22 | 23 | 23 |
| 6 | 24 | 24 | 25 | 25 | 26 | 26 | 27 | 27 | 24 | 24 | 25 | 25 | 26 | 26 | 27 | 27 |
| 7 | 28 | 28 | 29 | 29 | 30 | 30 | 31 | 31 | 28 | 28 | 29 | 29 | 30 | 30 | 31 | 31 |
| 8 | 0 | 0 | 1 | 1 | 2 | 2 | 3 | 3 | 0 | 0 | 1 | 1 | 2 | 2 | 3 | 3 |
| 9 | 4 | 4 | 5 | 5 | 6 | 6 | 7 | 7 | 4 | 4 | 5 | 5 | 6 | 6 | 7 | 7 |
| 10 | 8 | 8 | 9 | 9 | 10 | 10 | 11 | 11 | 8 | 8 | 9 | 9 | 10 | 10 | 11 | 11 |
| 11 | 12 | 12 | 13 | 13 | 14 | 14 | 15 | 15 | 12 | 12 | 13 | 13 | 14 | 14 | 15 | 15 |
| 12 | 16 | 16 | 17 | 17 | 18 | 18 | 19 | 19 | 16 | 16 | 17 | 17 | 18 | 18 | 19 | 19 |
| 13 | 20 | 20 | 21 | 21 | 22 | 22 | 23 | 23 | 20 | 20 | 21 | 21 | 22 | 22 | 23 | 23 |
| 14 | 24 | 24 | 25 | 25 | 26 | 26 | 27 | 27 | 24 | 24 | 25 | 25 | 26 | 26 | 27 | 27 |
| 15 | 28 | 28 | 29 | 29 | 30 | 30 | 31 | 31 | 28 | 28 | 29 | 29 | 30 | 30 | 31 | 31 |
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | |
|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 4 | 8 | 12 | 16 | 20 | 24 | 28 |
| 1 | 0 | 4 | 8 | 12 | 16 | 20 | 24 | 28 |
| 2 | 1 | 5 | 9 | 13 | 17 | 21 | 25 | 29 |
| 3 | 1 | 5 | 9 | 13 | 17 | 21 | 25 | 29 |
| 4 | 2 | 6 | 10 | 14 | 18 | 22 | 26 | 30 |
| 5 | 2 | 6 | 10 | 14 | 18 | 22 | 26 | 30 |
| 6 | 3 | 7 | 11 | 15 | 19 | 23 | 27 | 31 |
| 7 | 3 | 7 | 11 | 15 | 19 | 23 | 27 | 31 |
| 8 | 0 | 4 | 8 | 12 | 16 | 20 | 24 | 28 |
| 9 | 0 | 4 | 8 | 12 | 16 | 20 | 24 | 28 |
| 10 | 1 | 5 | 9 | 13 | 17 | 21 | 25 | 29 |
| 11 | 1 | 5 | 9 | 13 | 17 | 21 | 25 | 29 |
| 12 | 2 | 6 | 10 | 14 | 18 | 22 | 26 | 30 |
| 13 | 2 | 6 | 10 | 14 | 18 | 22 | 26 | 30 |
| 14 | 3 | 7 | 11 | 15 | 19 | 23 | 27 | 31 |
| 15 | 3 | 7 | 11 | 15 | 19 | 23 | 27 | 31 |
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | |
|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 1 | 1 | 2 | 2 | 3 | 3 |
| 1 | 4 | 4 | 5 | 5 | 6 | 6 | 7 | 7 |
| 2 | 8 | 8 | 9 | 9 | 10 | 10 | 11 | 11 |
| 3 | 12 | 12 | 13 | 13 | 14 | 14 | 15 | 15 |
| 4 | 16 | 16 | 17 | 17 | 18 | 18 | 19 | 19 |
| 5 | 20 | 20 | 21 | 21 | 22 | 22 | 23 | 23 |
| 6 | 24 | 24 | 25 | 25 | 26 | 26 | 27 | 27 |
| 7 | 28 | 28 | 29 | 29 | 30 | 30 | 31 | 31 |
| 8 | 0 | 0 | 1 | 1 | 2 | 2 | 3 | 3 |
| 9 | 4 | 4 | 5 | 5 | 6 | 6 | 7 | 7 |
| 10 | 8 | 8 | 9 | 9 | 10 | 10 | 11 | 11 |
| 11 | 12 | 12 | 13 | 13 | 14 | 14 | 15 | 15 |
| 12 | 16 | 16 | 17 | 17 | 18 | 18 | 19 | 19 |
| 13 | 20 | 20 | 21 | 21 | 22 | 22 | 23 | 23 |
| 14 | 24 | 24 | 25 | 25 | 26 | 26 | 27 | 27 |
| 15 | 28 | 28 | 29 | 29 | 30 | 30 | 31 | 31 |
How our GEMV uses it ("swap A and B").
- The 16 rows of the A tile are 16 weight rows, and its 16 columns are 16 values of k.
- The 8 columns of the B tile are up to 8 tokens.
- The accumulator therefore holds the outputs of 16 rows × 8 tokens.
- At m = 1, seven of the eight token columns are wasted, but that costs nothing that matters: the kernel is waiting for memory anyway.
- At m = 16 or 32, the kernel runs the mma for 2 or 4 blocks of 8 tokens. That count is
NB, computed bynb_of(m).
Why a tensor core for a matrix-vector product? Instruction count. Without it, every 16 bytes of weights would need a dozen conversion and fused multiply-add instructions per lane. Those instructions would compete with the loads for issue slots. One mma per 16 bytes keeps the SM free to keep memory busy.
Hopper also has wgmma, a "warpgroup" MMA that takes tiles from shared memory and is much faster for large matrices. cuBLAS uses it at m = 128/256. Our shipped GEMVs use the older mma.sync, which is simpler and plenty for memory-bound work. Our own wgmma kernels (rounds 4, 6 and 7 in the report) never beat cuBLAS.
2.6 Reductions inside a CTA and across CTAs
The K dimension is long (up to 8192), so it is split among the warps of a CTA. Each warp ends with partial sums for the same 16 rows × tokens, which must be added up.
- Inside a CTA:
- every warp writes its partial to shared memory;
__syncthreads()waits until all have written;- a few threads add them up.
- Across CTAs ("split-K"):
- When a matrix has few rows, one CTA per 16-row block would leave SMs idle. The router has 256 rows, which is only 16 CTAs for 132 SMs.
- So the K dimension is also split across
SCTAs, which must combine their partials through global memory. - Chapter 6 (
gemv_kernel) and chapter 9 (Triton) show two ways to do that in one launch. Lab 5 simulates both.
2.7 Templates: one compiled kernel per configuration
The kernels are C++ templates, such as gemv_kernel<NW, U, NB, DUAL, PIPE>, because:
- Arrays like
float acc[NB][4]stay in registers only if their size and every index are known at compile time. A runtime index would push them to slow local memory. #pragma unrollloops with compile-time trip counts become straight-line code, with no loop overhead and with loads that can be scheduled early.
The price is that every configuration is a separate compiled kernel. submission.py therefore lists exactly the combinations it uses (the C3(...) and Z3(...) lines, chapter 6). Asking for any other combination at run time raises "unsupported gemv config".
2.8 PTX, asm volatile and SASS
- PTX is NVIDIA's virtual instruction set.
asm volatile("...")lets CUDA C++ emit an exact PTX instruction. We use it for the streaming load,mma.sync, and the byte-shufflingprmtandlop3(chapter 7).- The driver compiles PTX into SASS, the real machine code, which you can inspect with
cuobjdump -sass.
2.9 CUDA graphs and launch cost
Launching a kernel from Python costs several microseconds of CPU time. A CUDA graph records the launches once and replays them, so the judge measures GPU time only. Two consequences for the code:
kernel_fnmust not wait for the GPU or read GPU values on the CPU: no.item(), no printing tensors. The graph cannot contain that.- Every kernel still pays roughly 1–2 µs of fixed cost inside the GPU: start-up, ramp-up, and the tail where the last CTAs finish. This is a big deal for the tiny router (floor 1.8 µs, ours 5.4 µs).
cd ~/projects/okc/muse_bf16_proj/learn/labs
python3 lab2_fragments.pyThe file is already on your Mac at ~/projects/okc/muse_bf16_proj/learn/labs/lab2_fragments.py; you can also view or download it here.Part 1 of the lab prints the three ownership maps above and checks an emulated mma against numpy. Part 2 belongs to chapter 5; you can run it now or later.
Check yourself
qkv_gate at m = 1 launches 144 CTAs on 132 SMs. What problem does that cause, and how does PIPE = 2 deal with it?
If only one CTA fit per SM, 12 CTAs would have to wait for a second "wave", almost doubling the time.
PIPE = 2 sets __launch_bounds__(threads, 2), which tells the compiler to use few enough registers that two CTAs fit on one SM. Then all 144 run at once.
Little's law check: compressed qkv_gate at m = 1 uses 16 streaming warps per CTA, and each lane keeps up to 2 groups × 48 bytes in flight. Is that enough per SM?
16 warps × 32 lanes × 96 bytes ≈ 49 KB per CTA, roughly double the ~25 KB per SM that Little's law asks for. So yes, with margin.
In the A-tile map, which elements does lane 0 hold, and why are they not contiguous?
Lane 0 holds rows 0 and 8, k-slots 0, 1, 8 and 9. The hardware spreads each lane over two rows and two k ranges.
If we loaded W in its natural row-major order, a lane's 8 values would come from 4 different places. Chapter 5 shows how relabelling k and repacking W turns them into one 16-byte load.