Triton from zero · Lesson 6 of 7

Performance: Warps, Stages & Autotune

Correct kernels are step one; fast kernels are step two. The three knobs that decide speed — tile size, num_warps, num_stages — and the tools that stop you guessing: @triton.autotune and do_bench.

▶ 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 lesson6_perf.py). On the interpreter you get correctness checks; the timings need the real GPU — that’s the whole point of this lesson.

1The three knobs

Every Triton kernel you’ve written so far has three performance dials you never touched:

knobwhat it changesset at
tile sizes BLOCK_M/N/Khow much data one program owns (lessons 1–5)kernel args / constexpr
num_warpshow many warps (groups of 32 threads) execute one programlaunch: kernel[grid](..., num_warps=4)
num_stageshow many K-chunks the loop prefetches ahead (software pipelining)launch: kernel[grid](..., num_stages=3)

There is no “correct” value for any of them — it depends on the GPU, the sizes, the dtype. The whole craft is: pick a sane default, then measure. That’s what autotune automates.

2num_warps: people per kitchen

A program is executed by a team of threads. Triton hides the team from you — except for its size. num_warps=4 means 4 warps (128 threads) work on your one tile; num_warps=8 means 256.

The kitchen analogy, fifth course

Your tile is a banquet table of dishes. num_warps is how many line cooks stand at that table. Too few cooks: the table is served slowly, cooks idle between steps. Too many: they trip over each other and the coordination overhead eats the gains. And the cooks can only work on the table you gave them — a small tile with many warps means each warp has almost nothing to do.

Practical rule of thumb: tiles of 16–64 elements per dimension want 4 warps; 64–128 want 8. The compiler will tell you if you’re way off — but it can’t tell you what’s fastest. Measure.

3num_stages: software pipelining

Your matmul K-loop does the same dance every iteration: load the next A/B chunks from HBM into shared memory, compute with them. Loading is slow (memory latency); computing is fast. If the loop waits for each load before computing, it idles half the time.

Software pipelining fixes this the way a chef preps while cooking: while the tensor cores compute with chunk k, the memory system is already fetching chunks k+1 … k+num_stages. The loads are issued ahead, so the compute never waits:

kernel[grid](..., num_stages=3)   # 3 chunks in flight: computing k, prefetching k+1, k+2

num_stages is how deep that prefetch pipeline goes. Deeper = less waiting, but more registers and shared memory held for the in-flight chunks — too deep and you spill, and spilling is HBM traffic again. Typical sweet spot on a T4: 2–4.

4@triton.autotune: let the GPU decide

Since no one can predict the best knob values, don’t: benchmark them all at first launch and keep the winner.

@triton.autotune(
    configs=[
        triton.Config({"BLOCK_M": 16, "BLOCK_N": 16, "BLOCK_K": 16}, num_warps=4, num_stages=2),
        triton.Config({"BLOCK_M": 64, "BLOCK_N": 64, "BLOCK_K": 32}, num_warps=8, num_stages=3),
        triton.Config({"BLOCK_M": 128, "BLOCK_N": 128, "BLOCK_K": 64}, num_warps=8, num_stages=4),
        # ... usually 4–8 configs
    ],
    key=["M", "N", "K"],               # re-tune per (M, N, K) — cached after the first call
)
@triton.jit
def matmul_kernel(...):
    ...                                 # unchanged: BLOCK_* come from the winning config

grid = lambda meta: (triton.cdiv(M, meta["BLOCK_M"]), triton.cdiv(N, meta["BLOCK_N"]))
matmul_kernel[grid](a, b, c, M, N, K, ...)   # first call benchmarks, rest use the cache

Three things to notice:

  • The grid becomes a lambda: it reads the tile size from the config, because the grid depends on BLOCK_M which autotune is about to choose.
  • BLOCK_M/N/K disappear from the launch — the winning config injects them.
  • The benchmark happens once per key (per unique M, N, K), then the best config is cached. That first-call cost is why you don’t list 243 configs — each one takes a real timing run.
Gotcha — the config space explodes

3 tile sizes × 3 tile sizes × 3 tile sizes × 3 warp counts × 3 stage counts = 243 configs. At ~1–2 s of benchmarking each, that’s minutes of your first launch. Production kernels list 4–8 sensible configs, or use prune_configs_by to drop configs that can’t win. The playground below shows how fast it grows.

5do_bench & the launch-bound truth

Timing kernels naively with time.time() lies: the first call includes compilation, the GPU pipelines calls, and the clock is noisy. The right tool:

import triton.testing as tt

fn()                                 # warmup: compile + fill caches
us = tt.do_bench(fn, rep=5) * 1e3    # median of several runs, in µs

do_bench clears the L2 cache between runs (so you measure cold-memory reality), warms up, and reports a stable median. Rule: never trust a number you didn’t get from do_bench.

The launch-bound truth for decode

A kernel launch costs ~5–10 µs of overhead — trivial next to a 200 µs matmul, but brutal next to a 15 µs GEMV. decode-lab’s kernels are tiny by design (batch 1!), so on T4 a GEMV can spend half its life just being launched. That’s why lesson 5’s fusion matters more than any knob: fewer launches beats faster kernels at decode sizes. Autotune optimizes the kernel; fusion optimizes the program. The benchmark explorer below shows both effects.

6Playground

The config explosion

Check what you’d list in an autotune configs array — watch the space multiply. Each config costs ~1–2 s of benchmarking on first launch.

tile sizes
num_warps
num_stages

Benchmark explorer (plausible T4 numbers, 256³ matmul)

Bars are per-config do_bench times. Drag the launch overhead slider — that’s the decode tax: tiny kernels feel it, big ones don’t. Click a bar.

launch overhead: 0 µs per kernel

Click a bar for the config story.

7Homework — seriously, do this

Run lesson6_perf.py on Colab (it’s the first lesson where the local box can’t show you the interesting part), then try each of these. Hints are collapsible.

1 · Read Part A’s output: which config won, and can you guess why?

On a T4, the 128×128×64 config usually wins for big square matmuls: bigger tiles mean fewer programs and more reuse per program, and num_stages=4 keeps the tensor cores fed. Try the same autotune on a tall skinny shape (M=64, N=4096, K=4096) — a different config often wins, because the tile aspect ratio changes what fits in shared memory.

2 · Add your own config to the list and see if it wins

Try e.g. BLOCK_M=64, BLOCK_N=32, BLOCK_K=64, num_warps=4, num_stages=2. You’ll probably lose to the existing ones — but on a different shape or GPU you might win. That’s autotune’s whole deal: the answer is hardware-dependent, and the tool finds it per-hardware.

3 · Sweep num_warps alone (1, 2, 4, 8, 16) with the tile fixed at 64×64

Extend Part B. Watch the curve: too few warps = idle tensor cores; too many = register pressure and sync overhead. The sweet spot for 64×64 on T4 is usually 4–8. If 16 wins, your GPU is different from the tutorial’s — that’s fine, the lesson is the method.

4 · The real test: GEMV — matvec with M=1, N=32000, K=4096 (lm_head)

This is decode’s actual workload. Write a 1D kernel (one program per output column block: out[n] = sum_k x[k] * w[n, k]) with a K-loop. Then autotune it and compare the winning config with the 256³ matmul’s winner — completely different, because GEMV is memory-bound: the winning config maximizes read bandwidth, not tensor-core utilization. Bonus: measure it with do_bench and compute the launch-overhead share.

@triton.autotune(
    configs=[
        triton.Config({"BLOCK_K": 64}, num_warps=4, num_stages=2),
        triton.Config({"BLOCK_K": 128}, num_warps=4, num_stages=3),
        triton.Config({"BLOCK_K": 256}, num_warps=8, num_stages=3),
    ],
    key=["K"],
)
@triton.jit
def gemv_kernel(x_ptr, w_ptr, out_ptr, K, N,
                BLOCK_N: tl.constexpr, BLOCK_K: tl.constexpr):
    pid = tl.program_id(0)
    offs_n = pid * BLOCK_N + tl.arange(0, BLOCK_N)
    acc = tl.zeros((BLOCK_N,), dtype=tl.float32)
    for k in range(0, tl.cdiv(K, BLOCK_K)):
        offs_k = k * BLOCK_K + tl.arange(0, BLOCK_K)
        x_v = tl.load(x_ptr + offs_k, mask=offs_k < K, other=0.0)
        w_t = tl.load(w_ptr + offs_n[:, None] * K + offs_k[None, :],
                      mask=offs_k[None, :] < K, other=0.0)
        acc += tl.sum(x_v[None, :] * w_t, axis=1)
    tl.store(out_ptr + offs_n, acc, mask=offs_n < N)
5 · Brain-bender: why does do_bench clear the L2 cache?

Without the flush, the second run of a kernel would find its input already in L2 cache from the first run — measuring cache hits that real workloads (which stream fresh data) never get. The flush makes every run pay cold-memory prices, so the median reflects production reality. A kernel that “wins” only when cached is a mirage — this is the whistle lesson: fast-but-wrong benchmarks invalidate conclusions.

8Your learning journey

ConceptStatus
Grids, tiles, broadcasting, strides, block pointers (Lessons 1–4)✔ covered
Reductions & the two-pass dance (Lesson 3)✔ covered
Fusion & memory-pass economics (Lesson 5)✔ covered
num_warps & num_stages✔ covered
@triton.autotune — configs, key, the grid lambda✔ covered
triton.testing.do_bench — honest timings✔ covered
Next: the capstone — mini FlashAttention (QKᵀ, softmax, PV in one kernel)⏳ Lesson 7