Tiling Strategies
Breaking large problems into shared-memory-sized tiles β the fundamental technique behind fast GEMM, attention, and every high-performance GPU kernel.
Tiling is the single most important optimization pattern in GPU programming. Every fast kernel β GEMM, FlashAttention, reduction, convolution β uses tiling to move data from slow HBM into fast shared memory and maximize data reuse. This chapter covers the principles, patterns, and tradeoffs.
1. The Core Idea
Without tiling: every operation reads from HBM (~600 cycle latency). With tiling: load a chunk into shared memory (~30 cycle latency), reuse it many times, then load the next chunk.
Without tiling: With tiling:
HBM β Compute β HBM HBM β Shared Mem β Compute (reuse N times)
HBM β Compute β HBM β Compute (reuse)
HBM β Compute β HBM β Compute (reuse)
... HBM β Shared Mem β Compute (reuse N times)
...
The math: If each data element is used times, tiling reduces HBM reads by . For GEMM with tile size : , so a 32Γ32 tile gives 32Γ fewer HBM reads.
2. Tiling for Matrix Multiplication
The Problem
Naive GEMM: each output element reads one row of and one column of from HBM:
// Naive: O(M*N*K) HBM reads
C[i][j] = 0;
for (int k = 0; k < K; k++)
C[i][j] += A[i][k] * B[k][j]; // each A[i][k] and B[k][j] from HBM
For : each element reads 4096 floats from A and 4096 from B. Total: billion bytes from HBM. At 3.35 TB/s, thatβs 41 ms β far slower than the 3.4 ms achievable with tiling.
The Tiled Solution
- Divide output matrix into tiles of size
- Each thread block computes one output tile
- Loop over K in steps of :
a. Cooperatively load a tile of into shared memory
b. Cooperatively load a tile of into shared memory
c.
__syncthreads()β wait for all loads d. Compute partial dot products using shared memory data e.__syncthreads()β wait before loading next tile
__shared__ float As[TILE_M][TILE_K];
__shared__ float Bs[TILE_K][TILE_N];
float acc = 0.0f;
for (int t = 0; t < K; t += TILE_K) {
// Cooperative load: each thread loads one or more elements
As[threadIdx.y][threadIdx.x] = A[row][t + threadIdx.x];
Bs[threadIdx.y][threadIdx.x] = B[t + threadIdx.y][col];
__syncthreads();
// Compute: all data in fast shared memory
for (int k = 0; k < TILE_K; k++) {
acc += As[threadIdx.y][k] * Bs[k][threadIdx.x];
}
__syncthreads();
}
C[row][col] = acc;
Data Reuse Analysis
With tile size :
- Each element of βs tile is used by threads (all threads in the same row of the output tile)
- Each element of βs tile is used by threads
- Reuse factor: reads from shared memory instead of HBM
For : HBM reads reduce by 32Γ. For : 128Γ reduction.
3. Double Buffering: Overlapping Load and Compute
Naive tiling has a stall: after loading a tile and computing, all threads wait for the next load. Double buffering overlaps loading the next tile with computing the current tile:
__shared__ float As[2][TILE_M][TILE_K]; // two buffers
__shared__ float Bs[2][TILE_K][TILE_N];
int curr = 0;
// Load first tile
load_tile(As[curr], Bs[curr], t=0);
__syncthreads();
for (int t = TILE_K; t < K; t += TILE_K) {
int next = 1 - curr;
// Start loading NEXT tile while computing CURRENT tile
load_tile_async(As[next], Bs[next], t); // non-blocking
// Compute with current tile (in shared memory)
compute(As[curr], Bs[curr], acc);
__syncthreads(); // wait for both load and compute
curr = next;
}
// Process last tile
compute(As[curr], Bs[curr], acc);
Benefit: Load latency (~600 cycles) is hidden behind compute. For compute-heavy tiles, this gives near-zero load overhead.
4. Tile Size Tradeoffs
| Larger tiles | Smaller tiles |
|---|---|
| β More data reuse β fewer HBM reads | β Less shared memory β higher occupancy |
| β Better arithmetic intensity | β Less register pressure |
| β More shared memory β fewer blocks per SM | β More HBM reads |
| β More registers for accumulators | β Lower arithmetic intensity |
| β Tail effects if matrix not divisible | β Better for small/irregular matrices |
Typical Tile Sizes in Practice
| Kernel | Tile size | Why |
|---|---|---|
| Simple GEMM | 32Γ32 | Fits in shared memory, good starting point |
| Production GEMM (cuBLAS) | 128Γ128 or 256Γ128 | Maximizes reuse, uses register tiling |
| FlashAttention | 64-128 per block | Balances reuse with SRAM capacity |
| Reduction | Block size (256-1024) | One tile per block |
Register Tiling
Production kernels like cuBLAS use a two-level tiling strategy:
- Thread block tile: loaded into shared memory (128Γ128)
- Per-thread tile: each thread accumulates a small sub-tile in registers (8Γ8)
This maximizes reuse at both levels β shared memory reuse across threads, register reuse within a thread.
5. Tiling Beyond GEMM
The tiling pattern applies everywhere:
| Kernel | Whatβs tiled | Whatβs reused |
|---|---|---|
| GEMM | A and B matrices along K dimension | Each tile element used by many output elements |
| FlashAttention | Q, K, V blocks along sequence | K/V blocks reused across Q rows |
| Convolution | Input patches and filter weights | Filter weights reused across spatial positions |
| Reduction | Input array chunks | Partial sums accumulated in shared memory |
| Softmax | Input chunks for max/sum | Two-pass (max, then normalize) on same data |
The universal principle: If data is read from HBM more than once, it should be tiled into shared memory.
Self-Check
- Explain why tiling reduces HBM reads and by how much (reuse factor)
- Implement tiled matrix multiplication with shared memory
- Explain double buffering and how it hides load latency
- Analyze the tile size tradeoff (reuse vs occupancy)
- Explain two-level tiling (shared memory + register)
- Identify tiling opportunities in a given kernel