Highly optimized Triton snippets
Fusion and tile-shape choice are the two levers that make a Triton kernel fast. Everything else — autotuning, swizzling, pipelining — is refinement around a tile you have already justified.
Fast Triton is choosing the right tile. A good tile does four things at once: it reuses data (so each byte read from HBM serves many FLOPs), it carries enough work per program (so launch and scheduling overhead is amortized), it keeps the live state in the register/shared budget (so the SM does not spill or lose occupancy), and it maps cleanly onto tensor cores or wide memory transactions (so the hardware's fast paths actually fire).
So before you write a config, reason about the data flow: what stays live in registers for the duration of the program, what streams from HBM tile by tile, what is reused across iterations or across neighboring programs, and which axis is reduced. The answers fix the tile's ownership and rough shape. Only then do you autotune — autotuning searches a small neighborhood around a tile you already understand; it does not invent the right structure for you.
Why fusion is Triton's highest-return use case
Chaining PyTorch operators is correct but wasteful. Each operator is its own CUDA kernel launch, and each one reads its inputs from HBM and writes its output back to HBM. Consider y = gelu(x + bias) written as two ops: x + bias reads x, reads bias, and writes a full-size temporary t to HBM; then gelu(t) reads t back and writes y. The intermediate t exists only to be round-tripped through memory.
The cost that matters here is arithmetic intensity: FLOPs performed per byte moved to and from HBM. Elementwise ops have tiny intensity — a GELU is a handful of FLOPs per element, but each element costs a load and a store (8 bytes round trip in fp32). These kernels are memory-bound: the ALUs sit idle waiting on HBM. The only way to speed them up is to move fewer bytes.
Fusion does exactly that. One kernel reads each input once, does all the arithmetic while the values are live in registers, and writes the result once. The intermediate never touches HBM, and you pay one launch instead of several.
For a chain of k elementwise ops over an N-element tensor, the unfused cost is roughly k reads + k writes of N elements. The fused cost is 1 read of each distinct input + 1 write. As k grows, the win approaches a factor of k — and it is pure bandwidth, with no change to the math.
Snippet 1 — fused bias + GELU
The classic transformer epilogue: add a per-column bias, then apply GELU. We fuse the add and the activation into one elementwise pass. The bias is indexed by the column of each element, so from a flat offset offs over a row-major (rows, D) tensor we recover the column with d = offs % D and gather bias[d].
import triton
import triton.language as tl
@triton.jit
def gelu(x):
# tanh approximation of GELU (matches torch's approximate="tanh")
# 0.5 * x * (1 + tanh( sqrt(2/pi) * (x + 0.044715 * x**3) ))
c = 0.7978845608028654 # sqrt(2/pi)
inner = c * (x + 0.044715 * x * x * x)
return 0.5 * x * (1.0 + tl.tanh(inner))
@triton.jit
def bias_gelu_kernel(x_ptr, b_ptr, y_ptr,
n_elements, D,
BLOCK: tl.constexpr):
pid = tl.program_id(0)
offs = pid * BLOCK + tl.arange(0, BLOCK)
mask = offs < n_elements
x = tl.load(x_ptr + offs, mask=mask, other=0.0)
d = offs % D # column index of each element
b = tl.load(b_ptr + d, mask=mask, other=0.0)
y = gelu(x + b) # add + activation, all in registers
tl.store(y_ptr + offs, y, mask=mask)
def bias_gelu(x, bias):
# x: (rows, D), bias: (D,)
y = torch.empty_like(x)
n = x.numel()
D = x.shape[-1]
grid = lambda meta: (triton.cdiv(n, meta["BLOCK"]),)
bias_gelu_kernel[grid](x, bias, y, n, D, BLOCK=1024)
return y
GELU's transcendentals are not the bottleneck and fusing them does not speed the math up — the ALU was never the limiter. The win is that we deleted the intermediate x + bias tensor (one full HBM write + one full HBM read removed) and collapsed two launches into one. This is a bandwidth and overhead win, which is exactly what a memory-bound op needs.
Snippet 2 — numerically stable row softmax
Softmax over the last axis. We assign one program per row, load the whole row into registers, and do the reduction there. The naive exp(x) / sum(exp(x)) overflows: a single large logit makes exp inf, and inf/inf is NaN. The fix is to subtract the row max first — mathematically a no-op (it cancels in the ratio) but it bounds every exponent at exp(0) = 1.
@triton.jit
def softmax_kernel(x_ptr, y_ptr,
row_stride, n_cols,
BLOCK: tl.constexpr):
row = tl.program_id(0)
cols = tl.arange(0, BLOCK)
mask = cols < n_cols
ptrs = x_ptr + row * row_stride + cols
# other=-inf so masked lanes never win the max and contribute 0 to the sum
vals = tl.load(ptrs, mask=mask, other=-float("inf"))
m = tl.max(vals, axis=0) # row max, for stability
num = tl.exp(vals - m) # in [0, 1], no overflow
den = tl.sum(num, axis=0)
out = num / den
tl.store(y_ptr + row * row_stride + cols, out, mask=mask)
The masked-load fill is doing double duty here, and getting it right is the whole game on the edge tile (see Correctness under masking below). Filling with -inf means a padding lane can never be the max, and its exp(-inf - m) = 0 contributes nothing to the denominator. Fill with 0 instead and the padding lanes inject exp(0 - m) into the sum, silently corrupting the result.
This kernel assumes the whole row fits in one tile — i.e. BLOCK >= n_cols (with BLOCK the next power of two above n_cols). That is fine for vocab/feature sizes up to a few tens of thousands. When a row is too large to hold in registers, you cannot take a single max and a single sum; you need the online (streaming) softmax that maintains a running max and running denominator across tiles and rescales as it goes. That is the technique behind FlashAttention — we build it in lesson 03.
Snippet 3 — fused RMSNorm
RMSNorm normalizes each row by its root-mean-square and scales by a learned per-feature weight: y = x * rsqrt(mean(x^2) + eps) * w. Like softmax it is a per-row reduction, so again one program per row. The subtlety is precision.
@triton.jit
def rmsnorm_kernel(x_ptr, w_ptr, y_ptr,
row_stride, D,
eps,
BLOCK: tl.constexpr):
row = tl.program_id(0)
cols = tl.arange(0, BLOCK)
mask = cols < D
ptrs = x_ptr + row * row_stride + cols
# cast to fp32 for the reduction; other=0 is sum-safe
x = tl.load(ptrs, mask=mask, other=0.0).to(tl.float32)
ss = tl.sum(x * x, axis=0) # sum of squares, fp32
inv_rms = tl.rsqrt(ss / D + eps) # 1 / sqrt(mean(x^2) + eps)
w = tl.load(w_ptr + cols, mask=mask, other=0.0).to(tl.float32)
y = x * inv_rms * w
tl.store(y_ptr + row * row_stride + cols, y.to(tl.float32), mask=mask)
If x is bf16 or fp16, summing x*x in the input dtype loses precision badly: bf16 has 8 mantissa bits, so once the running sum grows past ~256x the magnitude of a new term, that term rounds away entirely. Across thousands of features the error compounds and the norm drifts. Casting to fp32 (.to(tl.float32)) before the reduction keeps the sum accurate; you cast back only at the store. This is the same rule we apply to matmul: accumulate reductions in fp32 regardless of the I/O dtype. The reduction is also single-pass — one load of the row, one sum, done — because the whole row is live in registers.
Snippet 4 — tiled matmul with tl.dot
The canonical compute-bound kernel. Each program owns one BM×BN output tile of C = A @ B. It walks the shared K dimension in chunks of BK, loading an A tile and a B tile each step and accumulating their product into a register-resident accumulator.
@triton.jit
def matmul_kernel(a_ptr, b_ptr, c_ptr,
M, N, K,
stride_am, stride_ak,
stride_bk, stride_bn,
stride_cm, stride_cn,
BM: tl.constexpr, BN: tl.constexpr, BK: tl.constexpr):
pid_m = tl.program_id(0)
pid_n = tl.program_id(1)
offs_m = pid_m * BM + tl.arange(0, BM)
offs_n = pid_n * BN + tl.arange(0, BN)
offs_k = tl.arange(0, BK)
# 2D pointer grids into A (BM x BK) and B (BK x BN)
a_ptrs = a_ptr + (offs_m[:, None] * stride_am + offs_k[None, :] * stride_ak)
b_ptrs = b_ptr + (offs_k[:, None] * stride_bk + offs_n[None, :] * stride_bn)
acc = tl.zeros((BM, BN), dtype=tl.float32) # lives in registers
for k in range(0, K, BK):
a = tl.load(a_ptrs,
mask=(offs_m[:, None] < M) & (offs_k[None, :] < (K - k)),
other=0.0)
b = tl.load(b_ptrs,
mask=(offs_k[:, None] < (K - k)) & (offs_n[None, :] < N),
other=0.0)
acc += tl.dot(a, b) # fp16/bf16 in, fp32 acc
a_ptrs += BK * stride_ak
b_ptrs += BK * stride_bk
c = acc.to(c_ptr.dtype.element_ty)
c_ptrs = c_ptr + offs_m[:, None] * stride_cm + offs_n[None, :] * stride_cn
c_mask = (offs_m[:, None] < M) & (offs_n[None, :] < N)
tl.store(c_ptrs, c, mask=c_mask)
Map this onto the four tile properties from the opening:
- What stays live: the
acctile (BM×BNfp32 values) lives in registers for the entire K loop. This is the dominant register cost and the main occupancy constraint. - What streams: the
AandBtiles flow through the loop oneBK-chunk at a time. They are loaded, multiplied, and discarded. - What is reused: each loaded
Atile (BM×BK) participates inBNoutput columns, and eachBtile inBMrows. That reuse is exactly why a big output tile raises arithmetic intensity. - What is reduced: the K axis — which is why it is the loop, not the grid.
tl.dot(a, b) lowers to a tensor-core MMA instruction when the operands fit the hardware's fast path: fp16/bf16 (or tf32/fp8 on newer GPUs) inputs, fp32 accumulator, and tile dimensions that are multiples of the MMA shape (16 is the safe minimum on the contraction dim). Feed it fp32 inputs or a contraction dim of 13 and it falls back to a slow path or refuses to compile. The acc = tl.zeros(..., tl.float32) plus acc += tl.dot(...) pattern is the tensor-core idiom: low-precision multiply, high-precision accumulate.
Snippet 5 — autotuning the tile
The algorithm above is fixed; what is not fixed is the tile shape and the launch parameters. @triton.autotune benchmarks a list of candidate configs and caches the winner per distinct key.
@triton.autotune(
configs=[
triton.Config({"BM": 128, "BN": 256, "BK": 64}, num_warps=8, num_stages=3),
triton.Config({"BM": 256, "BN": 128, "BK": 64}, num_warps=8, num_stages=3),
triton.Config({"BM": 128, "BN": 128, "BK": 32}, num_warps=4, num_stages=4),
triton.Config({"BM": 64, "BN": 64, "BK": 32}, num_warps=2, num_stages=5),
],
key=["M", "N", "K"],
)
@triton.jit
def matmul_kernel(...): # same body as Snippet 4
...
The knobs trade off against each other:
| Knob | Bigger means | Cost of bigger |
|---|---|---|
BM × BN | more reuse per loaded tile, higher arithmetic intensity | larger fp32 accumulator in registers → fewer concurrent programs (lower occupancy), risk of spills |
BK | more work per K-iteration, fewer iterations, fewer pointer updates | larger A/B tiles in shared memory and registers per step |
num_warps | more parallel lanes per program to cover the tile | each warp needs registers; too many starves occupancy |
num_stages | deeper load/compute pipeline, more HBM latency hidden | each stage buffers a K-tile in shared memory; useless for non-dot kernels |
Autotuning explores a neighborhood of configs around a structure you already chose. It will not turn a memory-bound elementwise kernel into a tensor-core kernel, and it will not discover that you should have fused two ops. Fix the algorithm and the tile ownership by reasoning first; then let autotune pick the exact sizes for the hardware. Note autotune re-benchmarks for every distinct value of key — here (M, N, K) — so a workload with many shapes pays the search cost repeatedly. Keep key to the dimensions that actually change the optimal tile.
Snippet 6 — grouped / swizzled program ordering for L2 reuse
With a 2D grid, the default program order sweeps a full row of output tiles before moving to the next row. Along that row every program loads a fresh B column tile but the same A row tile — so the B tiles a program needs were loaded by far-away programs long ago and have been evicted from L2. We can keep the math identical but change the visitation order so that programs running close together in time touch overlapping B tiles, which then stay hot in L2.
@triton.jit
def matmul_grouped(a_ptr, b_ptr, c_ptr, M, N, K, ...,
BM: tl.constexpr, BN: tl.constexpr, BK: tl.constexpr,
GROUP_M: tl.constexpr):
pid = tl.program_id(0) # 1D launch this time
num_pid_m = tl.cdiv(M, BM)
num_pid_n = tl.cdiv(N, BN)
# remap a flat pid into (pid_m, pid_n) grouped along M
num_pid_in_group = GROUP_M * num_pid_n
group_id = pid // num_pid_in_group
first_pid_m = group_id * GROUP_M
group_size_m = min(num_pid_m - first_pid_m, GROUP_M)
pid_m = first_pid_m + ((pid % num_pid_in_group) % group_size_m)
pid_n = (pid % num_pid_in_group) // group_size_m
# ... identical tile body using pid_m, pid_n ...
Instead of marching across an entire output row, we process a GROUP_M-tall block of rows together before advancing in N. Programs in the same group reuse the same set of B column tiles within a short window, so those tiles are served from L2 instead of HBM. The result C is byte-for-byte identical — we only reordered which program computes which tile and when. This is one of the cheapest large wins in a matmul kernel.
The software pipeline and num_stages
Inside the K loop there are two phases: load the next A/B tiles from HBM, and compute the tl.dot. Done naively they serialize — the SM stalls on the load before it can multiply. The Triton compiler can instead prefetch: issue the load for K-tile i+1 while the tensor cores are still chewing on K-tile i. That overlap hides HBM latency behind useful compute. num_stages is how many K-tiles deep this prefetch pipeline runs.
The prefetch overlap pays off when there is real compute to hide the load behind — i.e. a tl.dot loop. For a tiny elementwise kernel there is no loop and almost no compute, so extra stages just burn shared memory for nothing. Set num_stages high for matmul/attention; leave it at the default (or 1) for elementwise and simple reductions.
Correctness under masking
When a tensor dimension is not a multiple of the tile, the boundary tile reads and writes out-of-range addresses unless masked. Two rules:
- Mask every load and every store. An unmasked load past the end reads garbage (or faults); an unmasked store past the end corrupts neighboring memory. The mask on the store must match the valid region of the output tile.
- The
otherfill must be reduction-safe. The value masked-out lanes take has to be the identity of whatever reduction consumes it:0.0for a sum (and for sum-of-squares),-inffor a max,+inffor a min. A wrong fill does not crash — it quietly poisons only the edge tiles, so the kernel looks correct on power-of-two shapes and fails on the others.
In the softmax kernel, loading padding lanes with other=0.0 instead of -inf compiles, runs, and gives correct answers whenever n_cols is a power of two (no padding lanes exist). The moment n_cols is, say, 1000 with BLOCK=1024, the 24 padding lanes each add exp(0 - m) to the denominator and every probability in that row comes out too small. No error, no NaN — just wrong numbers on exactly the inputs your unit test on shape 1024 never exercised.
Pitfalls
- BLOCK should be a power of two.
tl.arangeand the tile machinery assume power-of-two extents; pick the next power of two above your true size and mask the remainder. tl.arangebounds must betl.constexpr. The range is resolved at compile time —tl.arange(0, BLOCK)needsBLOCKto be a constexpr, never a runtime value.- Large tensors need int64 offsets. Offsets default to int32; once
row * row_stride + colexceeds ~2.1 billion the index silently overflows and wraps. Compute offsets in int64 (e.g. cast the base index) for big tensors. tl.dotwants the contraction dim a multiple of 16. That is the tensor-core MMA granularity; an off-multiple K either compiles to a slow path or errors. Pad or pickBKaccordingly.- Non-contiguous tensors need real strides. If you index assuming row-major but the tensor is transposed or sliced, you read the wrong elements. Either call
.contiguous()first or pass the actualtensor.stride(i)values into the kernel and use them everywhere.
Snippet 7 — benchmarking correctly
A kernel is only "optimized" relative to a measured baseline, on the right unit. Use triton.testing.do_bench, which warms up the GPU and the autotune cache, then times many reps and returns the median milliseconds.
import torch, triton
def bench_matmul(M, N, K, dtype=torch.float16):
a = torch.randn((M, K), device="cuda", dtype=dtype)
b = torch.randn((K, N), device="cuda", dtype=dtype)
ms_triton = triton.testing.do_bench(lambda: matmul(a, b),
warmup=25, rep=100)
ms_torch = triton.testing.do_bench(lambda: torch.matmul(a, b),
warmup=25, rep=100)
# matmul is compute-bound: count FLOPs = 2*M*N*K (one mul + one add per term)
flops = 2 * M * N * K
tflops_triton = flops / (ms_triton * 1e-3) / 1e12
tflops_torch = flops / (ms_torch * 1e-3) / 1e12
print(f"triton {tflops_triton:7.1f} TFLOP/s torch {tflops_torch:7.1f} TFLOP/s")
def bench_rmsnorm(rows, D, dtype=torch.float16):
x = torch.randn((rows, D), device="cuda", dtype=dtype)
w = torch.randn((D,), device="cuda", dtype=dtype)
ms = triton.testing.do_bench(lambda: rmsnorm(x, w), warmup=25, rep=100)
# memory-bound: count bytes moved = read x + read w + write y
bytes_moved = x.numel() * x.element_size() \
+ w.numel() * w.element_size() \
+ x.numel() * x.element_size()
gbps = bytes_moved / (ms * 1e-3) / 1e9
print(f"rmsnorm {gbps:7.1f} GB/s")
Report TFLOP/s for compute-bound kernels (matmul: 2*M*N*K FLOPs) and compare against the GPU's peak tensor-core throughput. Report GB/s for memory-bound kernels (count every byte read and written) and compare against peak HBM bandwidth. Quoting GB/s for a matmul or TFLOP/s for a softmax tells you nothing about whether you are near the roofline. Always benchmark against torch with the same dtype, shape, and warmup so the comparison is honest.
Optimization answer template
When asked to make a Triton kernel fast (in an interview or in real life), walk this in order:
- Classify the op. Elementwise, reduction, dot (matmul/attention), or a fused epilogue on top of one of those? This decides whether you are memory-bound or compute-bound, and therefore which lever matters.
- Choose tile ownership. Decide what one program is responsible for — a block of flat elements (elementwise), one row (per-row reduction like softmax/RMSNorm), or one
BM×BNoutput tile (matmul). This follows directly from which axis is reduced. - Pick tile sizes from reuse. Reason about what stays live in registers, what streams from HBM, and what is reused, then size the tile big enough to amortize launches and feed tensor cores but small enough to stay in the register/shared budget.
- Get correctness with masks and strides. Mask every load and store, choose reduction-safe
otherfills, accumulate reductions in fp32, and use real strides (or.contiguous()) so non-contiguous inputs are read correctly. - Autotune nearby configs. Fix the algorithm first, then search a small neighborhood of
BM/BN/BK,num_warps,num_stageswith@triton.autotunekeyed on the dimensions that change the optimum. - Measure the right unit.
do_benchwith warmup, GB/s for memory-bound and TFLOP/s for compute-bound, compared against a same-dtype torch baseline and the hardware roofline.