Vizuara · GPU notes · part two

How a Matmul Kernel Gets Fast

Ten kernels, from 1.3% of the hardware to 93.7%, with every byte and every multiply accounted for.

This post is the second half of a pair. The first one took a photograph of a dog and followed it down into the silicon: grids, blocks, warps, schedulers, the clock, and the 128-byte trays the memory system serves. It never optimized anything. This one does nothing but optimize. We take a single problem, multiplying two 4092 × 4092 matrices, and rewrite the kernel ten times, from a version that reaches 1.3% of the machine to one that reaches 93.7%.

The route is Simon Boehm's, from his excellent worklog on optimizing a CUDA matmul kernel, and all the measurements quoted here are his, taken on an NVIDIA A6000. 1A6000: 84 SMs, 768 GB/s of memory bandwidth, about 30 TFLOPs/s of fp32 arithmetic, 48 KB of shared memory per block. Boehm's code is on GitHub. What this post adds is the thing I wanted when I first read it: a picture for every step, and an explicit answer, in bytes and instructions, to the question why did that make it faster? Each kernel below gets the same treatment. What changed, what it costs, and the arithmetic that turns the change into a number of FLOPs.

1The problem, and what cuBLAS is

The operation is called SGEMM, and the name is an acronym stack worth unpacking because it explains the other name too. GEMM is GEneral Matrix Multiply. The S in front means single precision, that is, ordinary 32-bit floats. GEMM belongs to a catalogue of routines called BLAS, Basic Linear Algebra Subprograms, a standard list of operations agreed on in the 1970s that every numerical library implements: dot products, matrix-vector products, matrix-matrix products. cuBLAS is simply NVIDIA's own implementation of that catalogue for their GPUs. It is closed source, it ships with CUDA, and when PyTorch multiplies two matrices, this is very often the code that actually runs.

So cuBLAS is not an algorithm or a technique. It is the professionally tuned competitor: the number to beat, or rather to approach. In the scoreboard below, cuBLAS is 100% by definition, and every other row is a kernel you could write yourself in an afternoon or a week.

The full operation GEMM defines is C = αAB + βC, where α and β are scalars. 2The α and β exist so the routine can also scale and accumulate into an existing C, which is what makes it a useful building block. They add two multiplies per output element and change none of the analysis; ignore them. We will treat it as plain C = AB. Here is what that means for one output element, on small matrices where you can check the arithmetic by eye.

Three 8 by 8 matrices A, B and C with one row of A, one column of B and the resulting element of C highlighted, and the dot product written out
Figure 1. Matrix multiply, all of it. Every element of C is the dot product of one row of A with one column of B. Nothing in this post changes that arithmetic. Every kernel below computes exactly these numbers; they differ only in the order they do the work and where they keep the operands.

Scaling up to the real problem: with 4092 × 4092 matrices, C has 16.7 million elements, each needing 4092 multiplies and 4092 adds. That is 2 × 4092³ ≈ 137 billion floating-point operations for one matmul. The machine we are running on can do about 30 billion of them per millisecond. The question of this entire post is why the first kernel takes 445 milliseconds to do something the hardware could do in 4.6.

Horizontal bar chart of nine kernels from naive at 309 GFLOPs to cuBLAS at 23250 GFLOPs, with the 30000 hardware ceiling marked
Figure 2. The scoreboard we are going to walk up. Two things are worth noticing before we start. The first four steps are where almost all the performance lives: naive to 2D blocktiling is a 52× improvement. Everything after that, four more kernels of increasingly specialist work, adds a further 36%. And even cuBLAS leaves a fifth of the machine on the table.

2Two clocks: matmul should be compute-bound

Before optimizing anything it is worth knowing what the ceiling is and which resource we expect to hit it. There are only two candidates: the arithmetic units, and the memory system. So compute both times.

The arithmetic clock. 137 GFLOP at 30 TFLOP/s takes 4.6 milliseconds. That is the floor. No arrangement of the code beats it.

The memory clock. How many bytes must move, at minimum? A, B and C are each 4092² × 4 bytes ≈ 67 MB. You must read all of A, all of B, and read and write C: about 268 MB in total. At 768 GB/s that takes 0.35 milliseconds.

Compare them and the conclusion is emphatic. The arithmetic takes thirteen times longer than the unavoidable memory traffic. Matmul is inherently a compute-bound problem, and a perfectly written kernel should be sitting there waiting on the FP32 units with the memory system idling. That is the good news. The bad news is the third bar.

Log-scale bar chart comparing 4.57 ms of compute, 0.35 ms of minimum memory traffic, and 714 ms of naive memory traffic
Figure 3. The naive kernel does not move 268 MB. It moves about 548 GB, roughly two thousand times the minimum, because it re-reads the same rows and columns of A and B over and over. That is why it lands at 1.3%: a problem that should be limited by arithmetic has been turned into one limited by DRAM.
The one sentence this whole post is about

Every kernel from here on does the same 137 GFLOP of arithmetic. What changes, every single time, is how many bytes have to move to feed it, and how many instructions are spent moving them. Performance is the ratio between arithmetic and traffic, and each optimization raises that ratio. If you keep this one sentence in view, none of the ten kernels is mysterious.

3Kernel 1: the naive version, 1.3%

The obvious kernel gives one thread to each element of C, and that thread walks the dot product in a loop.

// one thread per output element
const uint x = blockIdx.x * blockDim.x + threadIdx.x;
const uint y = blockIdx.y * blockDim.y + threadIdx.y;

float tmp = 0.0;
for (int k = 0; k < K; ++k)
    tmp += A[x * K + k] * B[k * N + y];   // 2 loads, 2 flops
C[x * N + y] = tmp;

It is correct, it is readable, and it reaches 309 GFLOPs, which is 1.3% of what cuBLAS gets and about 1% of the hardware. To see why, stop thinking about the thread and start thinking about the warp: 32 consecutive threads that issue their loads together, as one request.

In this code threadIdx.x varies fastest within a warp, and it feeds x, which is the row index. So the 32 threads of a warp own 32 consecutive rows of C, all in the same column. Now ask what they read on the very first pass of the loop, when k = 0.

Kernel 1: warp 0 occupies a column of C, reads a column of A which is scattered in memory, and broadcasts one element of B
Figure 4. Warp 0 in kernel 1. Its 32 threads need 32 values from a column of A. But A is stored row by row, so consecutive rows are 4092 floats, about 16 KB, apart. The memory system can only deliver whole 32-byte sectors, so it fetches 32 separate sectors, 1024 bytes, to extract the 128 bytes anyone wanted.

Count the bill for that one k-step, for the whole warp. A costs 32 sectors × 32 bytes = 1024 bytes. B is free, since all 32 threads want the identical element and the hardware broadcasts it from one 32-byte sector. Total: 1056 bytes moved to perform 64 FLOPs (32 threads, one multiply and one add each). Boehm's profiler agrees with the diagnosis: the kernel achieves 15 GB/s of useful memory throughput on a GPU that can do 768.

4Kernel 2: memory coalescing, 8.5%

The fix does not touch the arithmetic, the loop, the block size, or the number of threads. It swaps which index each thread takes.

// same threads, same work, different assignment
// row: threadIdx.x DIVIDED by the block width
const uint x = blockIdx.x * BLOCKSIZE + (threadIdx.x / BLOCKSIZE);
// col: threadIdx.x MODULO the block width  ← the one that matters
const uint y = blockIdx.y * BLOCKSIZE + (threadIdx.x % BLOCKSIZE);

Now threadIdx.x feeds y, the column index, so a warp's 32 threads own 32 consecutive columns of one row of C. Re-run the same question. At k = 0, all 32 threads want the same element of A, which is a free broadcast. And they want 32 consecutive elements of a row of B, which in row-major storage are 32 consecutive floats: one unbroken run of 128 bytes.

Kernel 2: warp 0 occupies a row of C, broadcasts one element of A, and reads 32 contiguous elements of B as a single 128-byte transaction
Figure 5. The same warp after the swap. Its whole appetite for B is one 128-byte run, which the memory system delivers as a single transaction with nothing wasted.

This is what coalescing means, and it is worth stating precisely because the word gets used loosely. The hardware does not merge requests because it is clever; it merges them because it was always going to fetch whole 32-byte sectors, and the only question is whether the 32 threads' addresses fall inside a few sectors or thirty-two of them. Coalescing is not an optimization the GPU performs. It is a property your access pattern either has or lacks.

Bar chart comparing 1056 bytes moved in kernel 1, mostly wasted, against 160 bytes in kernel 2, all useful, for the same 64 FLOPs
Figure 6. The bill for one warp, one k-step, in both kernels. Identical arithmetic; 6.6× the traffic.

So here, concretely, is how a memory change becomes a FLOPs number. The kernel is limited by the rate the memory system can feed it. Feeding 64 FLOPs cost 1056 bytes and now costs 160, a factor of 6.6. If the kernel is purely memory-limited, its speed should rise by that same factor. Measured: 309 → 1986 GFLOPs, a factor of 6.4. Boehm's profiler shows the matching cause: useful throughput climbs from 15 GB/s to 110 GB/s. Two index lines, a 6× speedup, and the prediction lands within 3% of the measurement.

Aside · why isn't it 768 GB/s yet?

Because the kernel still asks for far more data than it needs: it re-reads all of A and B for every tile of C. What coalescing fixed is the efficiency of each request, not the number of requests. The remaining kernels attack the number. That is also why the naive kernel manages 309 GFLOPs rather than the 192 you would predict from raw DRAM bandwidth alone: the L2 cache absorbs a good share of the repeated reads. Caches soften the penalty; they do not remove it.

5The memory hierarchy, and what shared memory is

Kernel 2 fixed how efficiently we ask. The next kernels attack how often we ask, and for that we need one piece of hardware we have not used yet.

An SM has three places to keep numbers, and they differ by orders of magnitude in both speed and size. Registers are the fastest: each thread's private handful, read in the same cycle they are used. Shared memory is a scratchpad of about 48 KB per block, physically inside the SM, visible to every thread in the block, roughly 20–30× faster to reach than DRAM. And global memory is the HBM stacks off-chip: gigabytes, but hundreds of cycles away.

the three places a number can live registers · inside the thread ~256 KB per SM · read in the same cycle it is used shared memory · inside the SM, shared by the block 48 KB per block · a few cycles · YOU decide what goes in it L2 cache · 6 MB, shared by all SMs · automatic, not yours to control global memory (HBM) · off the chip 48 GB · 768 GB/s · hundreds of cycles away fast slow this oneis the toolwe have notused yet shared memory is a cache you fill by hand
Figure 7. Shared memory is the one level of the hierarchy you control explicitly. The L2 cache also holds recently used data, but you cannot tell it what to keep; shared memory you load deliberately, and it stays until you overwrite it.

6Kernel 3: caching a tile, 12.8%

Kernel 2 made every request efficient. It did nothing about the number of requests, and the number is enormous. To see how enormous, keep following one specific block: the one computing the top-left 32 × 32 tile of C, with 1024 threads, one per output.

First, count what that block truly needs. Its 32 rows of output depend on rows 0–31 of A, all 4092 columns of them. Its 32 columns of output depend on columns 0–31 of B, all 4092 rows. So the block needs 2 × 32 × 4092 = 261,888 distinct numbers to do its job.

A, B and C matrices showing that one block of C needs a full row strip of A and a full column strip of B, subdivided into chunks
Figure 8. What one block depends on. Its 32 × 32 tile of C needs an entire row strip of A and an entire column strip of B — every column of A's 32 rows, every row of B's 32 columns.

Now count what it actually fetches. Each of the 1024 threads runs the k-loop 4092 times and loads two values per iteration. That is 1024 × 4092 × 2 = 8,380,416 loads. Divide: every number the block needs is being dragged out of DRAM 32 times over.

Thirty-two is not a coincidence, and seeing where it comes from is the whole insight. Take one element of A, say A[0][7]. Which threads want it? Every thread computing an output in row 0 of the C tile, because they all walk row 0 of A. There are 32 such threads, one per column of the tile. In kernel 2 each of them goes and gets it separately.

The 32 by 32 tile of C with one row and one column highlighted, and the corresponding row of A and column of B that all 32 threads each fetch separately
Figure 9. The redundancy, on the block we are following. The 32 threads along the orange row all read the same row of A; the 32 along the purple column all read the same column of B. Each of them fetches it from global memory independently. The tile has 32 rows and 32 columns, so every value gets fetched 32 times.

So the fix writes itself: fetch each value once, put it somewhere every thread in the block can reach, and let all 32 of them read it from there. Registers cannot do this, since a register is private to one thread. This is exactly what shared memory is for.

Why we cannot simply preload everything. The obvious version, load the whole row strip of A and column strip of B and then compute, does not fit. The A strip alone is 32 × 4092 × 4 bytes = 512 KB, and B needs another 512 KB. A block gets 48 KB of shared memory. We are over budget by a factor of twenty-one.

The way out is to notice that the k-loop does not need all of k at once. It accumulates. So chop the k direction into chunks, and hold only one chunk at a time.

The 512 KB row strip of A dwarfing the 48 KB of shared memory, then the same strip cut into 128 chunks with one 8 KB chunk highlighted
Figure 10. Why the algorithm has the shape it has. The strips are far too big to preload, but a single 32-wide chunk of each, 2048 floats in total, is 8 KB and fits comfortably. With K = 4092 there are 128 such chunks.

Who loads what. Here the numbers line up neatly. A chunk of A is 32 × 32 = 1024 elements, and the block has exactly 1024 threads. So each thread copies precisely one element of A and one of B from global memory into shared memory. No loops, no leftovers, and the loads are coalesced because neighbouring threads copy neighbouring elements.

A 32 by 32 grid of threads mapped one-to-one onto a 32 by 32 chunk of As in shared memory
Figure 11. The cooperative load. Each thread fetches one element of A and one of B — 2048 global loads for the entire block — and then the block stops touching DRAM until the next chunk.

What a barrier is

Here is the thing shared memory forces on us that registers never did: the threads have to wait for each other. To see why, remember that thread (5, 9) is about to read row 5 of As — thirty-two values. Did thread (5, 9) write those values? No. It wrote exactly one element. The other thirty-one were written by thirty-one other threads.

And threads do not run in lockstep. The block's 1024 threads are 32 warps, and the SM's schedulers run whichever warps are ready, in whatever order suits them. Some warps are hundreds of cycles ahead of others. So it is entirely possible for thread (5, 9) to finish its own copy, race onward, and read As[5][17] before the thread responsible for As[5][17] has written it. It would read whatever junk was left in that shared-memory location from the previous chunk, and the answer would be wrong — sometimes, unpredictably, depending on scheduling.

__syncthreads() is the fix, and it does exactly one thing: every thread in the block stops on that line until all 1024 of them have reached it. It is a meeting point. The fast threads sit and wait; when the last straggler arrives, all of them are released together. Because every thread's copy statement is before the barrier and every thread's read is after it, the barrier guarantees the whole tile is complete before anybody looks at it.

what __syncthreads() is for without it — threads run at different speeds thread (5,9)thread (5,17)thread (0,3) its copy reads As[5][17] … still waiting on its global load… writes As[5][17] its copy read happens BEFORE the write — garbage with it — everyone meets at the line thread (5,9)thread (5,17)thread (0,3) its copy waits … slow global load, then writes As[5][17] its copy waits … __syncthreads() now reads — safe reads — safe reads — safe every copy is before the line · every read is after it · so the tile is always complete a barrier is a meeting point for all 1024 threads (the second barrier is the same idea in reverse: nobody may overwrite the tile until everybody has finished reading it)
Figure 12. Why the barrier exists. Threads finish their copies at different times, so without a meeting point a fast thread can read a slot its neighbour has not filled yet. The barrier costs the fast threads some waiting; it buys correctness. The second barrier at the bottom of the loop prevents the mirror-image bug — a fast thread starting the next chunk's copy and overwriting values a slow thread is still using.
__shared__ float As[32*32], Bs[32*32];   // 8 KB, lives in the SM
float tmp = 0.0;                       // running total, in a register

for (int chunk = 0; chunk < K; chunk += 32) {

    // 1) every thread copies exactly one element of each
    As[threadRow*32 + threadCol] = A[...];
    Bs[threadRow*32 + threadCol] = B[...];

    __syncthreads();   // 2) nobody reads until all have written

    // 3) 32 multiply-adds, every operand from shared memory
    for (int d = 0; d < 32; ++d)
        tmp += As[threadRow*32 + d] * Bs[d*32 + threadCol];

    __syncthreads();   // 4) nobody overwrites until all have read
}
C[...] = tmp;           // after 128 chunks, tmp is the answer

One chunk, step by step

Let us walk a single pass of that loop with nothing left out. Follow thread (5, 9), which owns the output at row 5, column 9 of the block's tile of C.

Step 1 — every thread copies one element. Thread (5, 9) issues two loads from global memory: one element of A, one element of B. These are the slow ones, hundreds of cycles each. It writes what comes back into As[5][9] and Bs[5][9]. Every other thread does the same for its own position, so between them the 1024 threads fill both 32 × 32 tiles completely.

Step 2 — the barrier. All 1024 threads meet at __syncthreads(). When the last one arrives, As and Bs are complete and correct.

Step 3 — 32 multiply-adds. Thread (5, 9) now needs the dot product of row 5 of As with column 9 of Bs. It loops d from 0 to 31:

d = 0:   tmp += As[5][0]  * Bs[0][9]
d = 1:   tmp += As[5][1]  * Bs[1][9]
d = 2:   tmp += As[5][2]  * Bs[2][9]
  …
d = 31:  tmp += As[5][31] * Bs[31][9]

Thirty-two reads of As, thirty-two reads of Bs, thirty-two multiply-adds — and every single one of those 64 reads hits shared memory, which is on the SM, not DRAM. That is the whole point of step 1 having happened.

Thread (5,9) reads row 5 of the As tile and column 9 of the Bs tile, and accumulates 32 products into one register
Figure 13. Step 3, drawn. Row 5 of As, column 9 of Bs, 32 multiply-adds into one register. All 1024 threads are doing this simultaneously, each with its own (row, column), which is how the block covers all 1024 outputs of the tile at once.

Step 4 — the second barrier, so that no thread starts overwriting As and Bs while a slower neighbour is still on step 3.

Step 5 — go round again. The block copies in the next 32 columns of A and next 32 rows of B, and thread (5, 9) repeats steps 2 to 4 on the new tile, adding into the same register. Its tmp is never reset and never leaves the thread.

This is the part you put your finger on: row 5 does get used again and again. Thread (5, 9) needs all 4092 values of row 5 of A, and it receives them in 32-wide bites, one per chunk. The window slides right 128 times until the row is exhausted.

Row 5 of A drawn as a long strip, with the 32-wide window that is resident in shared memory sliding right across chunks 0, 1, 2 and 127
Figure 14. The sliding window. Shared memory is small, so it holds 32 columns of row 5 at a time. Chunk by chunk the window advances, and each time it does, thread (5, 9) adds another 32 products into its running total. After 128 slides the whole 4092-length dot product has been accumulated.

Here is that accumulation happening on real numbers — one actual thread, one actual dot product of length 4092, built up 32 terms at a time.

A table showing chunk number, k range, the contribution of each chunk, and the running total, ending at 85,458 which equals the true value of C[5][9]
Figure 15. Thread (5, 9)'s register across all 128 chunks. Chunk 0 covers k = 0…31 and contributes 717; chunk 127 covers the last 28 and contributes 639; the running total ends on 85,458, which is exactly the correct value of C[5][9]. Nothing about the mathematics changed — only the order the terms are added, and where the operands were sitting when they were needed.

And here is why the sharing matters so much within each of those windows. Row 5 of As is not read only by thread (5, 9). It is read by thread (5, 0), thread (5, 1), and every thread along row 5 of the tile — 32 of them, exactly as in kernel 2. The sharing never went away. What changed is where they read it from.

The 32 threads in row 5 of the C tile all reading the same row 5 of the As tile in shared memory
Figure 16. The trade, stated exactly: one slow trip to global memory, then 32 fast reads of on-chip shared memory — instead of 32 slow trips to global memory.

How that turns into FLOPs

Now the accounting, per thread, for the same 32 steps of k, so the two kernels are directly comparable.

Per-thread ledger for 32 k-steps: kernel 2 does 64 global loads and no shared reads; kernel 3 does 2 global loads, 64 shared reads, the same 32 multiply-adds, and waits at 2 barriers
Figure 17. One thread, 32 steps of k, both kernels. The multiply-adds are identical — this is the same matrix multiply. Only the supply chain changed.

In kernel 2, doing 32 multiply-adds required 64 trips to DRAM, and the thread spent nearly all its time waiting for them. In kernel 3 the same 32 multiply-adds require 2 trips to DRAM plus 64 reads of shared memory, which is on-chip and roughly twenty to thirty times quicker to reach, and which does not consume DRAM bandwidth at all.

Since this kernel is limited by how fast operands can be supplied, cutting the DRAM traffic by 32× is what raises the FLOPs number. Across the whole block that is 8,380,416 global loads becoming 262,144 — and 262,144 is essentially the 261,888 distinct values the block genuinely needed, which is to say each value is now fetched once.

What it bought, in numbers. Per chunk the block now issues 2048 global loads and performs 65,536 shared-memory reads against them. Each value fetched from DRAM is therefore read 32 times before being discarded, which is precisely the redundancy we started with, now paid once instead of 32 times.

A ledger comparing kernel 2 and kernel 3 for the same block: global loads, shared memory reads, multiply-adds and how many times each value is fetched
Figure 18. The same block, both kernels. Global traffic falls 32×; the arithmetic is untouched. Across the whole matmul this is the step that takes DRAM traffic from roughly 548 GB toward the 268 MB minimum of Figure 3.

Which makes the result faintly disappointing. 1986 → 2980 GFLOPs: a 32× cut in global memory traffic bought 1.5× in speed. Understanding why is the subject of the next section, and it is the hinge the whole rest of the post turns on.

7Why kernel 3 disappointed: the bottleneck moved

A 32× cut in global memory traffic bought 1.5× in speed. That gap is not a disappointment to be shrugged at; it is information. It tells us that global memory was no longer the thing we were waiting on. Something else had become the limit while we were busy fixing DRAM.

To find it, use the only rule that ever matters here. A kernel's running time is set by whichever resource runs out first, so ask, for each resource in turn, how much of it one multiply-add costs. There are three candidates: DRAM bandwidth, shared-memory service, and instruction issue.

Count the instructions. Here is kernel 3's inner loop as the machine sees it. One line of C++ becomes three machine instructions.

tmp += As[threadRow*32 + d] * Bs[d*32 + threadCol];

// LDS   — load a word from shared memory  (As)
// LDS   — load a word from shared memory  (Bs)
// FFMA  — the multiply-add itself
Three instruction boxes: two red LDS loads and one green FFMA, showing two of every three issued instructions are memory loads
Figure 19. The instruction mix of kernel 3, per unit of arithmetic. Two loads, one multiply-add, plus loop overhead the compiler adds on top.

Now the hardware side, which we already know from part one. An SM has four warp schedulers, and each can issue one instruction per clock. That is the total budget: four instructions per cycle, for everything. If two out of every three instructions a warp issues are shared-memory loads, then at most a third of the SM's issue capacity is going into arithmetic — and the 128 FP32 lanes, which could accept a multiply-add every cycle, sit idle waiting to be given work.

The second limit points the same way. Shared memory is built from 32 banks, each able to serve one 4-byte word per cycle: about 32 words per cycle for the whole SM. At two words per multiply-add, that supply can sustain roughly 16 multiply-adds per cycle. The lanes can do 128. So even ignoring instruction issue entirely, operand supply alone caps this kernel near an eighth of the machine's arithmetic peak. Kernel 3 measures 2,980 GFLOPs against a 30,000 peak, which is a tenth — right up against that wall. 3This is a deliberately simplified model: it ignores broadcasts, bank conflicts and latency hiding. It is not meant to predict performance, only to show which resource the kernel is pressed against.

Boehm's profiler confirms the diagnosis directly. The dominant reason warps are stalled is MIO throttle — warps queueing for the shared-memory pipeline — not anything to do with DRAM, and not the arithmetic units being busy.

the new bottleneck: two loads per multiply shared memory As value Bs value 32 banks · ~32 words per cycle 4 issue slots per cycle 128 FP32 lanes mostly idle starved of operands 2 of every 3 slots are spent on loads the fix is not a faster pipe — it is asking for less load a value ONCE into a register, then use it MANY times kernel 3: 2.00 loads per multiply-add kernels 4 and 5 drive this to 1.125, then 0.25
Figure 20. Where kernel 3 actually spends its time. The data is close by now; what costs is that reaching for it burns an instruction slot every time. Every remaining kernel in this post is, one way or another, an attack on this one ratio.
Aside · the other thing wrong with kernel 3's loop

Look at the loop again: every iteration reads tmp, adds to it, and writes it back. The next iteration cannot start its addition until the previous one has finished, because they use the same register. That is a dependency chain 32 links long, and a multiply-add takes about four cycles to produce its result. One accumulator means the thread can never have more than one multiply-add in flight. Kernel 4 will hand each thread eight independent accumulators, which the scheduler can overlap freely — a second, quieter reason it goes faster than the load ratio alone predicts.

8Kernel 4: 1D blocktiling, 36.5%

We now have a precise target: get below two shared-memory loads per multiply-add. So ask the question that solves it. When can one load serve more than one multiply-add?

Only when two multiply-adds want the same number. So look at the definition of the output and find where operands repeat:

C[i][j]  =  sum over k of  A[i][k] * B[k][j]

Take two outputs in the same column, C[i][j] and C[i+1][j]. At step k the first needs A[i][k]·B[k][j] and the second needs A[i+1][k]·B[k][j]. Two different values of A, but the same value of B. Take two outputs in the same row instead, and it is the other way round: the same A value, two different B values.

A C matrix with a vertical pair and a horizontal pair of outputs highlighted, and the values each pair needs from A and from B
Figure 21. The observation the next two kernels are built on. Outputs stacked vertically share their B operand; outputs side by side share their A operand. A thread that owns only one output can exploit neither.

So give one thread a column of eight outputs. Thread now owns C[i..i+7][j], and at each step of the inner loop it needs eight different values of A and one value of B, which it loads once into a register and reuses eight times.

One thread's eight outputs stacked in a column of the C tile, fed by eight values from As and a single value from Bs held in a register
Figure 22. Where those eight outputs live: one column of the block's tile of C. Because they share a column, they share their B operand at every step.
One value Btmp loaded into a register, multiplied against eight values from As, each product added into a separate running total
Figure 23. One step of kernel 4's inner loop, with real numbers. Nine shared-memory loads go in; eight multiply-adds come out. The B value is fetched once and used eight times, and each product lands in its own accumulator.
float threadResults[8] = {0.0};       // 8 running totals, in registers

for (uint d = 0; d < BK; ++d) {
    float Btmp = Bs[d * BN + threadCol];   // ONE load…
    for (uint i = 0; i < 8; ++i)          // …reused 8 times
        threadResults[i] +=
            As[(threadRow * 8 + i) * BK + d] * Btmp;
}

The count. Eight loads from As, one from Bs, nine in total, producing eight multiply-adds: 1.125 loads per FMA, down from 2.000. The instruction mix flips from two-thirds loads to a bit over half, and the lanes start getting fed.

The block has to be reshaped to match. If every thread now owns eight outputs, a block of the old 1024 threads would cover 8192 outputs, and the shared-memory tile it needs would not fit. Boehm's kernel 4 uses a 64 × 64 tile of C with BK = 8, which works out neatly:

And because the block tile grew from 32 × 32 to 64 × 64, the global traffic improves as well, independently of everything above: reads per output fall from K/16 to K/32. A bigger tile means more reuse of each value fetched from DRAM.

Ledger comparing kernel 3 and kernel 4: block tile, threads per block, outputs per thread, loads per multiply-add, global reads per output, and accumulators
Figure 24. Everything kernel 4 changed. Two separate wins stacked on top of each other: a taller thread tile cut the shared-memory traffic, and a wider block tile cut the DRAM traffic.

Measured: 2,980 → 8,474 GFLOPs, a 2.8× step. The load ratio alone predicts about 1.8×; the rest comes from the halved DRAM traffic and from those eight independent accumulators giving the scheduler something to overlap. And the obvious question now asks itself: if stretching a thread's territory in one direction helped this much, what happens if we stretch it in two?

9Kernel 5: 2D blocktiling, 68.7%

Look back at the sharing we found in section 8. There were two of them: outputs in a column share their B operand, and outputs in a row share their A operand. Kernel 4 exploited one and ignored the other. Its thread owns a tall thin strip, 8 × 1, so it gets column-sharing on B and nothing at all on A — which is exactly why it still pays 8 separate A loads for 8 multiply-adds.

So make the thread's territory square. Give it an 8 × 8 patch of C, and both sharings come into play at once.

A thread's 8 by 8 patch of C, the 8 values it needs from As, one per row, and the 8 values from Bs, one per column
Figure 25. The 8 × 8 patch and everything it needs for one step of k. Eight A values, one per row of the patch. Eight B values, one per column. Sixteen numbers in total — to advance sixty-four outputs.

Count that again, because it is the whole kernel. The patch has 64 outputs. Output (i, j) needs A-value number i and B-value number j. Every one of the 8 A-values pairs with every one of the 8 B-values, and each pairing is one of the 64 outputs. So 16 loads produce 64 multiply-adds.

The 8 × 8 grid of products formed by pairing every element of one vector with every element of another has a name: it is an outer product. Here it is with real numbers.

Eight values from As down the left, eight from Bs across the top, and the 64 products they generate filled into an 8 by 8 grid
Figure 26. Sixteen numbers in registers, sixty-four multiply-adds out. Every value on the left is used eight times, and so is every value on top. This is the picture to keep if you keep only one from this post.

Now the mechanics, in three phases, because the structure of the inner loop matters as much as the count.

Three phases: eight loads into regM, eight loads into regN, then sixty-four multiply-adds with zero memory accesses
Figure 27. One step of kernel 5's inner loop. The thread first copies its operands out of shared memory into its own registers, and only then does the arithmetic. The third phase touches no memory whatsoever.
// phase 1 + 2: stage the operands into registers
for (uint i = 0; i < TM; ++i)
    regM[i] = As[(threadRow * TM + i) * BK + d];
for (uint i = 0; i < TN; ++i)
    regN[i] = Bs[d * BN + threadCol * TN + i];

// phase 3: 64 multiply-adds that touch no memory at all
for (uint m = 0; m < TM; ++m)
    for (uint n = 0; n < TN; ++n)
        threadResults[m * TN + n] += regM[m] * regN[n];

That staging step is what people mean when they say a kernel "caches in registers". Shared memory was a cache we filled by hand for the block; the register file is now a second cache we fill by hand for the thread. Each kernel in this post has added one more level of the hierarchy to the scheme.

The count: 16 loads, 64 multiply-adds, 0.25 loads per FMA. That is 4.5× better than kernel 4 and 8× better than kernel 3. Recall from section 7 that shared memory can supply roughly 32 words per cycle while the lanes can absorb 128 multiply-adds per cycle: at a quarter of a load per FMA, the supply finally matches the demand, and shared memory stops being the thing holding the kernel back.

The block reshapes again, for the same reason as before. With 64 outputs per thread, Boehm's kernel 5 uses a 128 × 128 tile of C and BK = 8:

Ledger comparing kernel 4 and kernel 5 across block tile, thread tile, threads, loads per multiply-add, global reads and accumulators
Figure 28. Kernel 4 against kernel 5. The same two levers as before — a wider block tile for DRAM traffic, a squarer thread tile for shared-memory traffic — pushed as far as the register file will allow.

Stepping back, kernels 3, 4 and 5 are one idea applied three times, and these two charts are the whole story of them.

Two bar charts: shared-memory loads per multiply-add falling from 2.0 to 1.125 to 0.25, and GFLOPs rising from 2980 to 8475 to 15972
Figure 29. Loads per multiply-add against measured speed, for kernels 3, 4 and 5. They are mirror images, which is the point: over this stretch, performance is set almost entirely by how many shared-memory instructions each unit of arithmetic costs.
Left: global memory reads per output falling across kernels. Right: shared memory reads per output falling across kernels
Figure 30. Both budgets, per output element. The block tile (left) decides how much DRAM traffic you pay; the thread tile (right) decides how many shared-memory instructions you pay. Kernels 4 and 5 improve both at once, which is why they are where the performance is.

Measured: 8,474 → 15,972 GFLOPs, and we are past two thirds of cuBLAS with a kernel that is still only about forty lines long. Which raises the obvious question: if a bigger thread tile keeps helping, why stop at 8 × 8? The answer is the subject of the next section.

10Occupancy: the Tetris, revisited

Kernel 5 asks each thread to hold 64 running totals, plus regM and regN, plus its indices. Those 64 accumulators must live in registers for the whole kernel — that is the entire point of them — so the thread needs at least 64 + 8 + 8 = 80 registers, and in practice a little more. Registers are a fixed, shared resource, so this is not free. To see what it costs, we need the packing game from part one, now played with a real kernel.

What occupancy means. An SM on this GPU can host at most 1536 threads, which is 48 warps. Occupancy is simply the fraction of those 48 slots that are actually filled. It matters for the reason part one gave: resident warps are what a scheduler switches to when the current warp stalls, so they are the raw material of latency hiding.

How many blocks fit. An SM accepts blocks until one of three budgets runs out — threads, registers, or shared memory — and whichever runs out first decides. For kernel 3 the numbers are exactly known, and they work out like this:

The smallest wins: one block, which is 1024 threads, which is 32 warps of the 48 available — 66% occupancy. Shared memory was never close to being the constraint; threads and registers were, together.

Left: 48 warp slots with 32 filled. Middle and right: three resource bars each for kernel 3 and kernel 5 showing which budget fills first
Figure 31. The packing game with real numbers. Kernel 3 fills the thread budget and the register budget simultaneously and lands at 66%. Kernel 5, with its 80-odd registers per thread, is stopped by the register file alone and lands lower — around 50%, or 33% if the compiler spends a few more registers.

So here is the uncomfortable fact. Kernel 5 has lower occupancy than kernel 3, and is five times faster. If occupancy were the thing to maximize, that could not happen. So it is not the thing to maximize, and it is worth being precise about why.

Latency hiding can come from two different places. The first is what part one described: many warps, so that when one stalls the scheduler switches to another. Call it thread-level parallelism. The second is many independent instructions inside a single warp, so that while one multiply-add is still in flight the next can already be issued, because it does not depend on the first. Call it instruction-level parallelism.

Kernel 3 has essentially none of the second kind. Its inner loop has one accumulator, and each iteration must wait for the previous one, so its only defence against latency is having lots of warps. Kernel 5 has 64 accumulators, and the 64 multiply-adds in its inner loop are completely independent of one another — the scheduler can keep many of them in flight from a single warp. It buys with ILP what kernel 3 had to buy with warps, and it gets a much better deal, because those same registers also eliminated seven eighths of the shared-memory traffic.

Aside · the practical rule

Occupancy is a means, not an end. What you actually want is for the arithmetic units to never run dry, and warps are only one way to arrange that. Chasing 100% occupancy by shrinking the per-thread tile would hand back the register-level caching that made kernel 5 fast in the first place. The useful question is never "is my occupancy high?" but "is anything starving?" — and section 7 showed you how to find out.

This also answers the question kernel 5 left hanging. Why not a 16 × 16 thread tile, with 256 accumulators? Because 256 registers per thread is beyond the 255 a thread may have at all, and long before that limit the register file would allow so few resident warps that nothing could hide even the shared-memory latency. The 8 × 8 tile sits near the sweet spot of that trade, which is precisely the kind of thing an autotuner is good at finding — as we will see in section 12.

11Kernel 6: vectorized memory access, 78.4%

From here the remaining gains are smaller and more specialist. We are close enough to the arithmetic limit that the wins come from shaving instructions rather than traffic.

A thread that loads four consecutive floats can do it as four 32-bit load instructions, or as one 128-bit load instruction. The bytes are identical; the instruction count is four times lower, and instruction issue is now part of what limits us. CUDA exposes this through the float4 type.

same bytes, a quarter of the instructions four scalar loads LDSLDSLDSLDS 4 instruction slots one vector load LDS.128 1 instruction slot the catch: the four values must be adjacent AND 16-byte aligned so As is stored TRANSPOSED, to make the values a thread wants adjacent 18,237 GFLOPs · 78.4% of cuBLAS
Figure 32. Vectorized access. The subtlety is the transpose: kernel 5 walks down a column of As, and columns are not contiguous. Storing As transposed when it is written into shared memory turns that column walk into a row walk, which is what makes the 128-bit load legal.

12Kernel 9: autotuning, 84.8%

By now the kernel has five free parameters: the block tile BM and BN, the k-chunk BK, and the per-thread tile TM and TN. Every choice trades against the others. Bigger tiles mean more reuse but more registers and more shared memory; more threads mean better latency hiding but a smaller share of the register file each.

Nobody can reason their way to the optimum, because it depends on the specific SM's register file size, its shared memory, its scheduler count, and the matrix shape. So you stop reasoning and start measuring: generate every legal configuration, compile it, run it, keep the winner.

Grid showing which combinations of block tile, k-chunk and thread tile are legal, with thread counts and shared memory per configuration
Figure 33. The search space, filtered by the hardware's own rules. A shape is only worth benchmarking if its thread count is legal, its shared memory fits, and the loads divide evenly among the threads. Boehm compiled and timed about 400 survivors. On the A6000 the winner was BM=BN=128, BK=16, TM=TN=8, worth another 5%; on an A100 a completely different shape won.

The lesson is not "tuning gives 5%". It is that the optimum is a property of the specific chip, not of the algorithm, which is exactly why a portable library cannot simply hard-code one kernel. Hold that thought for cuBLAS.

13Kernel 10: warptiling, 93.7%

Until now our hierarchy has had two levels: the block tile, which maps to an SM, and the thread tile, which maps to a thread's registers. But the first post established that the hardware has a third level in between: the warp, the group of 32 threads that a single warp scheduler issues instructions for. Kernel 10 makes that level explicit in the code.

A 128 by 128 block tile divided into eight 64 by 32 warp tiles, each subdivided into 8 by 8 thread tiles
Figure 34. Three levels, all visible at once. Each of the 8 warps owns a fixed 64 × 32 patch of the block's output, and each thread inside a warp owns 8 × 8 of that. Nothing about the arithmetic changes; what changes is which thread computes which output, and therefore which values sit next to which in the register file and in shared memory.

Why does merely relabelling who-computes-what buy 10%? Three reasons, all of them consequences of the warp being the real unit of execution. A warp now reads a compact, predictable region of shared memory, so its 32 lanes hit different memory banks instead of colliding. The values a warp needs stay resident in registers across more of the inner loop. And because each warp's work is independent of the others', the scheduler has cleaner runs of non-conflicting instructions to issue. 19,721 → 21,779 GFLOPs.

14cuBLAS, and the last 6%

Ten kernels in, we are at 93.7% of a library that has had two decades of engineers on it. The remaining gap is instructive, because it is not one clever trick.

cuBLAS is not one kernel. The library ships hundreds of megabytes of compiled code because it contains many specialised kernels and picks between them at runtime based on the matrix shapes. Our single kernel is tuned for one shape; cuBLAS carries a tuned kernel for practically every shape, which is the autotuning result of section 12 taken to its logical conclusion.

It changes strategy for awkward shapes. When the matrices are small or thin, there is not enough independent work to fill 84 SMs. cuBLAS then splits the K dimension across several blocks, has each compute a partial sum, and adds the partials in a second pass. More total work, better machine utilisation, faster wall clock.

It overlaps the loads with the arithmetic. Our kernels load a tile, sync, compute it, sync, load the next. cuBLAS prefetches: while computing on tile n it is already fetching tile n+1 into other registers, so the arithmetic units rarely stop. This is double buffering, and it is the main structural idea we skipped.

And even cuBLAS, at 23,250 GFLOPs, is only 78% of the chip's 30,000. The rest goes to the things no amount of software removes: the k-loop's own bookkeeping, the tails at the matrix edges, scheduling gaps, and the fact that the fp32 pipeline cannot be perfectly saturated by real code. The one way past it is to stop doing fp32 general arithmetic at all and use the tensor cores, which is a different instruction set and a different post.

15The whole ladder in one view

Line chart of GFLOPs across all ten kernels with three shaded regions: starved by DRAM traffic, starved by SMEM instructions, and limited by arithmetic
Figure 35. The journey, with the three regimes marked. Notice where the boundaries fall: the whole first half of the climb is about not being memory-starved, and only the last four kernels are working against the arithmetic itself.
KernelWhat changedThe mechanism, in one lineGFLOPs
1 Naive—32 scattered sectors per warp: 1056 B for 64 FLOPs309
2 Coalescingswap two index linesone 128 B transaction per warp: 160 B for 64 FLOPs1,986
3 SMEM cachingstage tiles in shared memoryDRAM reads per output: 8184 → 2562,980
4 1D blocktiling8 outputs per threadSMEM loads per FMA: 2.00 → 1.1258,475
5 2D blocktiling8 × 8 outputs per threadSMEM loads per FMA: 1.125 → 0.25 (outer product)15,972
6 Vectorizedfloat4 loads, transposed Asfour load instructions become one18,237
9 Autotuningsearch 400 tile shapesthe optimum is a property of the chip19,721
10 Warptilingan explicit warp-level tilefewer bank conflicts, better register locality21,779
cuBLASmany kernels, prefetching, split-Kthe reference23,250

Reading down the third column, the same idea appears in different clothes each time. Kernel 2 is about the shape of a request. Kernel 3 is about not asking twice. Kernels 4 and 5 are about getting more arithmetic out of each thing you asked for. Kernel 6 is about asking in fewer instructions. Kernels 9 and 10 are about fitting the request to the specific machine. Underneath all of them is the sentence from section 2: the arithmetic never changes, only the cost of feeding it.

If you want to go further, the natural next steps are Boehm's own repository, where every kernel here is a compilable file you can profile yourself, and NVIDIA's CUTLASS, which is what a production-grade version of kernel 10 looks like when generalised to every shape and precision. And if any of the hardware words in this post felt like vocabulary rather than machinery, the first post builds all of them from a photograph of a dog.

Sources & colophon
  1. Simon Boehm, "How to Optimize a CUDA Matmul Kernel for cuBLAS-like Performance: a Worklog", and the accompanying SGEMM_CUDA repository. Every GFLOPs figure, the per-output access counts (K/16, K/32, K/64 and 2K, 9K/8, K/4), the occupancy numbers for kernel 3, and the profiler findings quoted here are his measurements on an NVIDIA A6000.
  2. Stephen Jones, "How CUDA Programming Works", GTC 2022, for the hardware model this post assumes.
  3. NVIDIA, CUDA C++ Programming Guide, for coalescing rules, shared memory limits and vector load requirements.
  4. Colophon: the matrix figures are computed, not drawn. The 8 × 8 example is a real pair of integer matrices multiplied with NumPy, and the tile traces in Figures 9 and 13 are its actual partial products, checked against the full result. The byte counts in Figures 4–6 follow from 32-byte sector granularity and row-major layout; the predicted 6.6× and the measured 6.4× were derived independently. Generation scripts ship alongside the post.