Triton from zero · Lesson 5 of 7

Fused Kernels

The single biggest performance lever in decode-lab: replace five kernel launches and fourteen memory passes with one launch and five. You’ll fuse elementwise chains, then the entire SwiGLU MLP — the exact kernel in src/qwen3_lm/kernels/swiglu.py.

▶ 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 lesson5_fused_kernels.py). You should see PART A/B/C PASSED at the end.

1What fusing means

Here is the unfused way to compute out = x*2 + 3 in torch:

tmp = x * 2.0          # kernel 1: read x, write tmp
out = tmp + 3.0        # kernel 2: read tmp, write out

Two kernels, four memory passes — even though the math is trivial. The GPU spends its time shipping data to and from HBM, not computing. The fused way:

x = tl.load(x_ptr + offs, mask=mask)
tl.store(out_ptr + offs, x * 2.0 + 3.0, mask=mask)   # one kernel, two passes
The kitchen analogy, fourth course

Unfused is a line cook walking to the pantry between every step of a recipe: fetch potatoes, peel them, walk back to fetch the butter, walk back to fetch the pan… Fused is the cook grabbing everything once and not leaving the station until the dish is plated. The trip to the pantry is the expensive part — not the peeling.

Fusion = read your inputs once, do the whole computation in registers, write once. Everything between the tl.load and the tl.store is free — register arithmetic doesn’t touch memory at all. That’s why an elementwise chain (add, multiply, exp, sigmoid — any sequence) costs the same as a single op once the data is loaded.

2The economics: counting memory passes

Every kernel launch costs two things: the launch overhead (~5–10 µs, which decode feels hard) and the memory passes. Count the passes for the unfused MLP below vs the fused one:

step (torch)readswrites
g = F.linear(x, gate_w)x, gate_wg
u = F.linear(x, up_w)x, up_wu
F.silu(g)gg
silu(g) * ug, uh
F.linear(h, down_w)h, down_wout
total9 reads5 writes = 14 passes

The fused kernel: read x once, read the three weight matrices once, write out once — 5 passes, and x is read once instead of twice. Nearly 3× less data movement, and 1 launch instead of 5.

Memory-bound vs compute-bound

An op is memory-bound when its data movement dominates (most elementwise and norm ops: rmsnorm, softmax, silu, the fused MLP’s tiny tiles) and compute-bound when the math dominates (big matmuls on tensor cores). Fusion buys everything for memory-bound ops and little for compute-bound ones — which is why the attention matmuls in lesson 7 stay as matmuls, while the normalization around them gets fused.

3Fusing chains: the silu gate

Part A of the companion is the trivial chain (x*2 + 3). Part B is the real one — the core of SwiGLU, the activation that replaced GELU in modern LLMs:

@triton.jit
def silu_gate_kernel(g_ptr, u_ptr, out_ptr, N, BLOCK: tl.constexpr):
    pid = tl.program_id(0)
    offs = pid * BLOCK + tl.arange(0, BLOCK)
    mask = offs < N
    g = tl.load(g_ptr + offs, mask=mask)
    u = tl.load(u_ptr + offs, mask=mask)
    tl.store(out_ptr + offs, g * tl.sigmoid(g) * u, mask=mask)

Three elementwise ops (sigmoid, *, *) in one line, one pass: silu(g) * u. In torch that’s two kernels (or one fused F.silu and a multiply — still two passes over g and u). Here: load both, compute, store. This exact line — with different names — is what decode-lab’s swiglu kernel does between its matmuls.

4The fused MLP — decode-lab’s swiglu.py

The full MLP is three matmuls around that gate:

out = down(silu(x @ gate^T) * (x @ up^T))

Torch runs five kernels (table above). The fused kernel runs one: it computes gate/up tiles, applies silu in registers, and dots into the output — reusing the transpose-read trick from lesson 4 to read the weights sideways:

@triton.jit
def swiglu_mlp_kernel(x_ptr, gate_ptr, up_ptr, down_ptr, out_ptr, M, N_OUT, H, I,
                      N_ITERS_I: tl.constexpr, N_ITERS_H: tl.constexpr,
                      BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr,
                      BLOCK_H: tl.constexpr, BLOCK_I: tl.constexpr):
    pid_m = tl.program_id(0)
    pid_n = tl.program_id(1)

    x_bp = tl.make_block_ptr(x_ptr, (M, H), (H, 1), (pid_m * BLOCK_M, 0),
                             (BLOCK_M, BLOCK_H), (1, 0))
    out_bp = tl.make_block_ptr(out_ptr, (M, N_OUT), (N_OUT, 1),
                               (pid_m * BLOCK_M, pid_n * BLOCK_N),
                               (BLOCK_M, BLOCK_N), (1, 0))

    acc = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32)
    for ki in range(0, N_ITERS_I):
        # transposed views (lesson 4 Part C): gate (I,H) read as (H,BLOCK_I), etc.
        gate_bp = tl.make_block_ptr(gate_ptr, (H, I), (1, H), (0, ki * BLOCK_I),
                                    (BLOCK_H, BLOCK_I), (0, 1))
        up_bp = tl.make_block_ptr(up_ptr, (H, I), (1, H), (0, ki * BLOCK_I),
                                  (BLOCK_H, BLOCK_I), (0, 1))
        down_bp = tl.make_block_ptr(down_ptr, (I, N_OUT), (1, I),
                                    (ki * BLOCK_I, pid_n * BLOCK_N),
                                    (BLOCK_I, BLOCK_N), (0, 1))

        g = tl.zeros((BLOCK_M, BLOCK_I), dtype=tl.float32)
        u = tl.zeros((BLOCK_M, BLOCK_I), dtype=tl.float32)
        x_win = x_bp                                   # fresh window per I-chunk
        for kh in range(0, N_ITERS_H):
            x_t = tl.load(x_win, boundary_check=(0, 1), padding_option="zero")
            g_t = tl.load(gate_bp, boundary_check=(0, 1), padding_option="zero")
            u_t = tl.load(up_bp, boundary_check=(0, 1), padding_option="zero")
            g = tl.dot(x_t, g_t, g)                    # g  += x @ gate^T chunk
            u = tl.dot(x_t, u_t, u)                    # u  += x @ up^T chunk
            x_win = tl.advance(x_win, (0, BLOCK_H))
            gate_bp = tl.advance(gate_bp, (BLOCK_H, 0))
            up_bp = tl.advance(up_bp, (BLOCK_H, 0))

        hidden = g * tl.sigmoid(g) * u                 # silu(gate) * up — fused
        d_t = tl.load(down_bp, boundary_check=(0, 1), padding_option="zero")
        acc = tl.dot(hidden, d_t, acc)                 # acc += hidden @ down^T chunk

    tl.store(out_bp, acc.to(out_ptr.dtype.element_ty), boundary_check=(0, 1))
piecewhy it’s there
for ki ... N_ITERS_IOuter loop over intermediate chunks — each chunk needs its own gate/up dot-products, then feeds the down projection.
for kh ... N_ITERS_HInner loop over hidden chunks, accumulating this chunk’s g and u tiles. Two nested constexpr loops — the same nesting as decode-lab’s swiglu.py, which exists to keep tiles small for shared memory on a 6 GB GPU.
g = tl.dot(x_t, g_t, g)The gate projection, accumulated chunk by chunk. g_t is the transposed weight view.
hidden = g * tl.sigmoid(g) * uThe fusion moment: the elementwise gate happens in registers, between two matmul loops — no kernel boundary, no memory trip.
acc = tl.dot(hidden, d_t, acc)The down projection consumes the hidden tile directly.
tl.store(out_bp, ...)Exactly one write. The whole MLP: 1 launch, 5 passes.
Gotcha — I shipped this lesson with a stride bug

While writing the companion I declared the transposed weight views with the wrong strides ((1, I) instead of (1, H) for a (H, I) view of an (I, H) matrix) — the kernel came back NaN, exactly the silent-garbage failure mode decode-lab’s parity gate exists to catch. The fix was the lesson 4 homework #2 derivation, applied three times. If your fused kernel ever NaNs, check your strides first.

5When NOT to fuse

Fusion is not free. Everything fused lives in registers, and registers are the scarcest resource on a GPU:

situationverdict
Elementwise chains, norms, gates (memory-bound)✔ always fuse — data is read once, arithmetic is free
Big matmuls next to small elementwise steps✔ fuse the elementwise steps — the matmuls stay matmuls (they’re tensor-core-bound)
Fusing so much that register pressure spills to local memory✘ counterproductive — spilled registers become HBM traffic again, worse than the unfused version
Recomputation-heavy fusion (compute-bound kernels)✘ nothing to gain — fusion only removes memory movement, not math

The engineering rule: fuse memory-bound chains; leave compute-bound kernels alone. The swiglu kernel’s nested loops exist precisely because fusing everything into one giant tile would blow past shared memory — so it chunks instead.

6The pass counter

Watch the difference with your own eyes — run the two versions of the MLP and count the memory passes:

Unfused vs fused, animated

Each box is a compute step; dashed arrows between boxes are HBM round trips. Run both and compare the totals. W = weight read, X = input read, = write.

compute step HBM round trip (read or write)
0 memory passes

Pick a version, then press run.

7Homework — seriously, do this

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

1 · Fuse out = sigmoid(x) * x * 2 - 1 (the tanh trick) into one kernel

One load, one store, a chain of three elementwise ops in between. Verify against torch.sigmoid(x) * x * 2 - 1. This is the same shape as the silu gate — if you can write it without looking, fusion is yours.

2 · Break Part C’s strides on purpose, then fix them

Revert one of the weight views to (1, I) (the bug I shipped) and watch the max_err explode into NaN territory. Then fix it by deriving from first principles: an (H, I) view of an (I, H) matrix puts element (h, i) at address i*H + h, so strides are (1, H). Never guess strides — derive them.

3 · Add a bias to the fused gate (like nn.Linear’s bias)

Load a bias tile tl.load(b_ptr + offs) and add it inside the chain: (g + bias_g) * tl.sigmoid(g + bias_g) * (u + bias_u). Still one pass. Fusing bias is why Triton kernels beat F.linear + F.silu chains on memory-bound shapes.

4 · The real test: fuse rmsnorm + residual + scale into one kernel

Lesson 3’s rmsnorm already fuses residual. Now add a runtime scale factor: out = x * rsqrt(mean(x²) + eps) * w * alpha. Verify against the eager version. If you can do this, you can write the two fused kernels on decode-lab’s surgery map (rmsnorm + swiglu) from memory.

5 · Brain-bender: explain the swiglu loop nesting (why not one flat loop?)

The outer loop walks intermediate chunks; the inner loop walks hidden chunks to build this intermediate chunk’s gate/up. If you flattened it into one loop over hidden, you’d need the whole (BLOCK_M × I) hidden tile alive across all iterations — shared memory blows up on a 6 GB GPU. The nesting keeps the live tiles small: (BLOCK_M × BLOCK_H) in, (BLOCK_M × BLOCK_I) out. Same reason production kernels tile aggressively.

8Your learning journey

ConceptStatus
Grids, tiles, broadcasting, strides, block pointers (Lessons 1–4)✔ covered
Reductions & the two-pass dance (Lesson 3)✔ covered
Fusion = one load, register chain, one store✔ covered
Memory-pass accounting — 14 passes → 5✔ covered
The fused SwiGLU MLP — decode-lab’s swiglu.py pattern✔ covered
When NOT to fuse — register pressure & compute-bound kernels✔ covered
Next: performance — num_warps, num_stages, @triton.autotune, do_bench⏳ Lesson 6