The same multiplication, two performance worlds
GEMM (C = A·B) is where 'just throw threads at it' stops working. The naive version assigns one thread to one output element and loops over the K dimension. The tiled version cooperates within a block to load chunks of A and B into shared memory, then reuses each chunk many times before the next DRAM trip. Same answer, dramatically different speed.
Naive GEMM — feel the pain
Each thread computes one C[m][n]:
for k in 0..K: C[m][n] += A[m][k] * B[k][n]Every FMA requires two global-memory loads (one from A, one from B). No reuse, no shared memory. The kernel is bandwidth-bound and runs at ~5% of peak FLOPs.
Tiled GEMM — keep hot data in shared memory
Per output tile, threads in a block cooperatively load A-tile and B-tile into shared memory. Each thread then computes its partial output by reading from shared memory — many cheap reads per one global load. Inner loop:
- Load next A-tile and B-tile (global → shared).
__syncthreads()to ensure all threads see the loaded data.- Multiply-accumulate into a register:
Csub += As[ty][k] * Bs[k][tx]. - Move to next K-slice; repeat.
Numbers from RTX 4090 (FP32, non-Tensor-Core path):
| Kernel | Time (ms) | TFLOP/s | Comment |
|---|---|---|---|
| gemm-naive (512³) | 0.65 | 0.41 | One DRAM read per FMA |
| gemm-tiled (2048³) | 4.50 | 3.8 | Shared-mem reuse, register blocking |
| cuBLAS sgemm | ~3.9 | ~7.0 | Multi-stage tiling, Tensor Cores via cublasGemmEx |
The 9× speedup from naive to tiled is purely about data reuse. cuBLAS adds another ~2× by using Tensor Cores and multi-stage software pipelining (Track 8).