field notes from the bottom of the software stack ↓

The GEMM Scrapbook

One matrix multiplication. Seven CUDA kernels. A 70× speedup, documented like a road trip — with the wrong turns left in. By the end you'll know exactly why the naive kernel crawls at 1.3% of what the GPU can do, and how to claw your way to 94%.

C = A × B, in its natural habitat. Every cell of C is one row of A meeting one column of B. Drag to look around.
warming up…
audience: knows basic CUDA C++ benchmarks: RTX A6000 · 4096×4096 · fp32 reading time: one good coffee ×2
chapter 0

What even is GEMM, and why does everyone keep yelling about it?

GEMM = GEneral Matrix Multiply. The full BLAS ceremony is C = αAB + βC, but the α and β are seasoning. The meal is C = AB: take a matrix A (size M×K), a matrix B (size K×N), and produce C (size M×N), where every cell of C is a dot product — one row of A pointwise-multiplied with one column of B, then summed.

Cij = Σk=0…K−1  Aik · Bkj Each of the M·N output cells needs K multiplies and K adds → total work = 2·M·N·K floating-point operations (FLOPs) For M = N = K = 4096, that's 2·4096³ ≈ 137 billion FLOPs. Per multiplication. And we're going to do it in ~6 milliseconds.

Why does it matter? Because a large language model is, to a first approximation, a machine for doing GEMMs. Attention projections: GEMMs. The feed-forward blocks: two big GEMMs. The output head: a giant GEMM. When you're waiting for a chatbot's next token, you're mostly waiting for matrix multiplications. Make GEMM 2× faster and inference gets roughly 2× cheaper. That's why NVIDIA employs small armies to tune it, and why it's the single best kernel to learn GPU optimization on: simple enough to hold in your head, deep enough to teach you the whole machine.

Hover (or tap) any cell of C — its row of A and column of B light up. Press play to watch one dot product accumulate.
k = 0 / 8

Here's the property that makes the next seven chapters possible: those 137 billion FLOPs are almost embarrassingly parallel. Every cell of C can be computed independently — no cell needs another cell's answer. 16.7 million independent little jobs (for 4096×4096). That's the shape of workload GPUs were born for.

And here's the property that makes the next seven chapters necessary: the inputs are only A + B + C ≈ 201 MB, but the arithmetic touches those same numbers over and over — each element of A is needed by 4096 different output cells. GEMM is a data-reuse game. Win the reuse game, you win everything. Spoiler: the naive kernel loses the reuse game so badly it re-requests about half a terabyte from memory.

"scalar-vector: boring. vector-vector: cute. matrix-matrix: this is where the money is." — every BLAS library, basically
chapter 1

Meet the hardware (a workplace comedy)

Before writing kernel one, meet the cast. A GPU like the RTX A6000 is a factory with 84 identical workshops called SMs (Streaming Multiprocessors). You don't hire individual workers; you submit a grid of thread blocks, and each block gets assigned to a workshop. Inside a block are up to 1024 threads — the actual workers.

But the threads don't move freely. The hardware bundles them into groups of 32 called warps, and a warp moves in lockstep: 32 threads, one instruction, all together. Think of a warp as a 32-segment caterpillar. It's the single most important creature in this scrapbook, so let's give it a name.

🐛
Warpy
(32 threads)
We're 32 threads but we share one brain — every step, all my segments execute the same instruction. When I ask memory for data, I ask for all 32 of us at once. Remember that. It will ruin someone's benchmark in chapter 3.

The memory hierarchy, drawn to (emotional) scale

Threads need data, and data lives at very different distances. The numbers below are the whole plot of this scrapbook — every optimization we make is just moving work from a far shelf to a near one:

Press play: four couriers leave at once to fetch one number each. Distance drawn ∝ latency. The DRAM courier will be a while.
t = 0 cycles
StorageLivesLatency-ishSize-ishKitchen analogy
Registersper thread~1 cycle255 per threadyour own two hands
Shared memoryper block (on-SM)~20–30 cyclesup to 100 KB/SMthe counter you're standing at
L2 cachewhole GPU~200 cycles6 MBthe pantry down the hall
Global memory (DRAM)off-chip~400–600 cycles48 GB @ 768 GB/sthe warehouse across town
≈500 cycles for DRAM vs 1 for a register. if a register read were 1 second, a DRAM read would be ~8 minutes. now imagine cooking a dish where every single ingredient is a separate 8-minute drive. that's kernel 1. we wrote that kernel. on purpose. for science.

The one equation to tape inside your locker

The A6000 can do ~38.7 TFLOP/s of fp32 math but can only pull ~768 GB/s from DRAM. Divide those and you get the machine's ridge point:

38,700 GFLOP/s768 GB/s50 FLOPs per byte Every byte you fetch from DRAM must be repaid with ~50 floating-point operations, or the math units sit idle waiting for deliveries. The ratio of FLOPs done to bytes moved is called arithmetic intensity — it is the score we'll be chasing for seven kernels.

A whole GEMM has arithmetic intensity of roughly 2·4096³ FLOPs / 201 MB ≈ 680 FLOPs/byte — way above 50, so GEMM deserves to be compute-bound. Whether your kernel actually achieves that depends entirely on how much reuse you capture on-chip. That's the game. Let's play badly first.

🏭
DRAM
warehouse
Big Boxy Memory Co. — 48 GB in stock, 768 GB/s at the loading dock! We have your data, come get it. (eventually.) (bring a book.)
the rules

The ladder: how we keep score

From here on, every chapter is one kernel. Same problem every time — C = A·B, 4096×4096, fp32 — so the only thing changing is how well we use the machine. We measure GFLOP/s: the 137 billion FLOPs of work divided by wall-clock time. The reference bar is NVIDIA's own cuBLAS at 23,250 GFLOP/s.

A note on honesty, since this will become an article: the numbers on these pages are from Simon Boehm's classic kernel ladder, measured on an RTX A6000 — the canonical modern walk up this mountain. The kernels here follow the same route. A ready-to-run harness ships with this scrapbook so the numbers can be re-measured on any card, and the ratios between rungs are the durable lesson: they look similar on every modern NVIDIA GPU.

benchmark rules: warm up first, time with CUDA events, average many reps, and always verify the output against cuBLAS. a wrong matmul is infinitely fast and infinitely useless. — past me, after a very embarrassing afternoon
K1
kernel one

Naive: one thread, one number, zero shame

"just translate the math into code, how bad can it be" — famous last words
309 GFLOP/s1.3% of cuBLAS

The obvious plan, and honestly a beautiful one: C has 16.7 million cells, GPUs have millions of threads, so give every thread one cell. Each thread walks its row of A and its column of B, multiply-accumulates K = 4096 times, writes one float. Done. This is the kernel every CUDA tutorial ends with, and the kernel every fast library begins by deleting.

sgemm_naive.cu 14 lines of crime
__global__ void sgemm_naive(int M, int N, int K,
                            const float *A, const float *B, float *C) {
  // one thread ↔ one output cell of C
  const uint x = blockIdx.x * blockDim.x + threadIdx.x;  // row of C
  const uint y = blockIdx.y * blockDim.y + threadIdx.y;  // col of C

  if (x < M && y < N) {
    float acc = 0.0f;
    for (int k = 0; k < K; ++k)
      acc += A[x * K + k] * B[k * N + y];   // dot product
    C[x * N + y] = acc;
  }
}
// launch: dim3 block(32, 32);  dim3 grid(M/32, N/32);
Each thread drags its whole row + column across town from DRAM. Watch the "bytes requested" meter. Keep watching. Oh no.

The autopsy

Each thread does 2K = 8192 FLOPs but requests 2K·4 = 32 KB from global memory. That's the reuse game lost 0–100:

AInaive = 2K FLOPs2K × 4 bytes = 0.25 FLOPs/byte   (the machine wants ≈ 50) Summed over all 16.7M threads, the kernel requests ≈ 2·4096³·4 B ≈ 550 GB of reads for a job whose inputs total 201 MB — every element of A re-fetched up to 4096 times.

The L2 cache heroically absorbs a lot of that (which is why we're 75× slower than cuBLAS instead of 2700×), but the arithmetic units still spend almost all their time waiting. And it's actually worse than that — the way these threads ask for memory offends Warpy personally. Which brings us to the fastest 6× you will ever earn.

🐌
0this kernel: 309 GFLOP/s · 445 ms per matmulcuBLAS 23,250
K2
kernel two

Coalescing: carpool or perish

the 6.4× speedup that costs two swapped letters
1,987 GFLOP/s8.5% · 6.4× faster

Remember Warpy's warning: memory requests happen per-warp, not per-thread. When 32 threads of a warp load a float each, the hardware looks at the 32 addresses and groups them into as few 32-byte transactions as possible. If the addresses are consecutive, all 32 floats (128 bytes) arrive in a handful of transactions — one bus, everyone rides together. If they're scattered, it's up to 32 separate trips carrying mostly air.

Now look at kernel 1's indexing. Threads that are neighbors in threadIdx.x got consecutive rows — their addresses in A and C are a full 16 KB apart. Same warp, 32 wildly scattered addresses, worst case every single load. The fix is almost insultingly small: remap the indices so that consecutive threads own consecutive columns — neighboring addresses in memory.

sgemm_coalesced.cu the diff is 2 lines
// each 32-thread stripe of the block now walks ALONG a row of C,
// so warp-neighbors touch neighboring memory. that's the whole trick.
const uint x = blockIdx.x * 32 + (threadIdx.x / 32);  // row  (changes every 32 threads)
const uint y = blockIdx.y * 32 + (threadIdx.x % 32);  // col  (changes every thread)  ★

if (x < M && y < N) {
  float acc = 0.0f;
  for (int k = 0; k < K; ++k)
    acc += A[x * K + k] * B[k * N + y];  // B & C now coalesce beautifully
  C[x * N + y] = acc;
}
Same caterpillar, same 32 floats. Top: everyone drives alone (32 transactions). Bottom: the carpool (consecutive addresses → a few wide loads).

Nothing about the math changed. Nothing about how much data we touch changed. We only changed the shape of each warp's requests, and throughput jumped from 309 → 1,987 GFLOP/s. This is the recurring theme of GPU work: the hardware rewards you for asking politely.

interview tip: "what's memory coalescing?" is the "tell me about yourself" of GPU interviews. you now have a caterpillar-based answer.
🚶
0this kernel: 1,987 GFLOP/s · 69 ms per matmulcuBLAS 23,250
K3
kernel three

Shared memory: stop commuting, stock the counter

in which the block finally discovers teamwork
2,980 GFLOP/s12.8% · 1.5× faster

We're still going to the DRAM warehouse for every ingredient. But notice: the 1024 threads of one block all compute cells in the same 32×32 patch of C — and their dot products read the same 32 rows of A and 32 columns of B. A thousand workers making a thousand separate trips for overlapping shopping lists. The fix: tile the K dimension.

Chop A's rows and B's columns into 32-wide chunks. Each round, the block cooperatively hauls one 32×32 tile of A and one of B from DRAM into shared memory — the fast on-SM counter from chapter 1 — everyone grabbing one element (coalesced, obviously; we learned that lesson). Then __syncthreads(), and everybody computes their partial dot product from the counter at ~20-cycle latency instead of ~500. Slide the tiles along K, repeat, done.

Tiles of A and B slide onto the shared-memory counter, get squeezed for every FLOP they contain, then the window advances along K.
sgemm_smem.cu the inner loop
__shared__ float As[32 * 32];   // the counter: 4 KB each
__shared__ float Bs[32 * 32];

float acc = 0.0f;
for (int tile = 0; tile < K; tile += 32) {
  // whole block hauls one tile of A and B in together (coalesced)
  As[row * 32 + col] = A[x * K + (tile + col)];
  Bs[row * 32 + col] = B[(tile + row) * N + y];
  __syncthreads();                // wait till the counter is stocked

  for (int k = 0; k < 32; ++k)     // shop from the counter, not the warehouse
    acc += As[row * 32 + k] * Bs[k * 32 + col];
  __syncthreads();                // don't restock while people are shopping
}
C[x * N + y] = acc;
Every float now hauled from DRAM gets used by 32 threads instead of 1, so global traffic drops ~32× for the inner loop: 550 GB → ≈ 17 GB per matmul Per-thread arithmetic on shared memory: 2 loads + 2 FLOPs per k-step → AI against shared memory is still a sad 0.25… foreshadowing.

And the payoff for all that engineering is… 1.5×. One point five. We built the beautiful tile choreography and barely moved.

🐛
Warpy
Don't sulk. The warehouse trips are gone — DRAM isn't the bottleneck anymore. The problem is me: each of my segments still computes one measly cell, so I'm doing two shared-memory loads for every two FLOPs. The counter is fast, but it's not free. Your kernel is now latency-bound on shared memory. Give each of us more work per trip to the counter.
🚲
0this kernel: 2,980 GFLOP/s · 46 ms per matmulcuBLAS 23,250
K4
kernel four

1D tiling: give every thread a column to raise

one number per thread was a rookie ratio
8,475 GFLOP/s36.5% · 2.8× faster

Warpy's diagnosis: too many loads per FLOP. The cure is the same trick as chapter 3, one level down. We tiled DRAM→shared; now we tile shared→registers, the only memory that's actually free. Instead of one output cell, each thread now owns a little column of TM = 8 cells of C, kept in registers the whole time.

Watch what that does to the inner loop. For each k-step, the thread loads one value of B into a register — and reuses it against 8 values of A to update all 8 accumulators:

sgemm_1d_tile.cu the money loop
float acc[8] = {0.0f};                // 8 cells of C live in registers now

for (int k = 0; k < BK; ++k) {
  float bTmp = Bs[k * BN + col];     // ONE shared-mem load…
  for (int t = 0; t < 8; ++t)
    acc[t] += As[(row*8 + t) * BK + k] * bTmp;   // …amortized over 8 FMAs
}
One thread, eight plates spinning. Each B value is fetched once and billed to eight different dot products.
Per k-step, per thread: 8 loads of A + 1 load of B buy 8 FMAs = 16 FLOPs. loads per FLOP:  22916   — nearly 2× more math per shared-memory visit Result: 2,980 → 8,475 GFLOP/s. The largest single jump on the whole ladder, and it came from a register.

An asymmetry should be bugging you: B values get reused 8× but A values are still loaded fresh every FMA. If reusing along one axis is this good…

🛵
0this kernel: 8,475 GFLOP/s · 16 ms per matmulcuBLAS 23,250
K5
kernel five

2D tiling: the outer product awakens

8×8 per thread — now we're farming
15,972 GFLOP/s68.7% · 1.9× faster

…then reuse along both. Each thread now owns an 8×8 patch of C — 64 accumulators living in registers. Per k-step it loads a length-8 sliver of A and a length-8 sliver of B into registers, then computes their outer product: all 64 pairwise products, each feeding its own accumulator. 16 loads buy 128 FLOPs.

This is the moment GEMM's deep structure surfaces: a matmul is just a sum of outer products — for each k, (column k of A) ⊗ (row k of B), all stacked up. Every serious GEMM implementation on earth, cuBLAS and CUTLASS included, is arranged around exactly this shape. You've now derived it from first principles by being annoyed at load counters.

One sliver of A × one sliver of B = 64 updates, every k-step. The 8×8 grid fills like a bingo card that always wins.
sgemm_2d_tile.cu the outer product loop
float acc[8][8] = {{0.0f}};   // 64 registers of pure ambition
float regA[8], regB[8];

for (int k = 0; k < BK; ++k) {
  for (int i = 0; i < 8; ++i) regA[i] = As[(tRow*8+i) * BK + k];  // sliver of A
  for (int j = 0; j < 8; ++j) regB[j] = Bs[k * BN + tCol*8+j];  // sliver of B
  for (int i = 0; i < 8; ++i)
    for (int j = 0; j < 8; ++j)
      acc[i][j] += regA[i] * regB[j];   // 64 FMAs, zero memory ops
}
loads per k-step: 8 + 8 = 16  ·  FLOPs: 2·64 = 128  →  8 FLOPs per load (vs 1.78 in K4, 1 in K3) Register pressure is the tax: 64 accumulators + slivers ≈ 80+ registers/thread, which caps how many warps fit per SM. Tiling is always this trade — reuse up, occupancy down — and 8×8 is near the sweet spot for fp32 on Ampere.

68.7% of cuBLAS. The remaining gap isn't about what we compute anymore — it's about the pipes and the paperwork. Time for two chapters of pure logistics.

we went from "1 cell per thread" to "64 cells per thread" and got 5.4× overall. the lesson is not subtle: FLOPs are cheap, trips are expensive, and registers are the only free lunch on the menu.
🚗
0this kernel: 15,972 GFLOP/s · 8.6 ms per matmulcuBLAS 23,250
K6
kernel six

float4: ship pallets, not envelopes

same groceries, one dolly
18,237 GFLOP/s78.4% · 1.14× faster

Every load instruction has fixed overhead: issue it, track it, retire it. Loading one 4-byte float per instruction means paying that overhead 4× more often than necessary, because the hardware has 128-bit wide load instructions (LDG.128 from global, LDS.128 from shared) that move four floats in one go. In CUDA you unlock them by loading float4:

sgemm_vectorized.cu the cast of the century
// four floats, one instruction. compiler emits LDG.128 / LDS.128
float4 tmp = reinterpret_cast<const float4*>(&A[x * K + k])[0];

// while loading, store A's tile TRANSPOSED into shared memory,
// so the inner loop can later read slivers of A with LDS.128 too:
As[(k+0) * BM + row] = tmp.x;
As[(k+1) * BM + row] = tmp.y;
As[(k+2) * BM + row] = tmp.z;
As[(k+3) * BM + row] = tmp.w;
Top: four envelopes, four trips, four receipts. Bottom: one pallet, one receipt. The warehouse guys love kernel 6.

Two details hiding in that snippet, both classic senior-engineer moves. First, the transpose: storing A's tile column-major in shared memory costs nothing extra during the load phase, but lets the hot inner loop read A-slivers as contiguous 128-bit chunks. Arrange your data at write time so it's cheap at read time — you'll use this trick your whole career. Second, float4 requires 16-byte alignment; feed it a misaligned pointer and it's an instant trip to cudaErrorMisalignedAddress town.

🏎️
0this kernel: 18,237 GFLOP/s · 7.5 ms per matmulcuBLAS 23,250
K7
kernel seven

Warptiling: a tile for every tier

blocks tile the matrix, warps tile the block, threads tile the warp
21,779 GFLOP/s93.7% · 1.19× faster

Count our tiling levels: the block owns a big patch of C (tiled from DRAM into shared), and each thread owns an 8×8 patch (tiled from shared into registers). But there's a tier of the hardware we never gave its own tile: Warpy. Kernel 7 fixes the org chart — each warp gets an explicit warp tile, subdivided among its 32 threads.

The full org chart: matrix → block tile (shared mem) → warp tile → thread tile (registers). Use the slider to zoom through the tiers.

Why does adding a middle manager make anything faster? Three unglamorous, very real reasons:

1 · Register-cache locality. The warp's fragments of A and B get reused across the warp tile in a tight, predictable pattern, so the compiler schedules loads and FMAs into a smooth pipeline instead of a traffic jam.

2 · Bank-conflict dodging. Shared memory is 32 banks wide; if two threads of a warp hit the same bank, they queue. Warp-aware layouts let you arrange accesses so all 32 segments hit 32 different banks.

3 · It's the shape of the future. A warp computing a small matrix patch together is exactly the contract of tensor-core instructions (mma, wgmma). Warptiling is the fp32 dress rehearsal for how all modern GEMM libraries are built.

One more trick rides along in the fast kernels: double buffering. While the math units chew tile t, the next tile t+1 is already being fetched into a second buffer — compute and delivery overlap, and nobody waits:

Top timeline: load, compute, load, compute (everyone naps). Bottom: double buffering — the next tile is in flight while this one is being eaten.
🚀
0this kernel: 21,779 GFLOP/s · 6.3 ms per matmulcuBLAS 23,250
the finish line

445 ms → 6.3 ms: the whole trip on one page

Seven kernels, a 70× speedup, and not one line of assembly. Here's the ladder in full — press the button and watch the race we just ran:

RTX A6000 · 4096³ · fp32 · GFLOP/s
#KernelThe one ideaGFLOP/sms/matmulvs cuBLAS
1Naive1 thread = 1 cell309444.81.3%
2Coalescingwarps read neighbors1,98769.28.5%
3Smem tilingblock shares tiles2,98046.112.8%
41D tiling8 cells per thread8,47516.236.5%
52D tilingouter products, 64 cells15,9728.668.7%
6float4128-bit loads18,2377.578.4%
7Warptilinga tile per hardware tier21,7796.393.7%
cuBLASseveral careers of tuning23,2505.9100%

The whole scrapbook in three sentences

1. GEMM has enormous intrinsic data reuse (~680 FLOPs/byte available); a kernel is fast exactly in proportion to how much of that reuse it captures in fast memory. 2. So you tile at every level the hardware has — DRAM→shared (blocks), shared→registers (threads), and warps in between — and you make every remaining transfer wide, coalesced, and overlapped with compute. 3. Everything else is bookkeeping.

What's still on the mountain

The honest fine print: the real ladder has two rungs we hand-waved — resolving shared-memory bank conflicts and autotuning tile sizes per GPU (that's how you squeeze out 84.8% before warptiling's 93.7%). And above all of this sits a different vehicle entirely: tensor cores — dedicated matmul hardware driven by mma/wgmma instructions, where the A6000 does not do 39 but ~77 TFLOP/s of tf32 (and ~155 of fp16), and libraries like CUTLASS, Triton and DeepGEMM live. The beautiful part: it's all still tiles — the same hierarchy you just learned, with the thread-tile FMA swapped for a warp-wide matrix instruction. You now speak the language; the tensor-core chapter is a vocabulary lesson.

next trip: tensor cores + CUTLASS. same mountain, bigger engine. bring the caterpillar. — the sequel, probably

Take it with you

This scrapbook ships with a benchmark harness — all seven kernels as runnable CUDA files with timing, cuBLAS verification, and a Makefile — so every number here can be re-measured on whatever GPU you get your hands on. The reference numbers and route follow Simon Boehm's worklog; for the visual-explanation genre this page lives in, see bbycroft's LLM visualization and Wattenberger's essays; for what lies above kernel 7, start with CUTLASS and the CUDA C++ Programming Guide.

←  more writing