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.
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:
| Kernel | Idea | Boehm, A6000 | Where |
|---|---|---|---|
| naive | one thread per output | 1.3% | this chapter |
| coalesced | same kernel, warp across columns | 8.5% | |
| shared-memory tiles | load once, reuse T times | 12.8% | |
| 1D register blocking | each thread computes 8 outputs | 36.5% | chapter 8 |
| 2D register blocking | each thread computes 8×8 | 68.7% | |
| vectorized loads | 16-byte loads, transposed tile | 78.4% | |
| warp tiling | a level between block and thread | 93.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.
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.
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;
threadIdx.x mapping. In v1 the A loads are chapter 3’s column walk: every lane touches a different row, so a single warp-wide load pulls 32 sectors of 32 bytes to use 4 bytes of each.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.
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.
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.
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:
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:
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.
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))
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.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.)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.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.
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:
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.
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 thread | Shared loads per step | FMAs per step | Loads per FMA | Shared ceiling |
|---|---|---|---|---|
| 1 × 1 (this chapter) | 2 | 1 | 2 | 1/8 of peak |
| 8 × 1 | 9 | 8 | 1.125 | ≈ 1/4.5 |
| 4 × 4 | 8 | 16 | 0.5 | 1/2 |
| 8 × 8 | 16 | 64 | 0.25 | no 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.
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 peak | Global ld sectors / request | Top stall | What limits it |
|---|---|---|---|---|
| v1 naive, x walks rows | < 2% | ≈ 16 (A: 32, B: 1) | long_scoreboard | uncoalesced A loads and C stores |
| v2 naive, x walks cols | 4–8% | ≈ 2.5 (A: 1, B: 4) | long_scoreboard | two global loads per FMA |
| v3 tiled, T = 16 | 8–15% | 4 (tile loads) | mio_throttle / short_scoreboard | two shared loads per FMA |
| v3 tiled, T = 32 | 8–15% | 4 | same, plus barrier | same, at 67% occupancy |
| cuBLAS | 60–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.
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.
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
| Step | Prediction to write down first |
|---|---|
bench: v2 / v1 | several times faster (section 3) |
bench: v3 / v2 | faster, but less than 2× (section 4 vs 7) |
bench: T = 32 vs T = 16 | about the same (section 7) |
sass: LDS per FFMA in sgemm_tiled | 2, or 1.25 with LDS.128 |
ncu-table: stalls | the 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.
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.
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.
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 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.
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.)
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.
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.
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.
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.
__syncthreads() semantics; Nsight Compute Profiling Guide: the mio_throttle and short_scoreboard stall reasons.cublasSgemm, column-major conventions, math modes.