AnyLearn
All lessons
Programmingintermediate

Shared Memory Tiling for Matrix Multiplication

Why naive matmul on a GPU is bandwidth-starved, and how tiling with __shared__ memory reduces global memory traffic by a factor of the tile size. The classic optimisation, with the kernel that demonstrates it.

Not signed in — your progress and quiz score won't be saved.
Lesson progress1 / 9

The matmul we're optimising

Multiply C=A×BC = A \times B where AA is M×KM \times K, BB is K×NK \times N, CC is M×NM \times N. Each output element is a dot product:

Cij=k=0K1AikBkjC_{ij} = \sum_{k=0}^{K-1} A_{ik} \cdot B_{kj}

For a square N×NN \times N case: N2N^2 outputs, each costing NN multiply-adds. 2N32N^3 FLOPs total, but only 3N23N^2 bytes of input/output. The arithmetic intensity grows linearly with NN — matmul is the textbook compute-dense workload, if you don't blow the memory hierarchy. Naive matmul does blow it. That's what tiling fixes.

Full lesson text

All 9 steps on one page — for reading, reference, and search.

Show

1. The matmul we're optimising

Multiply C=A×BC = A \times B where AA is M×KM \times K, BB is K×NK \times N, CC is M×NM \times N. Each output element is a dot product:

Cij=k=0K1AikBkjC_{ij} = \sum_{k=0}^{K-1} A_{ik} \cdot B_{kj}

For a square N×NN \times N case: N2N^2 outputs, each costing NN multiply-adds. 2N32N^3 FLOPs total, but only 3N23N^2 bytes of input/output. The arithmetic intensity grows linearly with NN — matmul is the textbook compute-dense workload, if you don't blow the memory hierarchy. Naive matmul does blow it. That's what tiling fixes.

2. Naive kernel: one thread per output

The obvious mapping: each thread computes one element of CC. It reads a full row of AA and a full column of BB from global memory.

__global__ void matmulNaive(const float* A, const float* B, float* C, int N) {
  int row = blockIdx.y * blockDim.y + threadIdx.y;
  int col = blockIdx.x * blockDim.x + threadIdx.x;
  if (row >= N || col >= N) return;
  float acc = 0.0f;
  for (int k = 0; k < N; ++k) {
    acc += A[row * N + k] * B[k * N + col];
  }
  C[row * N + col] = acc;
}

It's correct. It's also painfully slow. Let's see why.

3. Counting the global loads

Each thread does 2N2N global loads (NN from A, NN from B) to produce one output. Across all N2N^2 threads that's 2N32N^3 global loads.

But every element of AA is genuinely needed only NN times (once per column of BB). Every element of BB is needed NN times (once per row of AA). The minimum number of distinct loads is 2N22N^2.

So the naive kernel reads each input value NN times from global memory. For N=1024N = 1024 that's a 1024× overcount. The L2 cache absorbs some of it but not enough. The kernel ends up memory-bound on a chip that could be doing 80 TFLOPS.

4. The tiling idea

Break CC into blocks of TILE×TILE\text{TILE} \times \text{TILE} outputs (say, 32×32). Each block of threads computes one tile of CC.

Observation: to compute a TILE×TILE region of CC, you only need strips of AA and BB of width TILE. The threads in the block all need the same strips. So:

  1. Have the block cooperatively load a TILE×TILE slab of AA and a TILE×TILE slab of BB into shared memory — once.
  2. Now every thread can do its part of the dot product reading from shared memory, not global.
  3. Slide along KK, loading the next pair of slabs, accumulating.

Each element of AA and BB now gets loaded from global memory TILE times fewer than the naive version.

5. Block computing a tile of C, walking along K

One block produces a TILE-by-TILE chunk of C by loading paired slabs.

flowchart LR
  GA["A in global memory"] --> SA["Shared tile of A (TILE x TILE)"]
  GB["B in global memory"] --> SB["Shared tile of B (TILE x TILE)"]
  SA --> DOT["Each thread: partial dot product"]
  SB --> DOT
  DOT --> ACC["Per-thread accumulator in register"]
  ACC --> NEXT["Slide next pair of slabs"]
  NEXT --> GA
  ACC --> OUT["Write C tile to global"]

6. The tiled kernel

#define TILE 32

__global__ void matmulTiled(const float* A, const float* B, float* C, int N) {
  __shared__ float As[TILE][TILE];
  __shared__ float Bs[TILE][TILE];

  int row = blockIdx.y * TILE + threadIdx.y;
  int col = blockIdx.x * TILE + threadIdx.x;
  float acc = 0.0f;

  for (int t = 0; t < N / TILE; ++t) {
    // Each thread loads one element of each slab
    As[threadIdx.y][threadIdx.x] = A[row * N + (t * TILE + threadIdx.x)];
    Bs[threadIdx.y][threadIdx.x] = B[(t * TILE + threadIdx.y) * N + col];
    __syncthreads();          // wait for all loads

    for (int k = 0; k < TILE; ++k) {
      acc += As[threadIdx.y][k] * Bs[k][threadIdx.x];
    }
    __syncthreads();          // wait before overwriting tiles
  }
  C[row * N + col] = acc;
}

Launch with dim3 block(TILE, TILE); dim3 grid(N/TILE, N/TILE);. Add boundary guards if NN isn't a multiple of TILE.

7. Why __syncthreads matters here

Two __syncthreads() calls bookend the work in each iteration of the outer loop. They're load-bearing:

  • First barrier — after all threads load their elements into As and Bs, before any thread reads from them. Without it, some threads would read stale or uninitialised tile entries while others were still writing.
  • Second barrier — after every thread finishes its KK-loop on the current slab, before any thread overwrites As/Bs with the next slab. Without it, fast threads would clobber the data slower threads were still reading.

__syncthreads() only synchronises threads in the same block. There is no built-in way to sync across blocks within a kernel; that's what kernel boundaries are for.

8. The bandwidth win, in numbers

ApproachGlobal loads per outputReuse via cache
Naive2N2Naccidental, via L2
Tiled (TILE=32)2N/322N / 32explicit, via shared

For N=1024N = 1024: naive does 2048 loads/output; tiled does 64. 32× less global memory traffic. Bigger tiles win bigger — until you hit the shared memory budget per block (~48 KB) or register pressure capping occupancy.

In practice, on an A100 this single optimisation takes matmul from ~5% of peak FLOPS to ~50%. Going further (warp-level tiling, double-buffering, tensor cores) is how cuBLAS hits 95%+. But the fundamental idea is right here: trade one expensive global load for TILE cheap shared-memory reads.

9. Gotchas worth flagging

A few traps people hit on their first tiled matmul:

  • Boundary handling. If NN isn't a multiple of TILE, the loads can run off the edge of AA or BB. Either pad the matrices or guard each load with if and write zeros.
  • Bank conflicts. Reading Bs[k][threadIdx.x] is column access — the textbook fix is __shared__ float Bs[TILE][TILE+1]; (the +1 padding shifts banks).
  • Choosing TILE. 32×32 = 1024 threads per block is the max; 16×16 = 256 threads runs more blocks per SM (higher occupancy) and is often faster on smaller GPUs. Profile both.
  • Forgetting the second __syncthreads(). Causes race conditions that only show up at certain matrix sizes or compiler optimisation levels. Painful to debug.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. For an N×N naive matmul, how many global memory loads does the kernel do across all output elements?
    • $N^2$
    • $2N^2$
    • $2N^3$
    • $N^3 \log N$
  2. Roughly by what factor does TILE-sized tiling reduce global memory traffic versus the naive kernel?
    • By a factor of 2
    • By a factor of TILE
    • By a factor of TILE²
    • Constant factor only
  3. Why does the tiled kernel call `__syncthreads()` twice per outer-loop iteration?
    • To flush the L2 cache
    • Once after loading the tiles (so all threads see them) and once after using them (so no thread overwrites tiles still in use)
    • Once for A and once for B
    • It only needs to be called once; the second is a defensive habit
  4. Two threads in the same block access `Bs[k][threadIdx.x]` with different k. What's the typical fix to avoid bank conflicts on this read?
    • Move `Bs` to global memory
    • Pad the shared array to `Bs[TILE][TILE+1]`
    • Switch to texture memory
    • Use `volatile float Bs[TILE][TILE]`
  5. Why can't tiled matmul use `__syncthreads()` to coordinate across the whole grid (i.e. across blocks)?
    • It's allowed but very slow
    • `__syncthreads()` only synchronises threads within one block; grid-wide sync requires a new kernel launch (or cooperative groups)
    • Grid sync only works with `__device__` functions
    • CUDA doesn't allow sync once threads have touched global memory

Related lessons