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:
| knob | what it changes | set at |
|---|---|---|
tile sizes BLOCK_M/N/K | how much data one program owns (lessons 1–5) | kernel args / constexpr |
num_warps | how many warps (groups of 32 threads) execute one program | launch: kernel[grid](..., num_warps=4) |
num_stages | how 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.
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
gridbecomes a lambda: it reads the tile size from the config, because the grid depends onBLOCK_Mwhich autotune is about to choose. BLOCK_M/N/Kdisappear 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.
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.
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
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
| Concept | Status |
|---|---|
| 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 |