Triton from zero · Lesson 2 of 7

2D Tiles & Matrix Multiply

The grid becomes a grid. You learn to think in 2D tiles — then use them to write a complete matrix multiply in ~25 lines of Triton, the exact kernel shape decode-lab’s GEMV and attention kernels are built from.

▶ How to run the code (Colab · T4 GPU)

Create a Colab notebook with a GPU runtime (Runtime ▸ Change runtime type ▸ T4 GPU), then in the first cell run:

!nvidia-smi
!pip install -q triton

Paste the code from this lesson (or the whole companion script) into the next cell. No GPU at hand? Run locally — the script falls back to Triton’s CPU interpreter automatically (source /home/tensor/.venv/bin/activate, then cd decode-lab/artifacts and python lesson2_matmul.py). You should see PART A PASSED and PART B PASSED at the end.

1Why 2D? The grid becomes a grid

Last lesson your grid was a line: grid = (4,), and tl.program_id(axis=0) told each program its position in that line. But a matrix is a rectangle — so this lesson the launch grid becomes a rectangle too:

grid = (triton.cdiv(M, BLOCK_M), triton.cdiv(N, BLOCK_N))   # (row-blocks, col-blocks)

pid_m = tl.program_id(axis=0)     # which ROW-block of C am I?
pid_n = tl.program_id(axis=1)     # which COL-block of C am I?

Two numbers, two axes. axis=0 indexes the first grid dimension (rows), axis=1 the second (columns). Each program now owns a 2D tile of the output — a BLOCK_M × BLOCK_N rectangle — instead of a 1D slice.

The kitchen analogy, extended

Last lesson: line cooks in a single row, badge pid. This lesson: a brigade arranged in rows and columns of stations. Your badge has two numbers — (pid_m, pid_n) — like a seat in a theatre: row pid_m, seat pid_n. The station’s address tells you which rows of the output matrix you’re responsible for, and which columns.

The only real difference from lesson 1: instead of one index vector, you build two — one for the rows you own, one for the columns:

offs_m = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)   # (BLOCK_M,)  row indices of my tile
offs_n = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)   # (BLOCK_N,)  col indices of my tile

This is the whole Part A kernel from lesson2_matmul.py — it fills every cell of C with its own linear index i*N + j, so you can literally see which program owned which cell:

@triton.jit
def tile_fill_kernel(out_ptr, M, N, BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr):
    pid_m = tl.program_id(axis=0)                     # which row-block am I?
    pid_n = tl.program_id(axis=1)                     # which col-block am I?

    offs_m = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)  # (BLOCK_M,) row indices
    offs_n = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)  # (BLOCK_N,) col indices

    # The star of the lesson, in one line:
    #   column vector (BLOCK_M, 1)  +  row vector (1, BLOCK_N)
    #   -> broadcast into a (BLOCK_M, BLOCK_N) tile of linear indices
    linear = offs_m[:, None] * N + offs_n[None, :]

    mask = (offs_m[:, None] < M) & (offs_n[None, :] < N)
    tl.store(out_ptr + linear, linear.to(tl.float32), mask=mask)

The 2D grid, visualized

Top: the C matrix, each cell colored by its owning program (the number is the value i*N + j the kernel stores). Bottom: the programs themselves — click one. Hatched edge = that program has masked-off (out-of-bounds) rows or columns.

2Broadcasting: build a tile in one line

Here is the trick that unlocks everything in Triton. We have a column vector and a row vector, and we want a whole 2D tile:

column = tl.arange(0, BLOCK_M)[:, None]     # shape (BLOCK_M, 1)   — a column
row    = tl.arange(0, BLOCK_N)[None, :]     # shape (1, BLOCK_N)   — a row
tile   = column + row                       # shape (BLOCK_M, BLOCK_N) — the whole 2D tile!

[:, None] adds a new axis at the end (turns a vector into a column); [None, :] adds a new axis at the front (turns a vector into a row). When you add a (BLOCK_M, 1) and a (1, BLOCK_N) tensor, Triton broadcasts: the column is repeated across all N positions, the row is repeated across all M positions, and every cell gets i + j — its row index plus its column index.

The street-grid analogy

Imagine a city with numbered avenues running north–south (the column vector [0, 1, 2, ...]) and numbered streets running east–west (the row vector [0, 1, 2, ...]). Every intersection is uniquely identified by both numbers: (avenue i, street j). The grid of all intersections IS the 2D tile — and the value at each intersection can be any function of i and j: the sum, the product, or (in matmul) i*K + j, the linear memory index.

This is exactly what the matmul kernel will use, in a slightly disguised form: offs_m[:, None] * stride + offs_k[None, :] — a column of row-indices plus a row of column-indices = a 2D tile of pointer offsets. Learn it here, on plain numbers:

The broadcast visualizer

Blue header column = offs_m[:, None] (the “avenues”), blue header row = offs_n[None, :] (the “streets”). Every body cell is offs_m[i] ⊕ offs_n[j]. Click a cell. Toggle between the i + j tile (Part A’s sum) and the i*K + j tile (the matmul-style index table).

Click a cell to see the broadcast math.

3Strides: how matrices actually live in memory

GPU memory is a long 1D strip of addresses — there are no 2D boxes in it. A 2D matrix is laid out row by row (this is called row-major order), and the address of element (i, j) is:

address(i, j) = base + i * stride_0 + j * stride_1

stride_0 = distance in memory between row i and row i+1   (= K for an M×K matrix)
stride_1 = distance between column j and column j+1       (= 1, always adjacent)

For an M×K matrix, stride_0 = K and stride_1 = 1: row 0 starts at element 0, row 1 at element K, row 2 at element 2K…

The bookshelf analogy

A matrix is a wall of shelves. Row-major means each shelf holds one row of the matrix, and the shelves are stacked in order. stride_0 is the distance from one shelf to the next — normally that’s exactly the shelf width, but it doesn’t have to be: if you only own a slice of a bigger wall (a view of a larger matrix), the shelves you use are spaced further apart. stride_1 is the distance between books on a shelf — almost always 1.

Why the kernel takes strides, not shapes

The kernel signature from lesson2_matmul.py is deliberately verbose — that verbosity is the point:

def matmul_kernel(a_ptr, b_ptr, c_ptr,
                  M, N, K,                          # real sizes
                  stride_am, stride_ak,             # how A steps through memory
                  stride_bk, stride_bn,             # how B steps through memory
                  stride_cm, stride_cn, ...):       # how C steps through memory

The kernel never needs to know that A is an M×K matrix — it needs to know how to walk it. Moving from offs_m to offs_m + 1 (one row down) means jumping stride_am elements; moving along offs_k (one column right) means jumping stride_ak elements. That’s why the pointer tiles are built the way they are:

# a tile of pointers: my BLOCK_M rows × BLOCK_K columns, positioned by strides
a_ptrs = a_ptr + offs_m[:, None] * stride_am + offs_k[None, :] * stride_ak
b_ptrs = b_ptr + offs_k[:, None] * stride_bk + offs_n[None, :] * stride_bn

Look at a_ptrs: a column of row-indices (offs_m[:, None]) times the row stride, plus a row of column-indices (offs_k[None, :]) times the column stride — the §2 broadcasting trick, in its real job. One line, and you have a (BLOCK_M, BLOCK_K) tile of addresses.

Why this matters for decode-lab

Stride arguments are what let one kernel handle many layouts: a dense matrix, a row of a bigger matrix (a view), even a transposed matrix — same kernel, different strides. decode-lab’s GEMV and attention kernels lean on exactly this. (Homework #3 explores it.)

4The matmul kernel, line by line

Now the whole thing. This is matmul_kernel from lesson2_matmul.py — the classic tiled GEMM in ~25 lines. Read it once through, then read the anatomy table below it:

@triton.jit
def matmul_kernel(a_ptr, b_ptr, c_ptr,
                  M, N, K,                          # real sizes
                  stride_am, stride_ak,             # how A steps through memory
                  stride_bk, stride_bn,             # how B steps through memory
                  stride_cm, stride_cn,             # how C steps through memory
                  N_ITERS: tl.constexpr,            # how many K-chunks (see note)
                  BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr, BLOCK_K: tl.constexpr):
    pid_m = tl.program_id(axis=0)                   # row-block / col-block
    pid_n = tl.program_id(axis=1)

    # rows/cols of C owned by this program, plus the K-chunk index vector
    offs_m = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)   # (BLOCK_M,)
    offs_n = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)   # (BLOCK_N,)
    offs_k = tl.arange(0, BLOCK_K)                     # (BLOCK_K,)

    # pointer tiles we slide along K in the loop below
    a_ptrs = a_ptr + offs_m[:, None] * stride_am + offs_k[None, :] * stride_ak
    b_ptrs = b_ptr + offs_k[:, None] * stride_bk + offs_n[None, :] * stride_bn

    acc = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32)
    for k in range(0, N_ITERS):
        # ragged K edge? load 0.0 for the out-of-range columns so the
        # partial sums stay exactly right
        a = tl.load(a_ptrs, mask=offs_k[None, :] < K - k * BLOCK_K, other=0.0)
        b = tl.load(b_ptrs, mask=offs_k[:, None] < K - k * BLOCK_K, other=0.0)
        acc = tl.dot(a, b, acc)                      # acc += a @ b  (tensor cores)
        a_ptrs += BLOCK_K * stride_ak                # slide both windows to the
        b_ptrs += BLOCK_K * stride_bk                # next K-chunk

    c_ptrs = c_ptr + offs_m[:, None] * stride_cm + offs_n[None, :] * stride_cn
    mask = (offs_m[:, None] < M) & (offs_n[None, :] < N)
    tl.store(c_ptrs, acc, mask=mask)
LineWhat it really means
pid_m / pid_n = tl.program_id(axis=0/1)My station’s row and column badge (§1). The pair names my output tile.
offs_m / offs_nThe row indices and column indices of C that I own.
offs_k = tl.arange(0, BLOCK_K)The K-axis window for one loop iteration — the “chunk” of the inner dimension this pass handles.
a_ptrs / b_ptrsPointer tiles: where my A-tile and B-tile live in memory (§3). These get slid through the loop.
acc = tl.zeros((BLOCK_M, BLOCK_N))My accumulator — starts at zero, grows into my final tile of C.
a = tl.load(a_ptrs, mask=..., other=0.0)Read my BLOCK_M × BLOCK_K slab of A. If K is ragged, missing columns load as 0.0 so partial sums stay exact.
acc = tl.dot(a, b, acc)The heart of the lesson: acc += a @ b. One instruction multiplies and adds a whole tile pair — on a T4 this runs on the tensor cores.
a_ptrs += BLOCK_K * stride_akSlide both windows one chunk further along K (not one element — one chunk).
mask = (offs_m[:, None] < M) & (offs_n[None, :] < N)The lesson-1 ragged-edge guard, now 2D: skip rows past M and columns past N.
tl.store(c_ptrs, acc, mask=mask)Write my finished tile of C — and only my tile.
Gotcha — loop bounds must be constexpr

Why is the K-loop written as range(0, N_ITERS) with N_ITERS: tl.constexpr? On a GPU, range(0, tl.cdiv(K, BLOCK_K)) would also work. But Triton’s CPU interpreter (the no-GPU fallback this repo relies on) can’t handle a runtime loop bound — tl.cdiv returns a tensor there. Passing N_ITERS=triton.cdiv(K, BLOCK_K) as a constexpr computed at launch time fixes it everywhere: the loop trip count is baked in at compile time, harmless on GPU and mandatory for the interpreter. Keep this pattern.

And the launch, for reference — the grid is 2D now, and the strides are passed straight from the torch tensors:

grid = (triton.cdiv(M, BLOCK_M), triton.cdiv(N, BLOCK_N))
matmul_kernel[grid](
    a, b, c, M, N, K,
    a.stride(0), a.stride(1),                    # row stride = K, col stride = 1
    b.stride(0), b.stride(1),
    c.stride(0), c.stride(1),
    N_ITERS=triton.cdiv(K, BLOCK_K),
    BLOCK_M=16, BLOCK_N=16, BLOCK_K=16,
)

5Why tiling is fast: three reasons

The naive way to compute C = A @ B would have each of the M·N output elements walk a full row of A and a full column of B — every element of A is read N times, every element of B is read M times. That’s 2·M·N·K memory reads, and memory — not math — is the bottleneck on a GPU.

Tiling fixes this in three compounding ways:

ReasonHow
1 · Data reuseEach loaded A-tile (BLOCK_M × BLOCK_K) is used to compute BLOCK_N output columns; each B-tile (BLOCK_K × BLOCK_N) feeds BLOCK_M output rows. With 16×16 tiles, each A element is read N/16 times instead of N — a 16× traffic cut.
2 · Tensor corestl.dot on a T4 maps to NVIDIA’s tensor cores — special hardware that does a 16×16×16 multiply-accumulate in one instruction. No scalar loop in the world can compete.
3 · Streaming along KThe loop slides the two windows along K, so each program only ever holds three tiles in its working set (A-chunk, B-chunk, accumulator) and streams through memory exactly once.
The kitchen analogy, third course

Instead of each cook fetching every ingredient for one dish, a cook is assigned a whole tray of dishes (an output tile) and a few big bowls of shared ingredients (the A/B tiles). One trip to the pantry feeds dozens of dishes. That trip is your memory read — you want as few trips as possible.

One more thing worth saying out loud: acc = tl.dot(a, b, acc) accumulates in place — the acc argument is the running sum. That is exactly the acc += a @ b of the math, and it means the K-loop can run as many chunks as needed, even when K is huge, without ever holding more than one chunk of A and B at a time.

6The K-loop, frame by frame

Everything above collapses into one tiny picture. Here is a full matmul with BLOCK_M = BLOCK_N = 2, BLOCK_K = 1 — one program, one K-chunk per step. Watch what gets loaded (the hot column of A and the hot row of B), what gets added to the accumulator, and how acc evolves into A @ B:

K-loop trace

A = [[1,2,3],[4,5,6]] (2×3) · B = [[7,8],[9,10],[11,12]] (3×2). Step through the loop — BLOCK_K = 1, so there are 3 iterations.

step — / —

Press next ▶ to start the loop.

7Homework — seriously, do this

Run lesson2_matmul.py on Colab, then try each of these by modifying the code. Hints are collapsible — try it first, peek only if stuck.

1 · Change BLOCK_M/N/K to 32, then to 8

The result must stay correct on every shape in the test list. Watch what happens to the grid for (17, 19, 23): cdiv(17,8)=3 row-blocks, cdiv(19,8)=3 col-blocks, N_ITERS=cdiv(23,8)=3. The masks handle all the ragged edges.

2 · Run shapes where K is not divisible by BLOCK_K (e.g. (16, 16, 5))

The ragged-K mask (offs_k < K - k*BLOCK_K, other=0.0) is what keeps the partial sums exact. Try deleting other=0.0 — on the GPU you may get garbage; the interpreter may just add uninitialized values. This is the silent-bug class decode-lab’s parity gate exists to catch.

3 · The stride exercise: compute C = A @ B.T with the same kernel

Don’t transpose anything. Call the kernel with b = b_t (shape N×K) and swap the strides: pass b.stride(0) as stride_bn and b.stride(1) as stride_bk. The pointer math walks B’s columns as if they were rows. If it works, you understand §3.

4 · The real test: write a transpose-read kernel from scratch

Write transpose_kernel(in_ptr, out_ptr, M, N, stride_im, stride_in, stride_om, stride_on, BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr) so that out[i, j] = in[j, i] — by swapping the strides, not the indices. The pointer tile for reading is in_ptr + offs_n[:, None]*stride_im + offs_m[None, :]*stride_in — wait, that’s the hint. Derive it yourself first.

@triton.jit
def transpose_kernel(in_ptr, out_ptr, M, N,
                     stride_im, stride_in, stride_om, stride_on,
                     BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr):
    pid_m = tl.program_id(axis=0)
    pid_n = tl.program_id(axis=1)
    offs_m = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)
    offs_n = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)
    # read the (n, m) element by swapping which index multiplies which stride
    in_ptrs = in_ptr + offs_n[:, None] * stride_im + offs_m[None, :] * stride_in
    mask = (offs_n[:, None] < N) & (offs_m[None, :] < M)
    val = tl.load(in_ptrs, mask=mask)
    out_ptrs = out_ptr + offs_m[:, None] * stride_om + offs_n[None, :] * stride_on
    tl.store(out_ptrs, val, mask=mask)
5 · Explain why BLOCK_M, BLOCK_N, BLOCK_K must be tl.constexpr

Because they shape the program itself: tl.arange lengths must be known at compile time, the accumulator’s shape is fixed, and tl.dot needs static tile sizes to select the tensor-core instruction. A runtime BLOCK_M would be like asking a bakery to print trays with a size that’s only decided when the customer arrives.

8Your learning journey

ConceptStatus
1D grids, pid, blocks & masks (Lesson 1)✔ covered
2D grids — program_id(axis=0/1), row/col blocks✔ covered
Broadcasting — [:, None] + [None, :] → 2D tile✔ covered
Strides & pointer tiles — a_ptr + offs_m[:,None]*sa + offs_k[None,:]*sb✔ covered
Tiled matmul — K-loop, tl.dot, accumulator, ragged-K masks✔ covered
Why tiling is fast — reuse, tensor cores, streaming✔ covered
Next: reductions — block sums, softmax, fused rmsnorm⏳ Lesson 3