Tiled matrix multiplication
Matrix multiplication is the operation a GPU is built for. It is also the easiest one to write badly. In a well-known worklog, Simon Boehm (2022) wrote the obvious CUDA matmul, one thread per output element, and measured it on an A6000 at 1.3 percent of the speed of NVIDIA's cuBLAS library on the same float32 problem. Fixing the memory access pattern took it to 8.5 percent, caching tiles in shared memory to 12.8, having each thread compute a short column of outputs to 36.5, a small square tile of outputs to 69, and a series of further refinements to 94.
The arithmetic was the same in every version, FLOPs. What changed was how many times each number was fetched from memory. Each output element is a reduction of the kind §11.4 studied, the dot product of a row of the first matrix with a column of the second, so a matmul is 16.7 million reductions running at once. Unlike the sums of §11.4, they overlap almost completely in their inputs, since every row of the first matrix feeds a whole row of outputs and every column of the second a whole column. Everything in this section is about making those reductions share what they load, and its measure of success is the number of loads, which we derive at every step. We start with the naive kernel and its traffic, derive exactly how much tiling with shared memory saves, push the same idea down to registers, prove that no schedule can do fundamentally better, and end with the tensor-core instructions that real kernels are built from. A NumPy simulation counts every read along the way, and the exercises ask you to write it yourself, a common interview problem.
Throughout, with , , and , all row-major, with bytes per element. (These are the standard names in GEMM libraries, so in this section is a matrix operand, not attention weights, and is the inner dimension, not a number of classes.)
The naive kernel and its traffic
The obvious kernel gives each thread one element and lets it compute a dot product of length .
// One thread per element of C. A is M x K, B is K x N, C is M x N, all row-major float32.
// Launch: dim3 block(32, 8); dim3 grid((N + 31) / 32, (M + 7) / 8);
__global__ void matmul_naive(const float* A, const float* B, float* C, int M, int N, int K) {
int col = blockIdx.x * blockDim.x + threadIdx.x;
int row = blockIdx.y * blockDim.y + threadIdx.y;
if (row < M && col < N) {
float acc = 0.0f;
for (int k = 0; k < K; ++k)
acc += A[row * K + k] * B[k * N + col]; // two global loads per multiply-add
C[row * N + col] = acc;
}
}
The mapping already follows §11.3. x is the column, so the 32 threads of a warp read 32 consecutive elements of a row of (coalesced) and the same element of (a broadcast). Map x to rows instead and the loads of become strided by , which is most of the difference between Boehm's first two kernels.
Now count. Each thread loads elements of and of , so the kernel issues
against the elements that actually exist. Every element of is loaded times, once by each thread in its row of , and every element of is loaded times. The arithmetic intensity, if every load went to HBM, would be , a quarter of a FLOP per byte in float32, the bottom of the roofline (§11.2). For in float32 that is about 550 GB of loads for 134 MB of distinct input data.
The caches soften this in practice, since a warp's neighbors often want the same row of and L1 and L2 catch some of the repeats. But caches are small and shared by thousands of threads, and the kernel gives them no help. The fix is to make the reuse explicit.
Tiling with shared memory
Picture the output divided into tiles, one per thread block, and one thread per output element. Tile of needs rows of (a strip) and columns of (a strip). Cut both strips into tiles along . Then
a sum of products of small tiles. The block computes it in phases. In each phase, its threads cooperatively load one tile of and one tile of into shared memory, one element each, wait at a barrier, then each thread does multiply-adds reading only shared memory, and waits again before the next phase overwrites the tiles.
In plain words, a block brings two small tiles on chip, squeezes uses out of every element, and only then fetches the next two.
Click a tile of in the widget and step through its phases to see which tiles of and each phase loads, and watch the global traffic counter against the naive kernel's.
#define TILE 32
// Launch: dim3 block(TILE, TILE); dim3 grid((N + TILE - 1) / TILE, (M + TILE - 1) / TILE);
__global__ void matmul_tiled(const float* A, const float* B, float* C, int M, int N, int K) {
__shared__ float As[TILE][TILE];
__shared__ float Bs[TILE][TILE];
int tx = threadIdx.x, ty = threadIdx.y;
int row = blockIdx.y * TILE + ty, col = blockIdx.x * TILE + tx;
float acc = 0.0f;
for (int p = 0; p < (K + TILE - 1) / TILE; ++p) {
int ka = p * TILE + tx, kb = p * TILE + ty;
As[ty][tx] = (row < M && ka < K) ? A[row * K + ka] : 0.0f; // coalesced, zero-padded edges
Bs[ty][tx] = (kb < K && col < N) ? B[kb * N + col] : 0.0f;
__syncthreads(); // tiles are complete
for (int k = 0; k < TILE; ++k)
acc += As[ty][k] * Bs[k][tx]; // reads only shared memory
__syncthreads(); // done before overwriting
}
if (row < M && col < N) C[row * N + col] = acc;
}
Both barriers are needed. The first guarantees the tiles are complete before anyone reads them, and the second that everyone has finished reading before anyone overwrites them. In the inner loop, As[ty][k] is the same word for every thread of a warp (a broadcast) and Bs[k][tx] is a row read across 32 banks, so there are no bank conflicts.
How much traffic tiling saves
Count the global loads. Each phase loads elements, there are phases per block, and blocks, so
In plain words, tiling divides global memory traffic by the tile size . Each element brought on chip is used by threads instead of one.
The same count works for rectangular tiles of outputs with an inner tile length . Each phase loads elements of and of , there are phases and blocks, and cancels,
The first term is the traffic for , read once per column of blocks, and the second for , read once per row of blocks. Only the output tile's shape matters, and square tiles are best for a given area. The shared memory each block needs is , which is why is kept small (32 or 64) while and grow to 128 or 256.
Put numbers on it, because the result is surprising. With output tiles in bfloat16, FLOPs per byte, and even tiles give only 85, both well below the H100's ridge point of 295. If every tile load went to HBM, a large GEMM would be memory-bound. It is not, because the blocks running at the same time share panels of and , and most of their tile loads hit the 50 MB L2 cache, so the true HBM traffic sits much closer to the minimum . That is why the order in which blocks are launched matters (the grouped ordering of §11.6), and why Hopper added thread block clusters, in which the TMA can multicast one tile into the shared memory of several blocks at once, cutting L2 traffic as well.
When the dimensions are not multiples of the tile, the edge tiles are partial. A careful kernel does not load out-of-range elements (our CUDA kernel writes zeros instead), so the exact count is that each of the columns of blocks reads all of , and each of the rows of blocks reads all of ,
Global traffic of naive and tiled matmul
Implement matmul_traffic(M, N, K, b) returning a dictionary of global element reads (not bytes) with keys "naive" (one thread per output, every operand from global memory), "tiled" (square output tiles that read only in-bounds elements, as derived above), and "minimum" (each input element read once). Use exact integer arithmetic.
A tiled matmul, simulated
The simulation follows the kernel's loop structure exactly. For each output tile and each phase it copies the in-bounds parts of one tile of and one of into "shared memory", counts those reads, and accumulates their product, counting the shared reads of the inner loop (each output element reads one row of the tile and one column of the tile).
The global reads fall by exactly the tile size (with , is not an integer, so the edge tiles make the factor a little less than 32), while the shared reads stay at for every . Tiling moved the traffic from HBM to shared memory. It did not remove it.
A tiled matmul that reports its traffic
Write tiled_matmul(A, B, b) that computes with explicit loops over output tiles and over phases along , as a thread block would, and returns (C, stats) with stats["global_reads"], the number of elements copied from A and B into tile buffers (in-bounds elements only), and stats["shared_reads"], the number of element reads from the tile buffers by the inner products (two per multiply-add). Inside a phase you may multiply the two small tile buffers with @. It must work for any shapes, including ones that are not multiples of .
Register tiling
Tiling made HBM traffic manageable, but look at the new bottleneck. The inner loop does one multiply-add per two shared memory reads. On an H100, shared memory delivers 128 bytes per cycle per SM (§11.1), 32 float32 values, enough for 16 multiply-adds per cycle. The SM's 128 float32 lanes can do 128. So the shared-tiled kernel runs at most at about one eighth of the float32 peak, which is why Boehm's shared memory version was still far below cuBLAS.
The fix is the same idea one level down. Give each thread a small tile of outputs, held in registers. At each step of the inner loop, the thread loads values from a column of the tile and from a row of the tile into registers, and performs all multiply-adds of their outer product.
// Inner loop of a register-tiled kernel. Each thread owns an 8 x 8 accumulator tile.
float acc[8][8] = {0.0f};
float a_reg[8], b_reg[8];
for (int k = 0; k < BK; ++k) {
for (int i = 0; i < 8; ++i) a_reg[i] = As[k][thread_row * 8 + i]; // As stored transposed (BK x BM)
for (int j = 0; j < 8; ++j) b_reg[j] = Bs[k][thread_col * 8 + j];
for (int i = 0; i < 8; ++i)
for (int j = 0; j < 8; ++j)
acc[i][j] += a_reg[i] * b_reg[j]; // 64 FMAs from 16 loads
}
Shared memory reads per multiply-add fall from 2 to , which for is , an eightfold reduction that exactly closes the gap above. The price is registers. The accumulators alone take per thread, plus 16 for the operands and some for addresses, so such kernels use well over 100 registers per thread and run at low occupancy, by design (the pitfall in §11.1). This is why the occupancy calculator is a guide and not a goal.
In plain words, reuse happens at every level. A block reuses each element it loads from HBM times, and a thread reuses each value it loads from shared memory times (for ) or times (for ).
| level | data held | typical size | reuse per element brought in |
|---|---|---|---|
| block tile in shared memory | of , of | and | for , for |
| warp tile | a piece of the block's output | ||
| thread tile in registers | accumulators | for , for | |
| tensor core instruction | a product (below) |
The table hides an exact count worth deriving once. With block tiles and register tiles, a block has threads, and at each of the steps of the inner loop each thread loads values of and of from shared memory, so the whole matmul reads elements from shared memory, while the global reads stay at . In plain words, the block tile divides HBM traffic by , and the register tile divides shared-memory traffic by , independently.
A two-level tiled matmul
Extend Exercise 11.5b to two levels. Write two_level_matmul(A, B, b, t) that computes with block tiles of the output, phases of width along , and, inside each block tile, register tiles owned by one thread (with dividing ). Each thread, at each step of a phase, loads its slice of column of the tile and its slice of row of the tile into registers and adds their outer product to its accumulators. Return (C, stats) with stats["global_reads"] (elements copied from A and B into the block's tile buffers, in-bounds only, as in 11.5b) and stats["shared_reads"] (elements loaded from the tile buffers into registers). At ragged edges a thread's register tile is clipped to the matrix, and it loads only the rows and columns that exist.
No schedule can do much better
Is tiling just a good trick, or is it optimal? Hong and Kung (1981) answered this for the classical algorithm. With a fast memory that holds elements, any order of computing the products must move elements between fast and slow memory. This is a theorem.
Tiling meets the bound. Three tiles fit in fast memory when , so , and the traffic is within a constant factor of the lower bound. The square-root dependence is why bigger on-chip memories matter so much. Doubling shared memory cuts the minimum traffic of every matmul by only , but that is still a 30 percent cut. FlashAttention's IO analysis has the same structure (§12.1).
∂ The lower bound, through the Loomis-Whitney inequality
We follow the argument of Irony, Toledo & Tiskin (2004), which makes Hong and Kung's bound explicit. The computation consists of the multiply-adds , one for each triple . Split the execution into consecutive segments that each contain exactly transfers between fast and slow memory (the last segment may contain fewer).
How many products can one segment do? During a segment, the elements of it can use are those in fast memory when the segment starts (at most ) or loaded during it (at most ), so at most . The same holds for . Each partial sum of that the segment touches must survive it, either by staying in fast memory at the end (at most of them) or by being written out during the segment (at most ), so at most of them are involved.
Counting triples by their shadows. Let be the set of triples the segment performs. Its projections onto the , , and planes are the elements of , , and it used. The Loomis-Whitney inequality says a finite set of lattice points in three dimensions is at most the square root of the product of the sizes of its three projections,
Adding up. All triples must be performed, so there are at least segments, and all but the last contain transfers. The total traffic is therefore
For large matrices the is negligible, and . In plain words, fast memory of size lets each transferred element take part in at most about products, so the products force the traffic. Note that the bound is for the classical algorithm. Strassen-like algorithms do fewer multiplications and have a different bound.
Tensor cores and MMA shapes
On a modern GPU the innermost level is not a thread doing FMAs. It is a warp issuing matrix multiply-accumulate (MMA) instructions to the tensor cores of §11.1. The instruction names its shape as , a product of an tile by a tile added to an accumulator.
- Ampere and later, warp-level
mma.sync.aligned.m16n8k16for bfloat16 or float16 inputs with float32 accumulation. One warp computes a output from a tile of and a tile of . The operands are spread across the warp's registers in a fixed layout, so that each of the 32 threads holds 8 elements of the tile, 4 of the tile, and 4 of the accumulator. - Hopper, warpgroup-level
wgmma.mma_async. Four warps (a warpgroup of 128 threads) issue one instruction of shape for 16-bit inputs, with any multiple of 8 up to 256, and for FP8. The operand is read directly from shared memory, from registers or shared memory, and the instruction is asynchronous, so the warps can issue loads for the next tile while the tensor cores work.
So a fast GEMM is a hierarchy of tiles. The grid tiles into block tiles, a block's warps split its tile into warp tiles, and each warp tile is a grid of MMA instructions. The simplest way to use tensor cores from CUDA C++ is the WMMA API, which hides the register layout behind opaque fragments. The kernel below gives each warp one output tile. It is correct but naive (operands come straight from global memory), and real kernels add every trick of this section around the same instructions.
#include <mma.h>
#include <cuda_fp16.h>
using namespace nvcuda;
// C (M x N, float) = A (M x K, half) * B (K x N, half), all row-major, M, N, K multiples of 16.
// One warp per 16 x 16 tile of C. Launch with blockDim.x a multiple of 32.
__global__ void wmma_gemm(const half* A, const half* B, float* C, int M, int N, int K) {
int warp = (blockIdx.x * blockDim.x + threadIdx.x) / 32;
int tiles_n = N / 16;
int tm = warp / tiles_n, tn = warp % tiles_n;
if (tm >= M / 16) return; // the whole warp leaves together
wmma::fragment<wmma::matrix_a, 16, 16, 16, half, wmma::row_major> a;
wmma::fragment<wmma::matrix_b, 16, 16, 16, half, wmma::row_major> b;
wmma::fragment<wmma::accumulator, 16, 16, 16, float> acc;
wmma::fill_fragment(acc, 0.0f);
for (int k = 0; k < K; k += 16) {
wmma::load_matrix_sync(a, A + (tm * 16) * K + k, K); // leading dimension K
wmma::load_matrix_sync(b, B + k * N + tn * 16, N); // leading dimension N
wmma::mma_sync(acc, a, b, acc); // acc += a @ b on the tensor cores
}
wmma::store_matrix_sync(C + (tm * 16) * N + tn * 16, acc, N, wmma::mem_row_major);
}
The cell decomposes a matmul into MMA instructions exactly as the hardware would, padding the edges with zeros, and checks the result. It also shows why the alignment advice of §11.1 is not a superstition. Every dimension is rounded up to the instruction shape, so a dimension of 17 costs as much as 32.
Count tensor core instructions
Implement mma_count(M, N, K, m=16, n=8, k=16) returning (instructions, utilization), the number of MMA instructions needed when each dimension is padded up to a multiple of the instruction shape, and the fraction of the instructions' multiply-adds that are real, divided by instructions * m * n * k.
The rest of the road to cuBLAS
Tiling at three levels is the core of every fast GEMM. The remaining distance is a list of refinements, each fixing one specific inefficiency, and knowing which fixes what is what interviewers probe.
| technique | what it fixes |
|---|---|
coalesced global loads, x on columns | wasted sectors (§11.3) |
| shared memory tiles, | HBM traffic, divided by the tile size |
| register tiles, per thread | shared memory bandwidth, divided by the register tile size |
vectorized 16-byte loads (float4) | instruction count of loads |
| swizzled or padded shared layouts | bank conflicts on tile reads |
double buffering, multi-stage pipelines (cp.async, TMA) | the load latency of each phase, by overlapping the next tile's load with this tile's math |
| warp tiling and tensor core MMA | the FP32 lanes' 15 times lower throughput |
| split-K, stream-K, persistent kernels | wave quantization and too few tiles for skinny shapes |
| grouped (swizzled) block order | L2 hit rate between blocks that share panels (§11.6) |
| epilogue fusion (bias, activation, cast) | an extra round trip of through HBM (§11.7) |
Split-K deserves a sentence, because it is §11.4 again. When and give too few output tiles to fill the GPU but is long, several blocks each sum one slice of for the same output tile, and their partial tiles are combined by a second pass or by atomic adds, with the same trade between an extra launch and nondeterministic rounding as any cross-block reduction.
Libraries such as cuBLAS and CUTLASS combine all of these, with tile shapes chosen per GPU and per problem size, and newer layers such as ThunderKittens, CuTe, and Triton make the same structure easier to write. You will almost never need to write a production GEMM yourself. You will constantly need to reason about one, to predict whether a shape is efficient, to fuse an epilogue, or to write an attention kernel that has exactly this structure inside.
In PyTorch
PyTorch's @ calls cuBLAS on a GPU and a tuned CPU library otherwise. The block checks that the blocked algorithm is the same computation as the library call, then times a tiled loop in PyTorch against one library call, a reminder that tiling only pays when the tile loop runs on the hardware, not in Python.
Read more
- Boehm (2022). How to Optimize a CUDA Matmul Kernel for cuBLAS-like Performance: a Worklog. Ten kernels from naive to near cuBLAS, each measured and explained, the best single reading on this topic.
- Kirk, Hwu & El Hajj (2022). Programming Massively Parallel Processors, 4th edition. Chapter 5 develops the shared memory tiled matmul and its traffic analysis.
- Hong & Kung (1981). I/O Complexity: The Red-Blue Pebble Game. The original lower bound on data movement for matrix multiplication and other computations.
- Irony, Toledo & Tiskin (2004). Communication lower bounds for distributed-memory matrix multiplication. The segment and Loomis-Whitney argument we used for the lower bound.
- NVIDIA. Matrix Multiplication Background User's Guide. Tile and wave quantization, tensor core alignment requirements, and how shapes map onto the hardware.
- NVIDIA. CUTLASS. The open-source template library that implements the full tiling hierarchy, with documentation of each level.
- NVIDIA. Parallel Thread Execution ISA, warp-level matrix instructions. The exact MMA shapes, operand layouts, and the Hopper
wgmmainstructions.