Triton from zero · Lesson 3 of 7

Reductions: Softmax & Fused RMSNorm

The second pillar of Triton: combining many numbers into one. You’ll build a numerically-safe softmax, then write decode-lab’s actual fused RMSNorm kernel — the first real kernel on your surgery map.

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

1What a reduction is

So far every operation in your kernels was elementwise: one input element in, one output element out. x + y, x * 2, tl.exp(x) — the shape never changes.

But the moment you need a summary of a tile — the biggest value, the total, the average — you need the second pillar: a reduction.

The attendance analogy

Elementwise work is grading each exam in a stack: one exam in, one grade out. A reduction is taking attendance: you look at the whole row of desks and boil it down to one number — “3 students present”. The desks don’t change; a new number appears. That’s tl.sum: many numbers in, one number out.

Reductions are everywhere in a transformer, and all four of decode-lab’s target kernels lean on them:

kernelthe reduction inside it
rmsnorm.pymean of over the hidden dim — a sum, then a division
softmax (attention)row max and row sum over the attention scores
attention.pydot products sum over the key dim — that’s what tl.dot is: a tiled reduction of products
gemv.py (lm_head)sum of x[k] * w[k] over the hidden dim, per vocabulary row

Lesson 2’s matmul was secretly a reduction too: each output cell is the sum of products along K. tl.dot is just the fused, hardware-accelerated version of “multiply everything, then reduce”.

2tl.sum & friends: reduce, then broadcast back

Triton gives you four reduction primitives, all with the same shape rules:

tl.sum(x)                    # EVERYTHING → one scalar
tl.max(x)                    # (BLOCK_M, BLOCK_N) tile → scalar
tl.sum(x, axis=1)            # reduce along axis 1 only → (BLOCK_M,)
tl.max(x, axis=1)            # one value per row → (BLOCK_M,)
tl.min(x, axis=0)            # one value per column → (BLOCK_N,)
tl.mean(x, axis=1)           # average per row → (BLOCK_M,)

axis picks which dimension gets combined away; the other dimensions survive. The resulting column of scalars is exactly what you [:, None] back into a broadcast to shape the whole tile again (lesson 2’s trick, now doing real work):

m = tl.max(x, axis=1)        # (BLOCK_M,)  — one number per row
p = x - m[:, None]           # broadcast the column back over the tile
l = tl.sum(p, axis=1)        # (BLOCK_M,)  — one total per row
out = p / l[:, None]         # broadcast again
The dance

Reductions in Triton follow a fixed three-step dance: reduce a tile to a column of scalars (axis=1) → broadcast that column back with [:, None]combine it with the original tile. Every kernel in this lesson is this dance, wearing different hats. If you can do the dance, you can write softmax, rmsnorm, layernorm, min-max normalization — all of them.

3Softmax: the two-pass dance

Softmax turns a row of raw scores into probabilities that sum to 1:

p_i = exp(x_i) / sum_j exp(x_j)

Problem: exp(1000) is Infinity in fp32. Attention scores routinely reach large magnitudes, so the naive formula silently NaNs. The fix is one of the most famous one-liners in numerical computing: subtract the row max first. Because exp(x − m) / Σ exp(x − m) = exp(x) / Σ exp(x) — the math is unchanged, but the exponents are now ≤ 0, so nothing overflows. This costs one extra pass over the row — which is free here, because the whole row already lives in the program’s registers:

@triton.jit
def softmax_kernel(x_ptr, out_ptr, M, N,
                   BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr):
    pid_m = tl.program_id(0)
    offs_m = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)      # (BLOCK_M,)
    offs_n = tl.arange(0, BLOCK_N)                        # (BLOCK_N,)

    row_mask = offs_m[:, None] < M
    col_mask = offs_n[None, :] < N
    x = tl.load(x_ptr + offs_m[:, None] * N + offs_n[None, :],
                mask=row_mask & col_mask, other=-float("inf"))

    m = tl.max(x, axis=1)                                 # (BLOCK_M,) row maxima
    m = tl.where(offs_m < M, m, 0.0)                      # fully-masked rows: no -inf
    p = tl.exp(x - m[:, None])                            # broadcast back over the row
    l = tl.sum(p, axis=1)                                 # (BLOCK_M,) row sums
    out = p / l[:, None]

    tl.store(out_ptr + offs_m[:, None] * N + offs_n[None, :], out,
             mask=row_mask & col_mask)
LineWhat it really means
other=-float("inf")The mask trick: out-of-bounds columns load as minus infinity, so they lose the max pass — and exp(−inf − m) = 0 in the sum pass. Masked columns contribute nothing, not garbage.
m = tl.max(x, axis=1)Reduce each row to its largest score. One number per row.
m = tl.where(offs_m < M, m, 0.0)Safety pin: a fully masked row (M not divisible by BLOCK_M) would have m = −inf and then −inf − −inf = NaN. Pin it to 0 — those rows are never stored anyway.
p = tl.exp(x - m[:, None])Broadcast the max column back and shift. Exponents ≤ 0 — overflow impossible.
l = tl.sum(p, axis=1)Reduce each row to its normalizer. One number per row.
out = p / l[:, None]Broadcast and divide. Each row now sums to exactly 1.

The two-pass dance, on real numbers

Step through the stages for a row of scores. The hot preset uses scores around 1000 — the exact case where the naive formula explodes. Click a value column to see an element’s journey through all stages.

stage — / —

4Fused RMSNorm — the real decode-lab kernel

Now the payoff. RMSNorm is the normalization used by Qwen3 (and most modern LLMs): scale each row by the root mean square of its own values, then apply a learned per-column weight:

out_i = x_i / sqrt(mean(x²) + eps) * w_i

Why RMS and not LayerNorm? No mean subtraction, no batch statistics — just a per-token scale. Perfect for autoregressive decode, where a single token arrives at a time and there is no batch to average over. And it is exactly the reduction dance: elementwise, sum along the row, broadcast back, multiply.

Here is the kernel — this is the actual file in your repo, src/qwen3_lm/kernels/rmsnorm.py, lightly trimmed for the lesson:

@triton.jit
def rmsnorm_kernel(x_ptr, residual_ptr, weight_ptr, out_ptr, M, N, eps,
                   HAS_RESIDUAL: tl.constexpr, BLOCK: tl.constexpr):
    row = tl.program_id(0)                       # one program per row
    offs = tl.arange(0, BLOCK)
    mask = offs < N

    x = tl.load(x_ptr + row * N + offs, mask=mask, other=0.0).to(tl.float32)
    if HAS_RESIDUAL:                             # fused x + residual, no second kernel
        x += tl.load(residual_ptr + row * N + offs, mask=mask, other=0.0).to(tl.float32)
    weight = tl.load(weight_ptr + offs, mask=mask, other=0.0).to(tl.float32)

    variance = tl.sum(x * x, axis=0) / N         # mean of squares (0-padded)
    out = (x * tl.rsqrt(variance + eps) * weight).to(out_ptr.dtype.element_ty)
    tl.store(out_ptr + row * N + offs, out, mask=mask)
LineWhat it really means
row = tl.program_id(0)One program per row — a 1D grid of (M,). Each program owns a whole row; the reduction stays inside the program, no cross-program communication needed.
other=0.0Different mask trick than softmax: padded columns load as zero, so they add nothing to the sum of squares.
.to(tl.float32)All math in fp32. On a bf16 model this is the whistle rule: compute in fp32, store back in the input dtype — never accumulate in the model’s low precision.
if HAS_RESIDUAL:A tl.constexpr switch: one kernel, two behaviors. The residual add is fused into the same pass — no second kernel launch, no second read of x.
tl.sum(x * x, axis=0) / NThe dance, wearing its RMS hat: reduce the squared row to one number, divide by the real length N (zeros padded by the mask divide out correctly).
tl.rsqrt(...)One fused reciprocal-square-root instruction instead of 1.0 / tl.sqrt(...).
.to(out_ptr.dtype.element_ty)Cast back to the output dtype before storing — the “store back to input precision” half of the whistle rule.

The RMSNorm dance, on real numbers

Step through the stages for one row. Watch the mean of squares become a scale factor (rsqrt), then reshape the row. eps = 1e-6.

stage — / —

5Why fusing wins: the memory traffic ledger

What does the unfused torch reference do? (rmsnorm_eager in the repo):

variance = x.float().pow(2).mean(-1, keepdim=True)
out = weight * (x.float() * torch.rsqrt(variance + eps)).to(x.dtype)

Each of those torch operations is a separate kernel launch that reads or writes the full tensor from HBM (GPU memory). Count the trips for one row:

passunfused (torch)fused (triton)
read x for + mean1 read1 read total — x is loaded once, squared, summed, scaled, all in registers
write variance1 write
read variance + read x again, scale2 reads1 write total
write out1 write
total3 reads + 2 writes per row1 read + 1 write

The fused kernel moves 3× less data — and data movement is the actual cost on a GPU.

Why this is existential for decode-lab

decode is launch-bound and batch-one: each torch op is a kernel launch (~5–10 µs of overhead), and every layer of the model applies rmsnorm once (32 layers in Qwen3-4B). The fused kernel replaces 3–4 launches and 5 memory passes with one launch and two passes. For a single-token step, that’s often the difference between 2 ms and 8 ms per token. This is the entire thesis of the surgery map: not faster math, but fewer trips.

6Homework — seriously, do this

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

1 · Break the softmax on purpose: change other=-float("inf") to other=0.0

The row max now sees padded zeros, and exp(0 − m) for masked columns is not 0 — the row sum is wrong and every probability shifts. This is why the mask value must match the math: −inf for the max pass, 0 for the sum pass. Try the same swap in the rmsnorm kernel and explain why other=0.0 is right there.

2 · Write min-max normalization from scratch

Per row: out = (x − min) / (max − min). You need two reductions (tl.min(x, axis=1) and tl.max(x, axis=1)) — the same dance as softmax but with both ends. Use other=+inf for the min pass and other=-inf for the max pass.

@triton.jit
def minmax_kernel(x_ptr, out_ptr, M, N, BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr):
    pid_m = tl.program_id(0)
    offs_m = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)
    offs_n = tl.arange(0, BLOCK_N)
    row_mask = offs_m[:, None] < M
    col_mask = offs_n[None, :] < N
    x = tl.load(x_ptr + offs_m[:, None] * N + offs_n[None, :],
                mask=row_mask & col_mask, other=0.0)
    lo = tl.min(x, axis=1)
    hi = tl.max(x, axis=1)
    out = (x - lo[:, None]) / (hi[:, None] - lo[:, None])
    tl.store(out_ptr + offs_m[:, None] * N + offs_n[None, :], out,
             mask=row_mask & col_mask)
3 · Write LayerNorm from scratch (the full dance)

Add the mean subtraction that RMSNorm skips: out = (x − μ) / sqrt(σ² + ε) * w + b with μ = tl.mean(x, axis=1) and σ² = tl.sum((x − μ[:,None])², axis=1) / N. You now need two reductions and two broadcasts — the dance twice in one kernel. This is the whole normalization family in one skill.

4 · Fuse it yourself: rmsnorm + scale by a runtime scalar

Multiply the output by a scalar alpha passed at launch (like the HAS_RESIDUAL switch, but a plain runtime arg). Verify against the eager version. One kernel, one pass, two jobs — that is what “fused” means.

5 · Brain-bender: why is other=0.0 + / N correct in rmsnorm?

Masked columns load as 0, so they add nothing to sum(x²). Dividing by the real length N (not the padded BLOCK) gives exactly mean(x²) over the real elements. If you divided by BLOCK instead, every ragged row would come out slightly too big — a silent bug no parity test with nice shapes would catch.

7Your learning journey

ConceptStatus
1D/2D grids, tiles, broadcasting, strides (Lessons 1–2)✔ covered
Reductions — tl.sum/max/min/mean, axis semantics✔ covered
The reduce → broadcast → combine dance✔ covered
Two-pass softmax + the −inf/0 mask tricks✔ covered
Fused rmsnorm(+residual) — decode-lab’s kernels/rmsnorm.py pattern✔ covered
Fusion economics — memory trips & launch overhead✔ covered
Next: block pointers (tl.make_block_ptr, tl.advance) for the tiled matmul⏳ Lesson 4