Part II — Kernels that matter · Chapter 7

Matmul I: tiling and shared memory

~60 min read2 interactive widgets4 plateslab 6 · ~50 min on your GPU

The chapter in one paragraph

Chapter 6 showed that the matrix multiply is where a model’s arithmetic lives. This chapter and the next take one FP32 matmul from the obvious kernel to within reach of cuBLAS, one idea at a time, with a prediction and a measurement at every step. Here we cover the first half of the road. The naive kernel, one thread per output, runs at around 1% or 5% of peak depending on a single detail: which way the warp is laid across the output, which decides whether its loads are coalesced. Tiling then makes a block load a tile of each input into shared memory once and reuse every element T times, cutting global traffic by T. And then it stalls, at roughly a tenth of peak, for a reason that no tile size can fix: every multiply-add still needs two loads from shared memory, and shared memory serves only one warp-wide load per cycle per SM, while the FMA units could take four. That ratio, loads per FMA, is the number chapter 8 attacks.

In this chapter

  1. The kernel that decides everything
  2. The naive kernel
  3. Which way the warp lies decides coalescing
  4. Where the naive kernel’s loads really go
  5. Tiling: load once, use T times
  6. The tiled kernel, line by line
  7. Why tiling stops near a tenth of peak
  8. Predictions, and what the profiler should show
  9. Lab 6: from naive to tiled
  10. Check your understanding

1. The kernel that decides everything

The problem, fixed for this chapter and the next: C = A · B with A of shape [M, K], B of shape [K, N], C of shape [M, N], all row-major, all FP32. The BLAS name is SGEMM (single-precision general matrix multiply). Each of the M·N outputs is a dot product of length K, so 2MNK FLOPs in total. The benchmark size is 4096 × 4096 × 4096: 137 GFLOPs, 4.4 ms at your card’s 30.9 TFLOPS FP32 peak if every cycle of every FMA unit were used.

Nobody reaches the peak, so the course measures against the best real implementation: cuBLAS, NVIDIA’s BLAS, with dozens of hand-tuned kernels per architecture. Lab 6 calls it in plain FP32 mode (no TF32 tensor cores, which would be a different contest; chapter 8) and reports every kernel as a percentage of cuBLAS. Expect cuBLAS itself at somewhere around 60–85% of the nominal peak: the peak assumes the boost clock, and under a long, power-hungry matmul the card clocks lower.

The road through these two chapters is well mapped. Simon Boehm’s worklog, the standard modern account, took SGEMM on an RTX A6000 through ten kernels; his measurements, as a percentage of cuBLAS, are the landmarks we will predict against:

KernelIdeaBoehm, A6000Where
naiveone thread per output1.3%this chapter
coalescedsame kernel, warp across columns8.5%
shared-memory tilesload once, reuse T times12.8%
1D register blockingeach thread computes 8 outputs36.5%chapter 8
2D register blockingeach thread computes 8×868.7%
vectorized loads16-byte loads, transposed tile78.4%
warp tilinga level between block and thread93.7%

Look at the shape of that column before reading on. The ideas of this chapter, the ones every CUDA tutorial teaches, get you to about an eighth of cuBLAS. The big steps come after, and the reason they come after is the most important thing this chapter has to explain.

2. The naive kernel

One thread per output element, each running the dot product in a loop. You met it in lab 5 as matmul_naive; here is the general version with a 2D launch:

__global__ void sgemm_naive(const float* A, const float* B, float* C, int M, int N, int K)
{
    int row = /* which row of C this thread owns */;
    int col = /* which column */;
    if (row >= M || col >= N) return;
    float acc = 0.0f;
    for (int k = 0; k < K; ++k)
        acc += A[row * K + k] * B[k * N + col];     // one FMA, two global loads
    C[row * N + col] = acc;
}

Every iteration of the loop does one fused multiply-add (FFMA, 2 FLOPs) and two loads from global memory. If each load went to DRAM, that would be 2 FLOPs per 8 bytes, an intensity of 0.25, and a ceiling of 168 GFLOPS: half a percent of peak (chapter 4). Before measuring anything, the question is how the two blank lines at the top are filled in, because that decides what a warp’s 32 loads look like.

3. Which way the warp lies decides coalescing

A warp is 32 threads with consecutive threadIdx.x (chapter 2). So the mapping from threadIdx.x to the output decides which 32 outputs a warp computes, and therefore which addresses its loads hit at each step k. There are two natural choices:

// v1: x walks the ROWS of C              // v2: x walks the COLUMNS of C
int row = blockIdx.x * 32 + threadIdx.x;  int col = blockIdx.x * 32 + threadIdx.x;
int col = blockIdx.y * 8  + threadIdx.y;  int row = blockIdx.y * 8  + threadIdx.y;
Your move: count the sectors per warp per k step for both versions (A and B loads together), and guess the speed ratio between v1 and v2.

v1: 32 sectors for A + 1 for B = 33 sectors per step, of which only 128 + 4 bytes are useful. v2: 1 for A + 4 for B = 5 sectors, all of the fetched B bytes useful. That is about 6.6× less traffic between L1 and L2 per FMA, and v2’s stores are coalesced too. The caches soften the difference (v1’s A sectors are reused by the next 7 values of k, if they survive in L1 that long), so the speedup is not exactly the sector ratio, but it is large: Boehm measured 6.4× on the A6000. This is chapter 2’s rule again, now with money on it: threadIdx.x walks the dimension that is contiguous in memory, and for C and B that is the column.

v2’s A load deserves a second look. All 32 lanes read the same address, and the hardware sends one request and broadcasts the value to every lane. That is as cheap as a load can be, and it will matter again when the same pattern appears in shared memory.

4. Where the naive kernel’s loads really go

v2 does 2 FLOPs per 8 bytes loaded. If DRAM served every byte, it could not exceed 168 GFLOPS. Yet it will run at several TFLOP/s on your card, ten or more times faster. So most of its loads never reach DRAM. Look at who else needs the same data at the same moment:

So the caches recover a good part of the reuse the math allows, by luck of timing. But notice what they do not change: every FMA still issues two load instructions, each of which has to travel through the L1/texture pipeline, be checked against the cache tags and come back hundreds of cycles later if it missed. The kernel spends its time waiting for loads and issuing loads. The fix is to stop relying on luck: bring the data the block will reuse into on-chip storage once, on purpose, and read it from there.

Your move: in v2, what would you expect the profiler’s top stall reason to be, and why is higher occupancy only a partial fix?

long_scoreboard: warps waiting for global loads to come back (chapter 5). More warps in flight hide more of that latency, which is why v2 benefits from full occupancy. But the loads still have to be issued, two per FMA, through the same L1 pipeline, and the data volume between L2 and L1 stays high. More warps cannot reduce the number of loads; only reuse can.

5. Tiling: load once, use T times

Give each block a T×T tile of C, one thread per element as before. To compute it the block needs a strip of T full rows of A and a strip of T full columns of B, far too much to hold at once. So it walks along K in phases of width T. In phase p:

  1. every thread loads one element of the T×T A tile (the block’s rows, columns pT…pT+T−1) and one element of the B tile (rows pT…, the block’s columns) into shared memory;
  2. the block waits at a barrier until both tiles are complete;
  3. every thread does T multiply-adds, reading a row of the A tile and a column of the B tile from shared memory;
  4. the block waits again, so that nobody overwrites a tile someone is still reading.

Count the global loads. The naive kernel issues 2K loads per output. The tiled kernel issues 2 per thread per phase, and there are K/T phases: 2K/T per output, T times fewer. If every one of them went to DRAM, the intensity would be T/4 FLOP/B: 4 for 16×16 tiles, 8 for 32×32 (chapter 4’s numbers). Walk through it:

Your move: with T = 32 at 4096³, how many bytes does the tiled kernel request from L2 in total, and what ceiling would that give if all of it came from DRAM?

2MNK/T = 2 × 40963 / 32 ≈ 4.3 billion loads of 4 bytes: 17 GB. At ~590 GB/s that is 29 ms, a ceiling of about 4.7 TFLOP/s (8 FLOP/B × 590 GB/s). In reality L2 serves much of it: the A strip of one block row (32 rows × 4096 × 4 B = 512 KB) is read by all 128 blocks of that row, and many of them run at the same time. So the DRAM ceiling is higher than 4.7, and, as section 7 shows, it is not the one that binds.

6. The tiled kernel, line by line

template <int T>
__global__ void sgemm_tiled(const float* A, const float* B, float* C, int M, int N, int K)
{
    __shared__ float As[T][T];
    __shared__ float Bs[T][T];
    const int tx = threadIdx.x, ty = threadIdx.y;
    const int row = blockIdx.y * T + ty;          // this thread's element of C
    const int col = blockIdx.x * T + tx;

    float acc = 0.0f;
    for (int k0 = 0; k0 < K; k0 += T) {                                              // one phase
        As[ty][tx] = (row < M && k0 + tx < K) ? A[row * K + k0 + tx]   : 0.0f;       // (1)
        Bs[ty][tx] = (k0 + ty < K && col < N) ? B[(k0 + ty) * N + col] : 0.0f;       // (2)
        __syncthreads();                                                             // (3)
        #pragma unroll
        for (int k = 0; k < T; ++k)
            acc += As[ty][k] * Bs[k][tx];                                            // (4)
        __syncthreads();                                                             // (5)
    }
    if (row < M && col < N) C[row * N + col] = acc;                                  // (6)
}
// launch: dim3 block(T, T), grid(ceil(N / T), ceil(M / T))
  1. (1) and (2), the loads, are both coalesced. In (1), consecutive tx read consecutive columns of a row of A; in (2), consecutive tx read consecutive columns of a row of B. That is the point of choosing which thread loads which element: the thread that loads an element is not the one that uses it, so loads can be arranged for coalescing independently of the math.
  2. Out-of-range elements load as 0, which adds nothing to a dot product. That is how one kernel handles any M, N, K. Threads outside C do not return early: they still load and still reach both barriers (chapter 3’s rule), and only the final store (6) is guarded.
  3. (3) and (5) are chapter 3’s two barriers: read-after-write (the tile must be complete before anyone reads it) and write-after-read (nobody overwrites it for the next phase until everyone is done). On a GPU a missing barrier shows up only sometimes, depending on timing, which is what makes it dangerous. (The CPU emulator these labs were checked with, dev/cudasim in the repository, runs each thread as far as it can before switching to the next, which turns a missing barrier into a wrong answer every time.)
  4. (4), the inner loop, is conflict-free. With T = 32, a warp is one row of the block (one ty, tx = 0…31). As[ty][k] is the same word for all 32 lanes: a broadcast. Bs[k][tx] is 32 consecutive words, one per bank. With T = 16 a warp spans two rows: two distinct As words 16 banks apart, and the two half-warps read the same 16 Bs words, still one pass each.
Your move: a colleague “simplifies” by putting the whole body inside if (row < M && col < N) { ... }. On square 4096 matrices the tests pass. What breaks, and where?

On any shape that is not a multiple of T, the threads of an edge block that fall outside C skip the barriers, while the others wait at them: undefined behaviour, in practice a hang or garbage (chapter 2). And those skipped threads were also responsible for loading tile elements that the in-range threads need: As[ty][tx] for a thread with col ≥ N is still a valid element of A’s tile. Tests on multiples of 32 never create such a block, which is why lab 6 tests shapes like 257 × 129 × 513.

7. Why tiling stops near a tenth of peak

Tiling divides global traffic by T. Look at the inner loop again, though, and count per FMA: one load from As, one load from Bs. The loads now come from shared memory instead of L1/L2, which is faster and never misses, but there are still two of them per multiply-add. Now compare the two pipes they compete for, per SM, per clock cycle:

So the tiled kernel’s ceiling is one eighth of peak, about 3.9 TFLOP/s on your card, and it does not depend on T. One caveat, in the spirit of chapter 5’s “check the SASS”: in the unrolled inner loop a thread reads As[ty][k], As[ty][k+1], … four consecutive, aligned floats, and the compiler may merge them into a single 16-byte LDS.128. That makes one A load per four FMAs, 1.25 shared loads per FMA instead of 2, and raises the ceiling to about a fifth of peak. make sass in lab 6 counts the instructions so you can see which one your compiler produced. Either way the ceiling is 12–20% of peak, and real kernels land below their ceiling (barriers, latency, the loads themselves take issue slots).

Here is the whole picture for any tile size: occupancy, the global-traffic ceiling, and the shared-memory ceiling:

Your move: T = 32 has twice the global intensity of T = 16. Which one will be faster, and why might the answer surprise you?

Probably neither by much, and T = 32 may even lose. The binding limit is the shared-memory ceiling, which is identical for both. Meanwhile T = 32 means 1,024-thread blocks: only one fits on an SM (1,536 threads maximum), so occupancy drops to 32 of 48 warps (67%), and every barrier makes 1,024 threads wait for the slowest. T = 16 fits six 256-thread blocks per SM at 100% occupancy with smaller barriers. Tiling has done its job, removing global traffic as a limit; the next gains must come from the other side of the ratio.

The ratio that remains

To go further, each value loaded from shared memory must be used by more than one FMA. There is only one place faster than shared memory to keep it: the thread’s own registers. If a thread computes not one output but a small block of them, say TM rows by TN columns, then at each step k it can load TM values of A and TN values of B into registers once and combine every pair: TM·TN FMAs for TM + TN loads.

Outputs per threadShared loads per stepFMAs per stepLoads per FMAShared ceiling
1 × 1 (this chapter)2121/8 of peak
8 × 1981.125≈ 1/4.5
4 × 48160.51/2
8 × 816640.25no longer the limit

That is register blocking, and it is where chapter 8 starts. It is also why the table in section 1 jumps from 13% to 69% of cuBLAS in two steps.

8. Predictions, and what the profiler should show

At 4096³, before you run anything. These ranges come from the mechanisms above and from Boehm’s ratios, not from measurements on your card:

Kernel% of FP32 peakGlobal ld sectors / requestTop stallWhat limits it
v1 naive, x walks rows< 2%≈ 16 (A: 32, B: 1)long_scoreboarduncoalesced A loads and C stores
v2 naive, x walks cols4–8%≈ 2.5 (A: 1, B: 4)long_scoreboardtwo global loads per FMA
v3 tiled, T = 168–15%4 (tile loads)mio_throttle / short_scoreboardtwo shared loads per FMA
v3 tiled, T = 328–15%4same, plus barriersame, at 67% occupancy
cuBLAS60–85%——chapter 8

The two stall names for the tiled kernels are new. mio_throttle means a warp could not issue because the memory-input/output queue, which feeds shared memory, was full; short_scoreboard means a warp is waiting for the result of a shared-memory load (a short-latency dependency, as opposed to long_scoreboard’s global loads). Together they are the profiler’s way of saying “shared memory is the bottleneck”: exactly figure 3.

Your move: in make ncu-table, how do you compute “shared loads per warp-wide FMA” for v3, and what value confirms section 7?

Divide smsp__inst_executed_op_shared_ld_pred_on_any.sum (warp-level shared-load instructions) by smsp__sass_thread_inst_executed_op_ffma_pred_on.sum / 32 (the FFMA count is per thread, so divide by the warp size). About 2 means the compiler kept one load per operand; about 1.25 means it merged the A loads into LDS.128. make sass shows the same thing statically. Either value is far above the 0.25 that chapter 8 will reach.

9. Lab 6: from naive to tiled

Lab 6SGEMM: naive rows, naive columns, tiled 16 and 32, cuBLAS~50 min · mlsys/labs/lab06-matmul-tiling/

One program, sgemm, with the four kernels of this chapter and cuBLAS. The test runs nine awkward shapes (1×1×1, 257×129×513, a K of 1…) against a double-precision reference; the benchmark reports TFLOP/s, % of peak and % of cuBLAS at 1024, 2048 and 4096 cubed. The harness lives in labs/common/gemm.h and lab 7 reuses it.

cd ~/unibo-lessons/mlsys/labs/lab06-matmul-tiling
make test        # every kernel on every shape: PASS
make bench       # the table; v1 at 4096 takes a few seconds
make sass        # FFMA and LDS counts per kernel: is it 2 loads per FMA, or 1.25?
make ncu-table   # sectors per request, shared loads, FFMAs, stall reasons at 2048 cubed
StepPrediction to write down first
bench: v2 / v1several times faster (section 3)
bench: v3 / v2faster, but less than 2× (section 4 vs 7)
bench: T = 32 vs T = 16about the same (section 7)
sass: LDS per FFMA in sgemm_tiled2, or 1.25 with LDS.128
ncu-table: stallsthe table in section 8

Paste back the PASTE: lines and the text output of make sass and make ncu-table. The v3 row of your bench is the baseline chapter 8 will measure every improvement against.

Stretch

Make each thread of the tiled kernel compute two outputs of C, one below the other: the block becomes T × T/2 threads for a T×T tile, each thread loads two elements of each tile per phase, and in the inner loop it reuses one Bs value for two FMAs. Before measuring, work out its shared loads per FMA and its ceiling from section 7’s table. You will have written the first step of chapter 8.

Check your understanding

Two naive kernels differ only in whether threadIdx.x maps to the row or the column of C. Why is one several times faster?

A warp is 32 consecutive threadIdx.x. Mapped to columns, its B loads are 32 consecutive floats (4 sectors) and its A load is one broadcast address; mapped to rows, its A loads are 32 addresses a full row apart (32 sectors) and its stores to C are scattered. Same FLOPs, about 6× more memory traffic per FMA.

The coalesced naive kernel has an intensity of 0.25 FLOP/B from DRAM’s point of view, yet runs at several TFLOP/s. How?

Most of its loads hit in cache: the 8 warps of a block share each B row segment in L1, one A sector serves 8 consecutive k, and blocks sharing rows or columns meet in L2. The effective DRAM traffic is a small fraction of what the load count suggests. What caches cannot remove is the two load instructions per FMA.

By how much does a T×T tiling reduce global loads, and why?

By a factor of T. Each element of an A or B tile is loaded once from global memory and then read by T threads from shared memory: a row of the A tile by the T threads computing one row of the C tile, a column of the B tile by the T threads of one column.

Why is the tiled kernel limited to about one eighth of FP32 peak, independently of T?

Its inner loop needs two shared-memory loads per FMA. Shared memory serves one warp-wide load per cycle per SM, and the SM can issue four warp-wide FFMAs per cycle, so at most 0.5 FFMA per cycle gets its operands: 1/8 of peak. T changes global traffic, not this ratio. (If the compiler merges four A loads into one LDS.128, the ratio is 1.25 and the ceiling about 1/5.)

Why must a thread whose output lies outside C still execute the loads and barriers of the tiled kernel?

Because the barriers must be reached by every thread of the block (otherwise the behaviour is undefined), and because that thread is responsible for loading one element of the A and B tiles that other, in-range threads need.

What do mio_throttle and short_scoreboard stalls tell you, together?

That warps are waiting on shared memory: mio_throttle when the queue feeding the shared-memory pipe is full, short_scoreboard when an instruction needs the result of a shared-memory load that has not returned yet. It is the profiler’s signature of a shared-bandwidth-bound kernel like the tiled matmul.

A thread computes an 8×8 block of outputs. How many shared loads and FMAs per step k, and why does that help?

8 loads from the A tile and 8 from the B tile into registers, then 64 FMAs combining every pair: 0.25 loads per FMA instead of 2, eight times less shared traffic per FLOP. Registers are the only storage fast enough to feed the FMA units, and each register value is now used 8 times.

Why does T = 32 give only 67% occupancy on your card?

A 32×32 block has 1,024 threads. An SM holds at most 1,536 threads, so only one such block fits: 32 warps out of 48.

Sources and further reading