a CUDA matmul — where
“utilization” finally means something
The same matrix multiply, written twelve ways. The first is correct and runs the GPU flat-out at 1.3% of its real speed. The last hits 93%. Reading the gap between them IS the Plasmient thesis — so we read it.
- sourcesiboehm/SGEMM_CUDA
- licenseMIT
- GPUA6000 (Ampere)
- range1.3% → 93.7% of peak
00 Start here — why a matrix multiply?
In Walk 01 each node of the graph was a scalar +=. In Walk 02 the heavy nodes — attention, the MLP — were matrix multiplies. That's not a coincidence: on a real model, the overwhelming majority of the GPU's time is spent multiplying matrices. Get matmul right and you've got the machine right.
So here we drop one level below Python and PyTorch, to the actual code the GPU runs: akernel. The task is SGEMM — Single-precision GEneral Matrix Multiply:C = α·(A·B) + β·C. It's the "hello world" of GPU programming and also the single most important operation in machine learning.
01 The mental model: threads, blocks, and the memory cliff
A GPU runs one small function — the kernel — across thousands of threads at once. You don't loop over the output; you launch a thread per output element and they all run in parallel. Threads are grouped into blocks; blocks tile the whole output.
The one fact that decides everything below is the memory hierarchy — and how steep the cliff is between its levels:
| where the number lives | who can see it | speed |
|---|---|---|
| registers | one thread | instant |
| shared memory (SMEM) | one block, on-chip | very fast |
| global memory (HBM) | everyone, off-chip | ~100× slower |
Every kernel below computes the exact same answer. The only thing that changes is how many times each number is dragged across that slow global-memory cliff. That is the whole game.
02 Kernel 1 — the naive version. Correct, and honest.
Here it is, verbatim. One thread computes one output element by walking a full row of A against a full column of B:
__global__ void sgemm_naive(int M, int N, int K, float alpha,
const float *A, const float *B,
float beta, float *C) {
// this thread owns exactly one output element C[x][y]
const uint x = blockIdx.x * blockDim.x + threadIdx.x;
const uint y = blockIdx.y * blockDim.y + threadIdx.y;
if (x < M && y < N) {
float tmp = 0.0;
for (int i = 0; i < K; ++i) {
tmp += A[x * K + i] * B[i * N + y]; // one row of A · one col of B
}
// C = α*(A@B) + β*C
C[x * N + y] = alpha * tmp + beta * C[x * N + y];
}
}And this is how you launch it — the line that fires one thread per output cell:
dim3 gridDim(CEIL_DIV(M, 32), CEIL_DIV(N, 32)); // enough blocks to cover C
dim3 blockDim(32, 32); // 1024 threads per block
sgemm_naive<<<gridDim, blockDim>>>(M, N, K, alpha, A, B, beta, C);
// ^^^^^^^^^^^^^^^^^^^^^^ launch: fire one thread per output elementIn plain words:
x, y— this thread figures out which output element it owns from its block and thread index.- the
forloop — the dot product: march across rowxof A and columnyof B, accumulating intotmp. - the last line — write the result. Nothing wrong here. It compiles, it's correct, it'll pass any test.
Launch it on a 4096×4096 matmul on an A6000 and it delivers 309 GFLOP/s. The GPU's real capacity is ~23,000. So this correct kernel runs at 1.3% of what the chip can do — whilenvidia-smi shows it pinned at 100%.
Before we ask why, feel the operation in your hands. Every cell of C is one independent dot product — hover a cell to see its row × column, click to accumulate, and drag the dimensions to watch the FLOP count explode. The kernel above is exactly this: one thread per cell of C.
The same matmul this kernel computes — reused live from Study Module 1. Each C cell is independent, so a GPU assigns one thread per cell and runs them all at once.
Want the full linear-algebra build-up behind this widget? It lives inStudy · Module 1 — Linear algebra & matmul.
03 Why so slow? Follow one number.
The GPU here isn't idle — it's starving. Every iteration of that inner loop reaches all the way out to global memory for a fresh A and B value. The arithmetic units can multiply far faster than memory can feed them, so they spend almost all their time waiting.
Worse: adjacent threads read A and B in a scattered pattern, so the hardware can't bundle their reads together. Each thread pays for its own slow trip. The chip looks fully occupied because threads are scheduled and running — they're just running the instruction that means "wait for memory."
04 Kernel 2 — coalescing: let neighbors read neighbors (→ 8.5%)
The code barely changes. All that changes is which thread computes which element, so that threads standing next to each other read memory addresses next to each other. When they do, the hardware fuses 32 separate reads into one transaction — "coalescing."
const int cRow = blockIdx.x * BLOCKSIZE + (threadIdx.x / BLOCKSIZE);
const int cCol = blockIdx.y * BLOCKSIZE + (threadIdx.x % BLOCKSIZE);
if (cRow < M && cCol < N) {
float tmp = 0.0;
for (int i = 0; i < K; ++i) {
tmp += A[cRow * K + i] * B[i * N + cCol];
}
C[cRow * N + cCol] = alpha * tmp + beta * C[cRow * N + cCol];
}Same math, same memory hierarchy, one reindexing. Result: 309 → 1986 GFLOP/s, a6.4× speedup from nothing but access order. We went from 1.3% → 8.5% of peak without touching the arithmetic. The lesson: on a GPU, how you touch memory matters more than how much you compute.
05 Kernel 3 — shared memory: stop re-reading the same data (→ 12.8%)
The naive kernel reads every element of A and B from slow global memory many times — once for every output element that needs it. The fix: each block first copies a tile of A and B into fast on-chip shared memory, then does all its math against that. Read slow memory once, reuse it many times.
__shared__ float As[BLOCKSIZE * BLOCKSIZE]; // a tile of A, in fast on-chip memory
__shared__ float Bs[BLOCKSIZE * BLOCKSIZE]; // a tile of B
float tmp = 0.0;
for (int bkIdx = 0; bkIdx < K; bkIdx += BLOCKSIZE) {
// every thread cooperatively loads ONE element of each tile from slow global memory
As[threadRow * BLOCKSIZE + threadCol] = A[threadRow * K + threadCol];
Bs[threadRow * BLOCKSIZE + threadCol] = B[threadRow * N + threadCol];
__syncthreads(); // wait until the whole tile is loaded
A += BLOCKSIZE; B += BLOCKSIZE * N; // slide to the next tile
// now do the dot-product against FAST shared memory, not global memory
for (int dotIdx = 0; dotIdx < BLOCKSIZE; ++dotIdx) {
tmp += As[threadRow * BLOCKSIZE + dotIdx] *
Bs[dotIdx * BLOCKSIZE + threadCol];
}
__syncthreads(); // don't overwrite the tile until everyone's done
}The new machinery — and it's the pattern behind nearly every fast GPU kernel:
__shared__— a scratchpad on the chip itself, shared by every thread in the block.- cooperative load — each thread grabs one element of the tile; together they fill it.
__syncthreads()— a barrier: "nobody starts the math until the whole tile is loaded," and later "nobody overwrites the tile until everyone's finished with it."- the inner loop now multiplies against
As/Bs— fast memory.
Result: 2980 GFLOP/s, 12.8% of peak. Still far from the ceiling — but now the bottleneck hasmoved, and that's the real skill: each kernel fixes the current limiter and exposes the next one.
06 The whole ladder — one answer, 72× of speed on the table
Boehm keeps going: blocktiling (each thread computes many outputs), vectorized loads, resolving bank conflicts, warptiling, double-buffering. Every step targets the memory system, not the math. Here's the full climb on an A6000, 4096×4096 — every row computes the identical matrix:
| kernel | GFLOP/s | % of cuBLAS (peak) |
|---|---|---|
| 1 · naive | 309 | 1.3% |
| 2 · global-mem coalescing | 1987 | 8.5% |
| 3 · shared-mem tiling | 2980 | 12.8% |
| 4 · 1D blocktiling | 8475 | 36.5% |
| 5 · 2D blocktiling | 15972 | 68.7% |
| 6 · vectorized access | 18237 | 78.4% |
| 9 · autotuning | 19721 | 84.8% |
| 10 · warptiling | 21779 | 93.7% |
| 0 · cuBLAS (NVIDIA's own) | 23250 | 100.0% |
Top to bottom: a 70× speedup on the same operation, same GPU, same correct answer. Every one of these kernels would light up nvidia-smi at ~100%. Only one number tells them apart, and it's not the one on the dashboard.
07 What actually separated them — the taste to take away
You don't need to memorize twelve kernels. You need the one mental model they all share:
Walk 01 showed you what a model computes. Walk 02 showed you those computations are matmuls. This walk shows you that a matmul's speed spans 70× — and the industry-standard "utilization" metric can't see any of it. Every kernel above pins the GPU at 100%. Only one is fast.
That gap is the entire company. In Karpathy's nanoGPT it surfaced as estimate_mfu()reporting ~35–50% while nvidia-smi said 100%. Here it's the difference between a 1.3%-of-peak kernel and a 93% one. Same phenomenon, one level down.utilization-truth — our flagship lab — is a kernel harness that measures this gap on your own GPU in one command: it runs matmul-bound, memory-bound, and launch-bound workloads and prints achieved % of peak next to what the dashboard claims. Your GPUs are lying to you; this is how we prove it. Every kernel here is one op climbing toward a ceiling onthe roofline — the diagram that puts all twelve versions, and the thesis, on a single chart.
↗ Full references — go to the source
We explain every kernel in our own words and never rehost the source. These are the originals — Simon Boehm's worklog is the canonical read on this.
- codesiboehm/SGEMM_CUDAthe repository — all 12 kernels benchmarked in this walk
- blogSimon Boehm“How to Optimize a CUDA Matmul Kernel for cuBLAS-like Performance: a Worklog” — the canonical read, kernel by kernel
- blogHorace He“Making Deep Learning Go Brrrr From First Principles” — compute-bound vs memory-bound vs overhead, the mental model behind every kernel here
- paperWilliams, Waterman & Patterson“Roofline: An Insightful Visual Performance Model” (CACM 2009) — the ceiling every kernel in this walk is measured against
- bookKirk & Hwu“Programming Massively Parallel Processors” (PMPP, 4th ed.) — the textbook for tiling, coalescing, and the memory hierarchy
- docsNVIDIACUDA C++ Programming Guide — threads, blocks, warps, shared memory, and coalescing
- docsNVIDIACUDA C++ Best Practices Guide — memory coalescing and the memory hierarchy, in detail
- docsNVIDIAMatrix Multiplication Background — arithmetic intensity, tiling, and why matmul is the canonical GPU workload
- codeNVIDIA CUTLASSthe production template library — how the tiling ideas from this walk look at industrial scale
- blogLei Mao“CUDA Matrix Multiplication Optimization” — a second independent worklog to triangulate against Boehm
- courseGPU MODE (formerly CUDA MODE)lecture series that reads real kernels end to end — the video companion to this kind of walk
- videoSimon Boehm @ GPU MODE“Lecture 9: Reductions” & the SGEMM walkthrough — the worklog, narrated
- docsNVIDIANsight Compute (ncu) — the profiler that tells you the achieved % of peak, per kernel
- blogMark Harris“How to Access Global Memory Efficiently in CUDA C/C++” — the coalescing idea from step 04, from the source