GEMM Worklog I: Naive to Tiled
GEMM Worklog I: Naive to Tiled
General Matrix Multiply (GEMM) is the computational engine of modern artificial intelligence. Fully connected layers, multi-head attention projections, convolutional layers (via im2col), and mixture-of-experts routers all compile down to GEMM:
\[C = \alpha (A \times B) + \beta C\]
where \(A \in \mathbb{R}^{M \times K}\), \(B \in \mathbb{R}^{K \times N}\), and \(C \in \mathbb{R}^{M \times N}\).
In this worklog, we embark on the canonical kernel engineering journey: elevating a naive GEMM achieving less than 1.5% of GPU capacity to a shared-memory tiled kernel achieving over 36% of theoretical peak compute.
1. Dissecting the Naive GEMM: Why is it Slow?
In a naive GEMM implementation, each thread computes a single output element \(C_{i,j}\) by performing a dot product between row \(i\) of matrix \(A\) and column \(j\) of matrix \(B\).
Matrix A (M x K) Matrix B (K x N) Matrix C (M x N)
+-------------------+ +-----+---+-----+ +-----+---+-----+
| | | | | | | | | | | |
|-------------------| (Row i) | | | | | | | | | |
| | x | | | | | = +-----+-v-+-----+
| | | | | | | | |C_ij| |
+-------------------+ +-----+-v-+-----+ +-----+---+-----+
(Col j)
The Naive CUDA Kernel
__global__ void gemm_naive(const float* __restrict__ A,
const float* __restrict__ B,
float* __restrict__ C,
int M, int N, int K) {
// Determine row and column for this thread
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
if (row < M && col < N) {
float sum = 0.0f;
// Compute dot product along dimension K
for (int k = 0; k < K; ++k) {
sum += A[row * K + k] * B[k * N + col];
}
C[row * N + col] = sum;
}
}
Performance Autopsy
Consider running this kernel on an NVIDIA A100 (\(M = N = K = 4096\)): 1. Flop Count: \(2 \times M \times N \times K = 2 \times 4096^3 \approx 137.4 \times 10^9 \text{ FLOPs}\). 2. Global Memory Loads: - For every step of loop \(k\), thread \((i, j)\) fetches \(A_{i,k}\) (4 bytes) and \(B_{k,j}\) (4 bytes) from off-chip DRAM. - Total bytes loaded from DRAM across all threads = \(M \times N \times K \times 2 \times 4 \text{ B} \approx 549.7 \times 10^9 \text{ Bytes}\) (~550 GB). 3. Operational Intensity: \[I_{\text{naive}} = \frac{2 \text{ FLOPs}}{8 \text{ Bytes}} = 0.25 \text{ FLOPs/Byte}\] 4. The Bottleneck: - On the A100, the ridge point is \(I^* = 9.6 \text{ FLOPs/Byte}\). - At \(I = 0.25\), the kernel is trapped deep within the Memory-Bound regime. - Maximum attainable performance: \[P_{\text{naive}} = 2,039 \text{ GB/s} \times 0.25 \text{ FLOPs/Byte} \approx 509.7 \text{ GFLOP/s}\] - Result: \(509.7 \text{ GFLOP/s} / 19,500 \text{ GFLOP/s} = \mathbf{2.6\%}\) of theoretical peak. In reality, uncoalesced memory traffic on matrix \(B\) drags actual performance down to **1.3%** (260 GFLOP/s).
3. The 2D Tiled CUDA Kernel
#include <cuda_runtime.h>
#include <stdio.h>
#define TILE_WIDTH 32
__global__ void gemm_tiled_shared_mem(const float* __restrict__ A,
const float* __restrict__ B,
float* __restrict__ C,
int M, int N, int K) {
// 2D shared memory cache tiles
__shared__ float s_A[TILE_WIDTH][TILE_WIDTH];
__shared__ float s_B[TILE_WIDTH][TILE_WIDTH];
int tx = threadIdx.x;
int ty = threadIdx.y;
// Output coordinate in matrix C
int row = blockIdx.y * TILE_WIDTH + ty;
int col = blockIdx.x * TILE_WIDTH + tx;
float acc = 0.0f;
// Number of phase tiles needed along dimension K
int num_tiles = (K + TILE_WIDTH - 1) / TILE_WIDTH;
for (int t = 0; t < num_tiles; ++t) {
// 1. Collaborative loading of Tile A into shared memory
int a_col = t * TILE_WIDTH + tx;
if (row < M && a_col < K) {
s_A[ty][tx] = A[row * K + a_col];
} else {
s_A[ty][tx] = 0.0f;
}
// 2. Collaborative loading of Tile B into shared memory
int b_row = t * TILE_WIDTH + ty;
if (b_row < K && col < N) {
s_B[ty][tx] = B[b_row * N + col];
} else {
s_B[ty][tx] = 0.0f;
}
// 3. Barrier synchronization: ensure entire tile is present in SRAM
__syncthreads();
// 4. Accumulate partial dot product from shared memory
#pragma unroll
for (int k = 0; k < TILE_WIDTH; ++k) {
acc += s_A[ty][k] * s_B[k][tx];
}
// 5. Barrier synchronization: ensure computation finishes before next tile load
__syncthreads();
}
// Write final accumulated result to global memory
if (row < M && col < N) {
C[row * N + col] = acc;
}
}
4. Benchmark Results on NVIDIA A100
Evaluating on square FP32 matrices (\(M = N = K = 4096\)):
Performance (TFLOP/s)
^
| cuBLAS Peak (~18.2 TFLOP/s)
| +-----------------------------
|
| Tiled GEMM (~7.1 TFLOP/s)
| +-----------+ (36.5% of peak)
|
| Naive GEMM (~0.26 TFLOP/s)
| + (1.3% of peak)
+--+-------------------+-----------+--------------------------------->
Naive Shared Tiled cuBLAS
| Implementation | Execution Time (\(M=N=K=4096\)) | Measured TFLOP/s | % of Theoretical Peak Compute |
|---|---|---|---|
| Naive GEMM | 528.4 ms | 0.26 TFLOP/s | 1.3% |
| 2D Tiled Shared Memory GEMM | 19.3 ms | 7.12 TFLOP/s | 36.5% |
| NVIDIA cuBLAS (Closed Source) | 7.5 ms | 18.32 TFLOP/s | 93.9% |
Why Did Performance Jump by 27x?
- Global Memory Bandwidth Slashed: Memory traffic dropped from 550 GB to ~17 GB.
- Coalesced Reads: Both \(A\) and \(B\) tiles are fetched using contiguous thread indexing (
tx). - Latency Hiding: Staged data in shared memory is accessed in ~20 cycles instead of ~400 cycles.
In the next chapter, we push further into the micro-architecture: register micro-tiling, float4 vectorization, and warp-level hierarchical tiling to close the gap to cuBLAS.