The level in one paragraph. Your coalesced kernel wastes nothing per request, yet it reads the whole input four thousand times over. The fix is the one you would use for a hot database row: keep a copy close to where it is used. On a GPU that copy lives in shared memory, loaded tile by tile by a whole block working together, with two barriers you already know from concurrency class. Your uniform/varying lens from level 1 keeps working, now also inside shared memory. It cuts global-memory traffic by 32x. Then comes the twist: the kernel gets only about 1.5x faster, because the bottleneck moved.
As in level 1: uniform = the same for all 32 threads of a warp · varying = different for each thread. A warp is 32 threads with neighbouring threadIdx.x and the same threadIdx.y.
Look at who reads what in the coalesced kernel. Row i of C has 4,096 outputs, each computed by its own thread, and every one of those threads reads all of row i of A from global memory. So each float of A is fetched 4,096 times, once per thread that needs it. The same holds for each float of B, read once by every thread in its column of C.
Add it up: A and B are 64 MB each, but the kernel asks global memory for about 550 GB in total, the whole input read four thousand times over. In backend terms, every request re-queries the same hot rows instead of caching them.
A useful way to measure this is arithmetic intensity: floating-point operations done per byte fetched from global memory. Each multiply-add (2 operations) brings in two fresh floats (8 bytes): 0.25 operations per byte. A GPU computes far faster than data can arrive at that rate, so the kernel's speed is capped at intensity × memory bandwidth. Plotted against intensity, that cap is a sloped line that flattens out at the compute peak: the roofline.
Keep "no tiling" and pick your GPU (Colab's free one is the T4). The red bar is the ceiling that binds; the others are the ones you would hit next.
One honest wrinkle: real untiled kernels run above the "no tiling" line, because the GPU's L2 cache quietly catches some of the reuse. The cache is doing this level's trick for you, partly and by accident. Doing it on purpose is the fix.
A GPU has several kinds of memory, from huge and slow to tiny and fast. The latencies below are rough orders of magnitude; they vary by chip.
| memory | who sees it | size | roughly how far | managed by |
|---|---|---|---|---|
| registers | one thread | a few hundred KB per SM, split among its threads | ~1 cycle | the compiler |
| shared memory | the threads of one block | tens of KB per block (48 KB by default) | ~20–30 cycles | you |
| L2 cache | the whole GPU | a few MB | ~200 cycles | the hardware |
| global memory | the whole GPU | gigabytes | ~400–800 cycles | you (it is where A, B, C live) |
An SM (streaming multiprocessor) is one of the GPU's core clusters; a block always runs on a single SM. Shared memory is the interesting row: on-chip, fast, and shared by exactly the threads of one block. It is a cache you manage: nothing arrives there unless your code copies it in, and it disappears when the block finishes. You declare it inside the kernel:
__shared__ float As[32][32]; // one copy per block, visible to all its 1,024 threads
C. What does the whole block need from A and B, and can it fit in shared memory? (think, then open)A 32×32 patch of C needs 32 full rows of A (a horizontal strip) and 32 full columns of B (a vertical strip). Each strip is 32 × 4,096 floats = 512 KB: far too big for shared memory. But the dot products can be done in pieces: take the first 32 columns of the A strip and the first 32 rows of the B strip, two 32×32 tiles of 4 KB each, do all the multiply-adds they allow, then move on to the next 32. That is tiling.
for each step t = 0, 1, …, 127:
every thread copies ONE float of A's tile and ONE float of B's tile into shared memory
wait until the whole block has finished copying
every thread does 32 multiply-adds, reading only shared memory
wait until the whole block has finished computing
every thread writes its one output
Why one float per thread per tile? A tile has 32 × 32 = 1,024 floats and the block has 1,024 threads: the copy is shared out perfectly. Then each loaded float is used by 32 threads: A's tile element in row r by the 32 threads computing row r of the patch, B's element in column c by the 32 threads computing column c. Watch it:
Tap a block of C, then step through t. The block moves along its strip of A (left to right) and down its strip of B (top to bottom), one tile each per step.
Back to the roofline lab above: choose "tile 32x32" and watch the DRAM ceiling move 32 times higher, from 0.25 to 8 operations per byte.
#define TILE 32
extern "C" __global__ void matmul_tiled(const float* A, const float* B, float* C, int N) {
__shared__ float As[TILE][TILE]; // (1)
__shared__ float Bs[TILE][TILE];
int tx = threadIdx.x, ty = threadIdx.y;
int row = blockIdx.y * TILE + ty; // (2) uniform
int col = blockIdx.x * TILE + tx; // (2) varying
float acc = 0.0f;
for (int t = 0; t < N; t += TILE) { // (3)
As[ty][tx] = A[row][t + tx]; // (4) packed
Bs[ty][tx] = B[t + ty][col]; // (4) packed
__syncthreads(); // (5)
for (int k = 0; k < TILE; ++k)
acc += As[ty][k] * Bs[k][tx]; // (6) broadcast · no conflict
__syncthreads(); // (7)
}
if (row < N && col < N)
C[row * N + col] = acc; // (8)
}
// A[r][c] stands for A[r * N + c]; the notebook version also checks bounds on the loads.
tx picks the column, so a warp owns a slice of a row of C.t is the tile's starting column in A and starting row in B. It is uniform: the whole block is on the same step.(tx, ty) copies element (ty, tx) of each tile. Read through the lens: A[row][t + tx] is varying only in the last index, packed; B[t + ty][col] too. The loads are coalesced exactly as in level 1.As[ty][k] is all uniform: one value broadcast to the warp. Bs[k][tx] is varying in the last index. Section 6 shows why both are fast in shared memory too.__syncthreads() is a barrier for the threads of one block: nobody passes until everybody arrives, the same idea as the barriers of concurrent programming. The first one is obvious. The second is not.
A fast warp finishes its 32 multiply-adds, loops around and writes the next tile into As and Bs while a slower warp is still reading the current one: a write-after-read race. Try it below.
A block shrunk to two warps; each writes half of the tile, and each computes on the whole tile. Step them in any order, switch barriers off, or let W0 run ahead as a fast warp would. With both barriers, no order you choose can produce a wrong step.
Two more rules from the same family. Every thread of the block must reach every barrier: a __syncthreads() inside an if that only some threads take can hang the block. And like any race, the missing-barrier bug shows up only sometimes, depending on timing, which is exactly what makes it dangerous.
Shared memory has its own version of sectors. It is split into 32 banks of 4 bytes: float i of a shared array lives in bank i mod 32. Each bank serves one address per step. So a warp's access is fast when its 32 addresses fall in 32 different banks, or are all the same address (a broadcast), and slow when several different addresses land in the same bank: a bank conflict, served one after another.
The level-1 rule carries over almost unchanged: all uniform is a broadcast; varying in the last index lands in 32 different banks; varying in an earlier index, with rows of 32 floats, puts every thread in the same bank.
Going down a column adds one row length to the index. With 32 floats per row that adds exactly one full lap of the 32 banks, so every thread lands on the same bank. With 33 it adds a lap plus one, so thread tx lands in bank (tx + k) mod 32: 32 different banks. Padding is the standard trick whenever a kernel must read a shared tile by columns, for example a transpose.
Global-memory traffic is down 32x. Commit to a number before you look.
Only about 1.5x, for 32x less traffic. Two reasons, and both matter for everything that follows:
With one output per thread, a 64×64 patch needs 4,096 threads, and a block can have at most 1,024. The tile size is capped by "one thread, one output". To grow the tile, each thread has to compute several outputs, and keep the values it reuses in registers, the fastest row of the table in section 2.
That is exactly the next move: more work per thread, with reused values in registers. It takes the climb from 12.8% to 36.5% and then 68.7% of cuBLAS. That is level 3.
Three variations of this kernel. Mark each index uniform or varying and predict the cost: in sectors for global memory, in bank steps for shared memory. In all of them tx is varying and ty, t, k are uniform.
The notebook's level 2 cell has the skeleton with three TODOs: the two tile loads and the two barriers. It compiles as given but produces a wrong answer until you fill them in; the harness checks every result against cuBLAS.
Each thread will compute a column of 8 outputs instead of one, reading each value of Bs once into a register and reusing it 8 times. Same shared memory, same barriers, far fewer shared-memory reads per multiply-add. Not built yet: this campaign is a prototype of two levels.