How to write GPU kernels in Python at block granularity with Triton — the abstraction level where you reason about program instances and tiles instead of individual SIMT threads. By the end you should be able to write vector-add, fused-softmax, and tiled-matmul kernels from scratch, autotune them, explain how Triton lowers to PTX, and decide when to reach for torch.compile, hand-written Triton, or CUDA/CUTLASS.
This lesson assumes you’ve internalized lessons 04–06: the CUDA SIMT execution model and memory hierarchy (04), GEMM tiling through shared memory (05), and fusion / FlashAttention (06). We’ll repeatedly contrast the Triton mental model against those.
1. Learning map
graph TD
A["Lesson 04:<br/>CUDA SIMT threads,<br/>memory hierarchy"] --> W["Why Triton:<br/>block-level programming"]
B["Lesson 05:<br/>GEMM SMEM tiling"] --> W
C["Lesson 06:<br/>fusion, FlashAttention"] --> W
W --> PM["Programming model:<br/>@triton.jit, program_id,<br/>arange, load/store+mask, dot"]
PM --> V["Ex 1: vector add<br/>(1D grid, masking)"]
V --> S["Ex 2: fused softmax<br/>(one block per row)"]
S --> M["Ex 3: tiled matmul<br/>(SMEM tiling → a few lines)"]
M --> AT["Autotuning<br/>@triton.autotune"]
AT --> CP["Compilation pipeline<br/>Python → TTIR → TTGIR<br/>→ LLVM → PTX"]
CP --> TI["torch.compile /<br/>TorchInductor<br/>(Triton, automatically)"]
TI --> DF["Decision framework:<br/>eager → compile → Triton<br/>→ CUTLASS → cuBLAS"]
style W fill:#44a,color:#fff
style DF fill:#4a4,color:#fff
Figure 1 — Dependency graph. The CUDA/GEMM/fusion background from lessons 04–06 is exactly what makes Triton’s abstractions feel motivated: Triton automates the tedious parts (coalescing, SMEM staging, pipelining) while leaving you the parts that actually need judgment (tile shapes, program structure).
2. Why Triton — a different altitude on the same hardware
In lesson 04 you wrote CUDA in the SIMT model: you author the program for one thread, then reason about how 32 of them form a warp, how warps form a block, how you hand-stage data into shared memory, __syncthreads(), and how threads must be arranged so their global loads coalesce into 128-byte transactions. Correctness and performance both live at the thread level. That’s maximal control — and maximal surface area for bugs.
Triton, introduced by Tillet, Kung & Cox (2019)1 and open-sourced by OpenAI in 20212, moves you up one level. You write the program for one block — Triton calls it a program instance — and you operate on tiles (small dense sub-tensors, e.g. a 128×64 block). The compiler owns everything inside the block:
Intra-block parallelism — it decides how the tile maps onto threads/warps (you only hint via num_warps).
Memory coalescing — a tl.load of a contiguous tile is automatically emitted as coalesced global accesses.
Shared memory — operands of block-level ops like tl.dot are automatically staged into SMEM, allocated and synchronized via liveness analysis. You don’t write __shared__ or __syncthreads().
Software pipelining — loop iterations are pipelined (double/triple buffering) via num_stages.
What you still control: the grid (how many program instances), the tile/block sizes, the program structure (loop order, which axis each program handles, masking), and coarse knobs (num_warps, num_stages).
graph TB
subgraph CUDA["CUDA — SIMT (lesson 04)"]
direction TB
C0["You author: one THREAD"]
C1["thread → warp (32) → block"]
C2["YOU manage:<br/>__shared__ staging,<br/>__syncthreads(),<br/>coalescing layout,<br/>register/tile mapping"]
C0 --> C1 --> C2
end
subgraph TRITON["Triton — block-level"]
direction TB
T0["You author: one BLOCK<br/>(program instance)"]
T1["operate on TILES<br/>(e.g. 128×64)"]
T2["COMPILER manages:<br/>SMEM staging,<br/>sync, coalescing,<br/>thread mapping, pipelining"]
T0 --> T1 --> T2
end
Figure 2 — Two altitudes on the same silicon. CUDA: you are the thread and manually orchestrate the block. Triton: you are the block and manipulate whole tiles; the compiler synthesizes the thread-level program. Neither is “higher level” in capability — Triton still targets tensor cores and shared memory — it’s a different granularity of control.
The mental-model shift is the whole game
The most common failure mode coming from CUDA is to keep thinking in threads. In Triton there is no threadIdx. tl.program_id(0) gives you the block index, not a thread index. A single tl.load(ptr + tl.arange(0, BLOCK_SIZE)) is not “one thread loading one element” — it is the whole block loading a BLOCK_SIZE-element tile, which the compiler spreads across all threads of the block and coalesces. If you catch yourself asking “what does thread 5 do here?”, you’re at the wrong altitude — ask “what tile does this program instance own?”
Why this abstraction pays off
The fused-softmax and FlashAttention-style wins from lesson 06 require warp-level reductions and careful SMEM choreography in CUDA. In Triton those become tl.max(row, axis=0) and tl.sum(...) on a tile — a few lines. You keep ~80–95% of hand-CUDA performance for most memory-bound and many compute-bound ops3, at a fraction of the authoring cost, and the same kernel recompiles for NVIDIA (PTX) or AMD (AMDGCN)4.
3. The programming model
The vocabulary you’ll use in every kernel:
Construct
What it does
@triton.jit
Marks a Python function as a Triton kernel; JIT-compiled on first call, keyed on arg dtypes/constexpr values.
tl.program_id(axis)
Index of this program instance along a grid axis (0/1/2). The Triton analogue of blockIdx.
tl.num_programs(axis)
Total program instances along an axis (grid size).
tl.arange(0, N)
A compile-time-sized [0..N) tile of offsets; N must be a power-of-two constexpr.
tl.load(ptr, mask=, other=)
Gather a tile from global memory; mask guards out-of-bounds lanes, other fills them.
tl.store(ptr, val, mask=)
Scatter a tile to global memory, masked.
tl.dot(a, b, acc)
Tile matmul acc + a @ b; uses tensor cores when dims are multiples of 16.
tl.max/tl.sum/tl.min
Tile reductions along axis.
BLOCK_SIZE: tl.constexpr
A compile-time constant — specializes the kernel and enables unrolling/vectorization.
grid launch kernel[grid](...)
grid is a tuple or a lambda meta: (...); launches that many program instances.
The constexpr distinction is load-bearing: BLOCK_SIZE is baked into the compiled kernel (different values → different compiled binaries), which is exactly what lets the compiler unroll tl.arange loops and pick vector widths. Runtime scalars like n_elements are ordinary arguments.
4. Worked example 1 — vector add
The “hello world.” One 1D grid; each program instance owns a BLOCK_SIZE-element tile. Mirrors the official vector-add tutorial.
import torchimport tritonimport triton.language as tl@triton.jitdef add_kernel( x_ptr, # *Pointer* to first input y_ptr, # *Pointer* to second input out_ptr, # *Pointer* to output n_elements, # runtime scalar (NOT constexpr) BLOCK_SIZE: tl.constexpr, # tile size, compile-time constant): # Which block am I? (analogue of CUDA blockIdx.x) pid = tl.program_id(axis=0) # This block owns elements [block_start, block_start + BLOCK_SIZE) block_start = pid * BLOCK_SIZE offsets = block_start + tl.arange(0, BLOCK_SIZE) # a whole tile of indices # Guard the tail: the last block may run past n_elements. mask = offsets < n_elements # Whole-tile loads/stores; the compiler coalesces these. x = tl.load(x_ptr + offsets, mask=mask) y = tl.load(y_ptr + offsets, mask=mask) tl.store(out_ptr + offsets, x + y, mask=mask)def add(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor: out = torch.empty_like(x) n_elements = out.numel() # grid is a lambda over the compile-time meta-params: how many blocks? grid = lambda meta: (triton.cdiv(n_elements, meta["BLOCK_SIZE"]),) add_kernel[grid](x, y, out, n_elements, BLOCK_SIZE=1024) return outif __name__ == "__main__": x = torch.rand(98_432, device="cuda") y = torch.rand(98_432, device="cuda") out = add(x, y) torch.testing.assert_close(out, x + y) print("ok")
Notice what’s absent versus CUDA: no thread indexing, no blockDim, no manual coalescing. tl.arange(0, BLOCK_SIZE)is the tile; the compiler maps it onto threads. triton.cdiv is ceil-division so the grid covers a non-multiple length, and the mask cleans up the tail.
Masking is not optional — it is correctness
Because BLOCK_SIZE is a fixed constexpr and lengths rarely divide it evenly, every boundary load/store needs a mask. Forget it and the last block reads/writes out of bounds — often silently (garbage results), sometimes an illegal-memory-access crash. For reductions, also set other= to the reduction’s identity: -inf for max, 0 for sum. This is the single most frequent Triton bug for people arriving from NumPy, where slicing handles bounds for you.
5. Worked example 2 — fused softmax (the canonical Triton win)
Row-wise softmax on an M×N matrix. This is the textbook demonstration of why fusion beats eager PyTorch. From lesson 06’s memory-wall framing: a naive PyTorch softmax materializes several intermediates (x.max, x - max, exp, sum, divide), reading ~5MN and writing ~3MN elements from/to DRAM. A fused kernel reads Xonce, does everything in registers/SRAM, and writes Yonce — roughly a 4× traffic reduction on a bandwidth-bound op.5
Strategy: one program instance per row (or a persistent program that strides over rows). Each program loads its entire row into SRAM as a single tile, reduces on-chip, writes back. This is the official fused-softmax tutorial; the CUDA equivalent needs explicit warp-shuffle reductions and synchronization — here it’s tl.max/tl.sum.
import torchimport tritonimport triton.language as tl@triton.jitdef softmax_kernel( output_ptr, input_ptr, input_row_stride, output_row_stride, n_rows, n_cols, BLOCK_SIZE: tl.constexpr, # >= n_cols, next power of two num_stages: tl.constexpr,): # Persistent programs: each one strides over multiple rows. row_start = tl.program_id(0) row_step = tl.num_programs(0) for row_idx in tl.range(row_start, n_rows, row_step, num_stages=num_stages): # Pointer to the start of this row. row_start_ptr = input_ptr + row_idx * input_row_stride col_offsets = tl.arange(0, BLOCK_SIZE) input_ptrs = row_start_ptr + col_offsets # BLOCK_SIZE may exceed n_cols → mask, fill padding with -inf so it # never wins the max and contributes 0 to the sum after exp. mask = col_offsets < n_cols row = tl.load(input_ptrs, mask=mask, other=-float("inf")) # Numerically-stable softmax, entirely on-chip. row_minus_max = row - tl.max(row, axis=0) numerator = tl.exp(row_minus_max) denominator = tl.sum(numerator, axis=0) softmax_output = numerator / denominator # Write the row back once. output_row_start_ptr = output_ptr + row_idx * output_row_stride output_ptrs = output_row_start_ptr + col_offsets tl.store(output_ptrs, softmax_output, mask=mask)def softmax(x: torch.Tensor) -> torch.Tensor: n_rows, n_cols = x.shape # One block must hold a whole row → next power of two >= n_cols. BLOCK_SIZE = triton.next_power_of_2(n_cols) # More columns per row → more warps to spread the work. num_warps = 8 if BLOCK_SIZE >= 2048 else (4 if BLOCK_SIZE >= 512 else 2) num_stages = 4 if BLOCK_SIZE < 32768 else 2 y = torch.empty_like(x) # Launch as many persistent programs as the device can co-resident; a # simple, robust choice is min(#SMs * occupancy, n_rows). For clarity we # just use n_rows here (one program per row is always correct). grid = (n_rows,) softmax_kernel[grid]( y, x, x.stride(0), y.stride(0), n_rows, n_cols, BLOCK_SIZE=BLOCK_SIZE, num_stages=num_stages, num_warps=num_warps, ) return yif __name__ == "__main__": x = torch.randn(1823, 781, device="cuda") torch.testing.assert_close(softmax(x), torch.softmax(x, axis=1)) print("ok")
The three ideas that make this fast: (1) one row = one tile, so the whole reduction happens in SRAM/registers with no round-trip; (2) other=-float("inf") makes the mask correct through both the max and the exp→sum; (3) subtracting the row max before exp is the standard numerical-stability trick — identical math to eager PyTorch, but never spilled to DRAM.
"One block per row" has a ceiling
This pattern assumes an entire row fits in one block — i.e. BLOCK_SIZE ≥ n_cols and the tile fits in registers/SRAM. For very wide rows (say n_cols in the hundreds of thousands) you run out of shared memory / registers and must switch to an online / streaming softmax that tiles the row and combines running (max, sum) — the exact trick lesson 06 used inside FlashAttention. Know which regime you’re in.
Quiz: At what abstraction level does Triton let you operate, and what does that buy you versus CUDA's SIMT model?
Answer
Triton operates at block (program-instance) granularity over tiles, one level above CUDA’s per-thread SIMT model. In CUDA you author the program for a single thread and manually orchestrate warps, shared-memory staging, __syncthreads(), and coalesced access patterns. In Triton you author the program for one block and manipulate whole tiles; the compiler synthesizes the thread-level code — it handles intra-block thread mapping, coalescing, shared-memory allocation for tl.dot operands, and loop pipelining. You keep control of the parts that need judgment (grid size, tile shapes, loop/program structure, num_warps/num_stages) and hand off the tedious, error-prone parts. The payoff: far less code and fewer bugs for ~80–95% of hand-CUDA performance on most kernels, plus portability across NVIDIA/AMD from one source. The cost: less last-mile control than raw CUDA/PTX when you need to squeeze the final few percent or exploit a very specific hardware feature.
6. Worked example 3 — tiled matmul (SMEM tiling in a few lines)
Lesson 05 built a GEMM by hand: load BLOCK_M×BLOCK_K and BLOCK_K×BLOCK_N tiles into __shared__, __syncthreads(), multiply-accumulate into registers, loop over K. In Triton that entire choreography — SMEM staging, sync, and even double-buffering — is implicit. You write the tiling; the compiler stages tiles into shared memory (by inspecting tl.dot operands) and pipelines the K-loop. Based on the official matmul tutorial.
import torchimport tritonimport triton.language as tl@triton.autotune( configs=[ triton.Config({"BLOCK_M": 128, "BLOCK_N": 256, "BLOCK_K": 64, "GROUP_M": 8}, num_stages=3, num_warps=8), triton.Config({"BLOCK_M": 64, "BLOCK_N": 256, "BLOCK_K": 32, "GROUP_M": 8}, num_stages=4, num_warps=4), triton.Config({"BLOCK_M": 128, "BLOCK_N": 128, "BLOCK_K": 32, "GROUP_M": 8}, num_stages=4, num_warps=4), triton.Config({"BLOCK_M": 64, "BLOCK_N": 64, "BLOCK_K": 32, "GROUP_M": 8}, num_stages=5, num_warps=2), ], key=["M", "N", "K"], # re-tune when any of these change)@triton.jitdef matmul_kernel( a_ptr, b_ptr, c_ptr, M, N, K, stride_am, stride_ak, stride_bk, stride_bn, stride_cm, stride_cn, BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr, BLOCK_K: tl.constexpr, GROUP_M: tl.constexpr,): # ---- Grouped program ordering for L2 reuse (see note below) ---- pid = tl.program_id(axis=0) num_pid_m = tl.cdiv(M, BLOCK_M) num_pid_n = tl.cdiv(N, BLOCK_N) 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 % group_size_m) pid_n = (pid % num_pid_in_group) // group_size_m # ---- Tile offset ranges ---- offs_am = (pid_m * BLOCK_M + tl.arange(0, BLOCK_M)) % M offs_bn = (pid_n * BLOCK_N + tl.arange(0, BLOCK_N)) % N offs_k = tl.arange(0, BLOCK_K) # Pointers to the first A and B tiles. a_ptrs = a_ptr + (offs_am[:, None] * stride_am + offs_k[None, :] * stride_ak) b_ptrs = b_ptr + (offs_k[:, None] * stride_bk + offs_bn[None, :] * stride_bn) # ---- The K-loop: SMEM staging + pipelining are IMPLICIT ---- accumulator = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32) for k in range(0, tl.cdiv(K, BLOCK_K)): # Mask the K tail so we don't read past K. k_mask = offs_k[None, :] < K - k * BLOCK_K a = tl.load(a_ptrs, mask=k_mask, other=0.0) b = tl.load(b_ptrs, mask=offs_k[:, None] < K - k * BLOCK_K, other=0.0) # Tensor-core matmul; accumulate in FP32. accumulator = tl.dot(a, b, accumulator) # Advance to the next K tile. a_ptrs += BLOCK_K * stride_ak b_ptrs += BLOCK_K * stride_bk c = accumulator.to(tl.float16) # cast after accumulating in FP32 # ---- Store the output tile, masked on the M/N edges ---- offs_cm = pid_m * BLOCK_M + tl.arange(0, BLOCK_M) offs_cn = pid_n * BLOCK_N + tl.arange(0, BLOCK_N) c_ptrs = c_ptr + stride_cm * offs_cm[:, None] + stride_cn * offs_cn[None, :] c_mask = (offs_cm[:, None] < M) & (offs_cn[None, :] < N) tl.store(c_ptrs, c, mask=c_mask)def matmul(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor: M, K = a.shape K2, N = b.shape assert K == K2 c = torch.empty((M, N), device=a.device, dtype=torch.float16) grid = lambda meta: ( triton.cdiv(M, meta["BLOCK_M"]) * triton.cdiv(N, meta["BLOCK_N"]), ) matmul_kernel[grid]( a, b, c, M, N, K, a.stride(0), a.stride(1), b.stride(0), b.stride(1), c.stride(0), c.stride(1), ) return c
Three things to see. First, the entire shared-memory dance from lesson 05 collapsed into accumulator = tl.dot(a, b, accumulator) inside a plain Python for loop — the compiler stages a and b tiles into SMEM and pipelines the loop (num_stages). Second, FP32 accumulation (tl.zeros(..., tl.float32)) with a cast to FP16 only at the end — same numerics discipline as cuBLAS. Third, the grouped program ordering: instead of walking output tiles row-major, programs are grouped into GROUP_M-row super-blocks so that consecutive programs reuse the same rows of A / columns of B, dramatically improving L2 hit rate (the tutorial’s 9×9 example drops from ~90 tile loads to ~54).6
tl.dot is your tensor-core door
tl.dot compiles to mma/wgmma tensor-core instructions when the tile dims are multiples of 16 (and dtypes are supported). You never touch wmma/PTX MMA intrinsics or CUTLASS templates — but you also don’t get CUTLASS’s full control over warp specialization and epilogue fusion. For most GEMM shapes this is close to cuBLAS3; for the shapes cuBLAS/CUTLASS are hand-tuned for (large square FP16/FP8), the vendor libraries still win the last several percent. That trade-off drives the decision framework in §10.
7. Autotuning
The optimal BLOCK_*, num_warps, and num_stages depend on the problem shape and the GPU. Rather than guess, wrap the kernel in @triton.autotune: you give it a list of triton.Configs and a key; on the first call for each distinct key value, Triton benchmarks every config by wall-clock and caches the winner.7
Recall the two most impactful compilation knobs (both triton.Config fields):
num_warps — thread-level parallelism inside a block. num_warps=8 → the compiler spreads the tile across 8 × 32 = 256 threads.
num_stages — software-pipelining depth of the K-loop (double/triple buffering of loads). Most useful for matmul on SM80+; set num_stages=1 to disable pipelining when debugging.
Autotuning is empirical, and it costs the first call
The autotuner doesn’t model the hardware — it just times each config and keeps the fastest. Set TRITON_PRINT_AUTOTUNING=1 to see the winner and time spent. Two practical consequences: (1) the first invocation for each new key value is slow (it benchmarks everything), so keep the config list focused; (2) put every shape parameter that changes the optimal config in key, or you’ll silently reuse a stale winner.
8. How Triton compiles
Understanding the lowering demystifies what Triton abstracts (and what it can’t). The pipeline is a progressive MLIR-based lowering (see the PyTorch blog, Triton kernel compilation stages)8:
Figure 3 — Triton compilation pipeline. Both TTIR and TTGIR are MLIR dialects. The interesting hardware-specific work happens at TTGIR, where layout encodings (#blocked, #shared, nvidia_mma/amd_mfma, dot_op, slice) describe how each tile is distributed across warps and lanes, and where coalescing, shared-memory staging, and software pipelining are applied. Note: Triton’s own MLIR pipeline — not the closed-source ptxas — is ~4/5 of compile time.8
Stage by stage:
Python AST → Triton IR (TTIR). The @triton.jit decorator walks the function’s AST and builds SSA-form Triton-IR. Optimizations here are hardware-independent: inlining, common-subexpression elimination, dead-code elimination, loop-invariant code motion, unrolling.
TTIR → TritonGPU IR (TTGIR). GPU-specific. This is where layout encodings get attached to tensors — the compiler decides how a 128×64 tile is partitioned across warps and threads — and where the passes you actually benefit from run: coalescing, shared-memory allocation for tl.dot operands, software pipelining (num_stages), tensor-core (mma/wgmma) lowering, and vendor-specific passes (NVIDIA TMA/async-dot, AMD LDS/ping-pong).
TTGIR → LLVM IR → target. Lower to LLVM IR, then to PTX (assembled by ptxas into a cubin) on NVIDIA, or AMDGCN (→ hsaco) on AMD. Artifacts (.ttir, .ttgir, .llir, .ptx, .cubin) are cached on disk; dump them with TRITON_KERNEL_DUMP=1.
So: Triton abstracts thread-level details, coalescing, SMEM management, and pipelining (the TTGIR passes). You still control block/tile sizes, program structure, and the coarse num_warps/num_stages knobs. What you cannot express is anything below the tile abstraction — bespoke warp specialization, custom async-copy schedules, or hand-laid register allocation. That’s where CUDA/CUTLASS remains necessary.
9. torch.compile / TorchInductor — Triton, generated for you
Here’s the twist that reframes everything above: most Triton kernels running in production LLM code today were not written by a human.torch.compile (PyTorch 2.x, default backend TorchInductor) generates Triton automatically (PyTorch 2 / TorchInductor)910.
The pipeline:
TorchDynamo hooks CPython frame evaluation to safely capture the FX graph of your eager code.9
AOTAutograd traces an ahead-of-time backward graph, and PrimTorch canonicalizes ~2000+ ATen ops down to ~250 primitives.11
TorchInductor lowers that graph and, for GPU, emits Triton kernels — fusing pointwise + reduction chains, choosing persistent vs. non-persistent reductions, and picking tile sizes/autotuning automatically. On CPU it emits OpenMP C++ instead.9
The headline capability is fusion: Inductor spots memory-bound op chains and fuses them into one Triton kernel that reads inputs once, computes in registers, and writes once — exactly the DRAM-traffic win you engineered by hand in §5’s fused softmax, but derived automatically from torch.softmax. For large GEMMs, Inductor is selective: it typically defers to cuBLAS/cuDNN, and under max-autotune it benchmarks cuBLAS vs. CUTLASS vs. a Triton template and picks the winner, optionally fusing the pointwise epilogue in.12
import torch@torch.compile # TorchInductor fuses this into (typically) one Triton kerneldef gelu_bias(x, b): x = x + b return x * 0.5 * (1.0 + torch.erf(x / 2**0.5))x = torch.randn(8192, 4096, device="cuda", dtype=torch.float16)b = torch.randn(4096, device="cuda", dtype=torch.float16)y = gelu_bias(x, b) # first call compiles; subsequent calls hit cached kernels
To see the generated Triton, set TORCH_LOGS="output_code" (or torch._inductor.config.debug=True) — you’ll get readable @triton.jit kernels you can learn from and even copy out.
The relationship between hand-written and compiler-generated Triton is not competitive but layered:
Compiler-generated Triton covers the long tail of elementwise/reduction/norm fusions across a whole model, with zero authoring effort and correctness guaranteed by the tracer. It’s excellent at fusing what’s adjacent in the graph.
Hand-written Triton wins where Inductor’s pattern matching gives up: a novel algorithm the graph doesn’t express (FlashAttention-style online softmax, fused MoE routing, custom quantized matmul), or where you need control the heuristics won’t find. You can drop a user-defined @triton.jit kernel intotorch.compile — Inductor treats it as an opaque node and still fuses around it and autotunes it.13
Real-world evidence for hand-written Triton: Liger-Kernel (LinkedIn) — a library of hand-written Triton kernels (fused RMSNorm, RoPE, SwiGLU, and a fused-linear-cross-entropy that avoids materializing logits) that cut LLM-training memory and boost throughput beyond what torch.compile finds14; and Unsloth, whose fast fine-tuning rests on hand-written Triton kernels15. Both exist precisely because there’s a gap above what Inductor generates automatically.
Quiz: You have a standard transformer block in eager PyTorch that's slow. When does torch.compile likely suffice, and when do you reach for hand-written Triton?
Answer
torch.compile usually suffices when the slowness is death-by-a-thousand-cuts from many small memory-bound ops — elementwise activations, biases, LayerNorm/RMSNorm, residual adds, dropout — that are adjacent in the graph. Inductor fuses these into a handful of Triton kernels, collapsing DRAM round-trips, and defers the big GEMMs to cuBLAS. You get most of the win for a one-line @torch.compile and no kernel authoring. Start here, always: measure eager, then measure compiled.
Reach for hand-written Triton when: (1) the hot path is an algorithm the graph doesn’t express, so there’s nothing for Inductor to fuse — e.g. FlashAttention-style online-softmax attention, fused MoE dispatch/combine, or a custom quantization/dequant matmul; (2) profiling shows Inductor’s generated kernel is leaving performance on the table for your specific shapes and you can beat its heuristics; or (3) you need a fusion that crosses a boundary Inductor won’t cross (like fused-linear-cross-entropy that never materializes the full logits, à la Liger-Kernel). The pragmatic workflow: torch.compile first, profile, and hand-write Triton only for the residual hotspots — then drop those kernels back into the compiled graph as user-defined kernels.
10. When to use what — the decision framework
A ladder from fastest-to-develop to most-control. Climb only as far as the profiler forces you.
Figure 4 — The kernel-authoring ladder. Each rung trades development speed for last-mile control. Most LLM systems work lives on the first two rungs; the top rung (vendor libraries) is where you land for standard GEMM/conv rather than a step you climb through.
Decision heuristics:
Reach for…
When
Eager PyTorch
Prototyping, research iteration, anything not yet proven hot. Correctness and dev speed dominate.
torch.compile
Always try it before hand-optimizing. Big wins on memory-bound fusion chains; free GEMM-backend selection. The default for production training/inference.
Hand-written Triton
A specific hotspot Inductor can’t express or beats poorly; you need tensor cores via tl.dot with custom fusion; you want one source that runs on NVIDIA and AMD.
CUDA / CUTLASS
The last few percent on a critical GEMM/attention kernel; you need warp specialization, custom async-copy pipelines, TMA/wgmma control, or bespoke epilogues Triton won’t emit.
cuBLAS / cuDNN
Standard, large GEMM/conv on NVIDIA — decades of hand-tuning you can’t beat. This is what torch.compile and even hand-Triton call into for the heavy GEMMs. Closed, NVIDIA-only, inflexible on fusion.
The through-line for a GPU engineer at a frontier lab: torch.compile handles the breadth; your hand-written Triton (and occasionally CUDA/CUTLASS) handles the handful of kernels that define the model’s efficiency frontier — attention variants, MoE, quantized matmuls, custom losses.
11. Practice problems
Practice problem 1: Fused dropout, and comparing to torch.compile
Problem. Write a Triton kernel for dropout that, given input x, a probability p, and a per-element random mask, computes x * (rand > p) / (1 - p) in one pass (elementwise, masked at the tail). Then compare against torch.compile(lambda x: torch.nn.functional.dropout(x, p)). What do you expect, and why?
Worked solution.
import torch, triton, triton.language as tl@triton.jitdef dropout_kernel(x_ptr, out_ptr, n, p, seed, BLOCK_SIZE: tl.constexpr): pid = tl.program_id(0) offs = pid * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE) mask = offs < n x = tl.load(x_ptr + offs, mask=mask) # Seeded RNG per element — no need to materialize a mask tensor in DRAM. r = tl.rand(seed, offs) keep = r > p out = tl.where(keep, x / (1.0 - p), 0.0) tl.store(out_ptr + offs, out, mask=mask)def dropout(x, p, seed=0): out = torch.empty_like(x); n = x.numel() grid = lambda m: (triton.cdiv(n, m["BLOCK_SIZE"]),) dropout_kernel[grid](x, out, n, p, seed, BLOCK_SIZE=1024) return out
This mirrors the official low-memory dropout tutorial: the key win is generating randomness in-kernel with tl.rand(seed, offs) so you never allocate or read a full random-mask tensor from DRAM — halving the memory traffic of a naive mask = torch.rand_like(x) approach.
Comparison expectation. For a single dropout op in isolation, torch.compile will generate essentially the same fused elementwise Triton kernel and perform comparably — Inductor is very good at exactly this pattern. The hand-written kernel’s advantage shows up (a) when you want the seeded, mask-free RNG that saves the mask allocation, and (b) when dropout is one link in a chain (dropout(activation(x @ w + b))) that you can fuse yourself in ways specific to your model — though even there, torch.compile will fuse the adjacent pointwise ops automatically. Lesson: for standalone standard ops, torch.compile is the right default; hand-Triton earns its keep on the memory-layout tricks and cross-op fusions the compiler won’t discover.
Practice problem 2: Diagnosing a boundary bug
Problem. A colleague’s Triton matmul returns correct results when M, N, K are all multiples of the block sizes, but produces NaNs/garbage in the last row-block and column-block for arbitrary shapes. The K-loop uses a = tl.load(a_ptrs) and b = tl.load(b_ptrs) with no masks, and the final store has c_mask = (offs_cm[:, None] < M) & (offs_cn[None, :] < N). What’s wrong, and what’s the fix?
Worked solution.
The store is correctly masked, so out-of-bounds writes don’t happen — that’s why it’s not an illegal-access crash. The bug is in the loads: with no mask, the K-loop reads past the ends of A and B on the final K-tile (when K isn’t a multiple of BLOCK_K) and off the M/N edges, pulling in garbage (or NaN/inf) memory. Those garbage values flow through tl.dot into the accumulator, so even the in-bounds elements of an edge output tile get contaminated — the store mask can’t save values that were already corrupted during accumulation.
Fix: mask every load, filling with the additive identity 0.0 so it contributes nothing to the dot product:
(And, as in §6, mask the M/N ranges of the pointers too, or use the % M / % N wrap trick the tutorial uses to keep pointers in-bounds.) General principle: in Triton, masking on the store protects memory safety, but masking on the load protects numerical correctness — you need both. This is the single most common Triton bug and the reason the mental-model warning in §2 and the masking warnings in §4–§6 matter.
Quiz: Where in the compilation pipeline does memory coalescing get decided, and could you override it?
Answer
Coalescing is decided at the TritonGPU IR (TTGIR) stage, via layout encodings that determine how each tile is distributed across warps and threads — the coalesce pass arranges the thread→element mapping so that a tile load turns into contiguous, coalesced global-memory transactions. You do not control this directly from the Python source; it’s one of the things Triton abstracts away. You influence it only indirectly, by choosing tile shapes and access patterns (contiguous vs. strided) and via num_warps. To inspect what the compiler chose, dump the TTGIR with TRITON_KERNEL_DUMP=1 and read the #blocked layout annotations. If you truly need to hand-lay the memory access pattern, that’s the signal you’ve hit Triton’s ceiling and should drop to CUDA/CUTLASS.
Quiz: Your hand-written Triton matmul is ~10% slower than torch.matmul for large square FP16 inputs. Is something wrong?
Answer
Probably not — this is expected. torch.matmul dispatches to cuBLAS (or a cuBLASLt heuristic), which is hand-tuned by NVIDIA over years for exactly large square FP16 GEMM, including warp specialization, TMA/wgmma scheduling, and split-K strategies that Triton’s tl.dot template doesn’t fully replicate. Being within ~10% of cuBLAS with a few dozen lines of portable Triton is a good result, and it’s why the decision framework (§10) says to call cuBLAS/cuDNN for standard large GEMM/conv rather than hand-write it. Hand-Triton matmul earns its place for non-standard GEMMs — unusual shapes, custom epilogue fusion, quantized/mixed-precision variants, or AMD portability — not for beating cuBLAS at its home game. Sanity checks before concluding: did autotuning actually run (TRITON_PRINT_AUTOTUNING=1), are your dims multiples of 16 so tl.dot hits tensor cores, and are you accumulating in FP32?
12. Practice & resources
Hands-on (do these in order):
Work through the four official Triton tutorials end to end, typing them yourself, not copy-pasting: vector add → fused softmax → matrix multiplication → low-memory dropout. For each, run the included triton.testing.Benchmark and read the plot against the PyTorch baseline.
Take one memory-bound fusion in your own model (e.g. bias → GELU → dropout, or RMSNorm) and write a single fused Triton kernel for it. Then wrap the eager version in @torch.compile, dump Inductor’s generated kernel with TORCH_LOGS="output_code", and compare — both the wall-clock (triton.testing.do_bench) and the generated code. Note where they converge and where your hand version wins.
Add @triton.autotune to your kernel over a handful of BLOCK_SIZE/num_warps configs; set TRITON_PRINT_AUTOTUNING=1 and see which config wins on your GPU.
Dump the IR of your matmul with TRITON_KERNEL_DUMP=1 and read the .ttgir — find the #blocked and nvidia_mma layouts and the pipelined K-loop.
Real-world hand-written Triton:Liger-Kernel (LinkedIn — fused RMSNorm/RoPE/SwiGLU/FLCE for LLM training) and Unsloth (fast fine-tuning on hand-written Triton kernels). Read their kernels as production examples of everything above.
13. What’s next
Two threads to pull:
08-data-parallelism-and-collectives(next) — you can now write fast single-GPU kernels; next is scaling across GPUs: data/tensor/pipeline parallelism and the NCCL collectives (all-reduce, all-gather, reduce-scatter) that stitch them together.
06-memory-wall-and-flashattention(back-reference) — revisit FlashAttention now that you can read/write Triton. The online-softmax tiling you studied there is a canonical hand-written Triton kernel; try sketching its inner loop with tl.dot and running max/sum accumulators, and compare to the official fused-attention tutorial.
Philippe Tillet, H. T. Kung, David Cox, “Triton: An Intermediate Language and Compiler for Tiled Neural Network Computations,” MAPL 2019 (3rd ACM SIGPLAN Int’l Workshop on Machine Learning and Programming Languages), DOI 10.1145/3315508.3329973. PDF: eecs.harvard.edu/~htk/publication/2019-mapl-tillet-kung-cox.pdf. Introduces the tile abstraction and block-level programming model. [established] ↩
OpenAI, “Introducing Triton: Open-source GPU programming for neural networks,” July 2021, openai.com/index/triton. OpenAI’s open-sourcing and re-implementation of Triton. [established] ↩
The ~80–95%-of-hand-CUDA figure is an approximate synthesis, not a single benchmarked number. Primary support: the Triton paper reports matmul “on par with cuBLAS,” reaching >90% of device peak performance on some tasks (Tillet, Kung & Cox, MAPL 2019, PDF). Exact fraction is workload- and shape-dependent. [contested] ↩↩2
Triton supports multiple hardware backends (NVIDIA → PTX, AMD → AMDGCN) from one source; see the Triton documentation and third-party backends, triton-lang.org. [established] ↩
OpenAI Triton API reference, triton.autotune and triton.Config. Documents empirical per-key benchmarking of configs and caching of the winner. [established] ↩
PyTorch blog, “Triton kernel compilation stages,” pytorch.org/blog/triton-kernel-compilation-stages. Walkthrough of the Python → TTIR → TTGIR → LLVM IR → PTX/AMDGCN MLIR-based lowering, including the observation that Triton’s own compiler passes (not ptxas) dominate compile time. [established] ↩↩2
Jason Ansel et al., “PyTorch 2: Faster Machine Learning Through Dynamic Python Bytecode Transformation and Graph Compilation,” ASPLOS 2024, DOI 10.1145/3620665.3640366. PDF: docs.pytorch.org/assets/pytorch2-2.pdf. Describes TorchDynamo (CPython frame-evaluation graph capture), AOTAutograd, and TorchInductor (Triton for GPU, C++/OpenMP for CPU). [established] ↩↩2↩3
PyTorch 2.0 overview, “GET STARTED / PyTorch 2.0,” pytorch.org/get-started/pytorch-2.0: “PrimTorch canonicalizes ~2000+ PyTorch operators down to a closed set of ~250 primitive operators.” [established] ↩
TorchInductor max-autotune mode benchmarks candidate backends (cuBLAS/cuDNN, CUTLASS, Triton templates) for GEMM/conv and selects the fastest, with epilogue fusion. See PyTorch docs, torch.compile / TorchInductor configuration, docs.pytorch.org/docs/stable/torch.compiler.html and the torch._inductor.configmax_autotune options. [established] ↩
Pin-Lun Hsu et al. (LinkedIn), “Liger Kernel: Efficient Triton Kernels for LLM Training,” arXiv:2410.10989 (2024); code: github.com/linkedin/Liger-Kernel. Reports ~20% average training-throughput gain and ~60% GPU-memory reduction vs. HuggingFace baselines via fused RMSNorm/RoPE/SwiGLU and fused-linear-cross-entropy. [recent] ↩
Unsloth, open-source fast LLM fine-tuning built on hand-written Triton kernels, github.com/unslothai/unsloth. [recent] ↩