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
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) | reads | writes |
|---|---|---|
g = F.linear(x, gate_w) | x, gate_w | g |
u = F.linear(x, up_w) | x, up_w | u |
F.silu(g) | g | g |
silu(g) * u | g, u | h |
F.linear(h, down_w) | h, down_w | out |
| total | 9 reads | 5 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.
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))
| piece | why it’s there |
|---|---|
for ki ... N_ITERS_I | Outer loop over intermediate chunks — each chunk needs its own gate/up dot-products, then feeds the down projection. |
for kh ... N_ITERS_H | Inner 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) * u | The 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. |
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:
| situation | verdict |
|---|---|
| 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:
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
| Concept | Status |
|---|---|
| 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 |