Why Triton Exists & the Programming Model
The framing question of every Triton interview: "why is Triton's model different from CUDA?" Get the block-vs-SIMT distinction right and you've cleared the conceptual bar.
Whether you understand Triton's block/tile programming model vs CUDA's per-thread SIMT — and can name what the compiler does for you. The #1 separator: candidates who think a Triton "program" is a CUDA thread.
Two Philosophies
You write per-thread scalar code (acc += A[m,k]*B[k,n]) and manually manage coalescing, shared memory, and __syncthreads.
You write tile-level code (acc += A[m:m+BM, :] @ B[:, n:n+BN]); the compiler maps threads underneath. There is no threadIdx.
You are one program (tl.program_id) that owns a whole tile — not one thread among many.
What the Compiler Does For You
In CUDA you hand-tune these; in Triton the compiler handles them via block-level data-flow analysis. Naming three of these is a standard interview answer:
__shared__ / __syncthreads.
Compilation path: @triton.jit Python → Triton IR → TritonGPU IR → LLVM → PTX → SASS, launched over a grid of program instances.
The Framing Round
1. What is a Triton "program" analogous to in CUDA?
2. Which of these do you NOT manage by hand in Triton?
• "Block" in Triton = a program's tile of data, not a CUDA thread block. Same word, different meaning.
• You cannot control individual threads — only tile shapes, num_warps, num_stages.
• "Does Triton always beat cuBLAS?" — No; its win is near-peak performance with far less code for custom fused ops.
Your First Kernel — Vector Add
The warm-up. Every Triton kernel follows this skeleton: compute offsets, mask the boundary, load, compute, store. Get the masking right and you won't segfault in a live coding round.
Rarely the actual bar — but the boundary mask is where candidates crash live. If n_elements isn't a multiple of BLOCK_SIZE, the last program runs out of bounds without it.
The Universal Skeleton
program_id → offsets
mask boundary
tl.load
compute
tl.store
The Complete Kernel
@triton.jit
def add_kernel(x_ptr, y_ptr, out_ptr,
n_elements, BLOCK_SIZE: tl.constexpr):
pid = tl.program_id(axis=0)
start = pid * BLOCK_SIZE
offs = start + tl.arange(0, BLOCK_SIZE)
mask = offs < n_elements
x = tl.load(x_ptr + offs, mask=mask)
y = tl.load(y_ptr + offs, mask=mask)
tl.store(out_ptr + offs, x + y, mask=mask)
grid = lambda m: (triton.cdiv(n, m['BLOCK_SIZE']),)
add_kernel[grid](x, y, out, n, BLOCK_SIZE=1024)
Spot the Bug: Illegal Memory Access
This kernel crashes with "illegal memory access" when n = 1000, BLOCK_SIZE = 1024. Click the line at fault.
offs = pid * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE) x = tl.load(x_ptr + offs) # no mask! tl.store(out_ptr + offs, x + 1.0)Conceptual Round
1. Why must BLOCK_SIZE be tl.constexpr?
2. Does add_kernel[grid](...) block until the GPU finishes?
• Forgetting the mask when size isn't a multiple of the block.
• Passing BLOCK_SIZE positionally — meta-params must be keyword args.
• This uses plain pointer arithmetic (x_ptr + offs), not tl.make_block_ptr — block pointers appear in matmul/attention. Don't conflate them.
The Memory Model & Masking
Where candidates break in live coding. Triton manipulates pointers into tensors, not tensors — strides, broadcasting, and the right other= value for a masked reduction are the details that separate strong from weak.
Whether you build addresses from strides (never assume contiguity) and pick the correct other= fill for masked lanes — -inf for a max, 0.0 for a sum. This is a top boundary-handling separator.
Pointers, Strides, Broadcasting
An address is base + i*stride_i + j*stride_j. Build 2D pointer blocks by broadcasting offs_i[:, None] against offs_j[None, :]. Strides come from PyTorch's .stride() and are in elements, not bytes.
offs_m = pid_m*BM + tl.arange(0, BM)
offs_n = pid_n*BN + tl.arange(0, BN)
ptrs = base + offs_m[:, None]*stride_m \
+ offs_n[None, :]*stride_n
mask = (offs_m[:, None] < M) & \
(offs_n[None, :] < N)
tile = tl.load(ptrs, mask=mask, other=0.0)
Spot the Bug: The Wrong Fill Value
This kernel computes a row max but returns wrong answers for rows with all-negative values. Click the mistake.
offs = tl.arange(0, BLOCK_SIZE) row = tl.load(p + offs, mask=offs<n, other=0.0) # max reduction! m = tl.max(row, axis=0)Pick the neutral element for your reduction: other=-float('inf') for max, other=0.0 for sum, other=float('inf') for min. The fill must never affect the result.
Conceptual Round
1. How do you correctly index a transposed/strided tensor in Triton?
2. Triton strides are measured in...?
• Wrong other= for the reduction (the bug above).
• Broadcasting mistakes — [:, None] vs [None, :] transposed.
• Forgetting the mask on tl.store → out-of-bounds writes.
• Assuming contiguity instead of threading strides through.
Fused Softmax
The canonical Triton coding exercise. "Write a fused softmax kernel" tests masking, numerical stability, and single-pass reduction all at once — and sets up Flash Attention.
Two things: why subtract the row max (numerical stability + shift-invariance), and why fusion is faster (softmax is memory-bound; separate ops round-trip DRAM per op).
One Pass, On-Chip
Load a whole row into SRAM, then do max → subtract → exp → sum → divide on-chip in one pass, and write once. The naive PyTorch softmax round-trips DRAM per op (~5MN read + 3MN write); the fused kernel touches DRAM ~once in / once out (~2MN) → roughly 4× less traffic.
row = tl.load(row_ptr + cols,
mask=cols < n_cols, other=-float('inf'))
row = row - tl.max(row, axis=0) # stability
num = tl.exp(row)
den = tl.sum(num, axis=0)
tl.store(out_ptr + cols, num / den,
mask=cols < n_cols)
Fused vs Unfused — Watch the DRAM Traffic
Step through what happens to a row under each approach. This is the narration interviewers want when they ask "why is fusion faster?"
Conceptual Round
1. Why subtract the row max before exp, and why is it still correct?
2. Why is a fused softmax faster than exp/sum/div as three ops?
• One-block-per-row requires the row to fit in SRAM — fails for huge n_cols.
• Forgetting other=-inf (Module 3 bug).
• Reducing over the wrong axis.
• BLOCK_SIZE = next_power_of_2(n_cols) — reductions want power-of-two width, extra lanes masked.
Autotuning & Performance
Autotuning itself is engineering practice, but the concepts get tested: "what does num_stages do?" and "when does Triton recompile?" Don't confuse pipelining depth with warp count.
The meaning of the tuning knobs — especially the classic mix-up: num_stages = software-pipelining depth, num_warps = threads per program (÷32). And what triggers recompilation.
The Autotune Decorator
@triton.autotune(
configs=[
triton.Config({'BLOCK_SIZE': 128}, num_warps=4),
triton.Config({'BLOCK_SIZE': 1024}, num_warps=8)],
key=['n_elements'])
@triton.jit
def kernel(...): ...
Spot the Bug: Wrong Results During Tuning
A kernel accumulates into an output buffer in place. It's correct without autotuning, but produces garbage once @triton.autotune is added. Click the fix it's missing.
@triton.autotune(configs=[cfg_a, cfg_b], key=['M','N']) # no reset_to_zero for the accumulator@triton.jit
def kernel(out_ptr, ...): # does out += partialConceptual Round
1. What does num_stages control?
2. When does Triton re-run autotuning?
• Missing a shape dim from key → stale config for new shapes.
• First call is slow (tuning cost); TRITON_PRINT_AUTOTUNING=1 shows the winner.
• num_stages too high overflows shared memory → compile failure.
• triton.heuristics must come before triton.autotune, not after.
Blocked Matrix Multiply
The block matmul: a 2D grid of C-tiles, a K-loop with tl.dot, an fp32 accumulator, and L2-friendly grouped ordering. The concepts here are the bridge to Flash Attention.
Why the accumulator is fp32 even with fp16 in/out, and what GROUP_SIZE_M buys you (L2 reuse) — the kind of "why is this arranged this way" question that separates candidates.
Each Program Owns One C-Tile
acc = tl.zeros((BM, BN), dtype=tl.float32)
for k in range(0, tl.cdiv(K, BK)):
a = tl.load(a_ptrs, mask=k_mask, other=0.0)
b = tl.load(b_ptrs, mask=k_mask, other=0.0)
acc = tl.dot(a, b, acc) # fp32 accumulate
a_ptrs += BK * stride_ak
b_ptrs += BK * stride_bk
c = acc.to(tl.float16) # cast once at the end
input_precision= (not the deprecated allow_tf32).Match the Matmul Concept to Its Purpose
Drag each piece onto what it's for.
fp32 accumulatorGROUP_SIZE_M orderingK-loop masktl.dotGROUP_SIZE_M finishes a group of row-tiles before moving across columns, so the same A/B tiles stay hot in L2. Triton's 9×9 example: row-major needs 90 tile-loads for the first 9 outputs; grouped needs 54 — a real ~10% gain. It changes only order, never the math.
Conceptual Round
1. Why accumulate in fp32 when inputs and output are fp16?
2. Does GROUP_SIZE_M change the result?
• Off-by-one in the pid → (pid_m, pid_n) decomposition.
• Forgetting the K-loop mask when K isn't divisible by BLOCK_K.
• Applying a fused activation after the fp16 cast instead of on the fp32 accumulator.
• Using deprecated allow_tf32 — use input_precision="tf32". For the CUDA-level version of this, see GEMM from Scratch.
Flash Attention in Triton
The highest-value module in the course. "Explain online softmax and how Flash Attention avoids the N×N matrix" is a staple ML-breadth question — and the #1 misconception is thinking it reduces FLOPs. It doesn't.
That Flash Attention trades memory for recompute: same (or more) FLOPs, far less HBM traffic, O(N) memory instead of O(N²). Candidates who say "it's faster because fewer operations" fail. The bottleneck was memory movement, not math.
Never Materialize the N×N Score Matrix
Standard attention computes the full T×T score matrix QKᵀ, softmaxes it, then multiplies by V — that matrix is O(N²) in HBM. Flash Attention keeps a Q-tile in SRAM and streams over K/V tiles, maintaining running statistics so it never stores the scores.
-inf. Tracks the max score per query row across tiles seen so far.
The Rescaling Trick — Why It's Exact
The subtle part: when a new K/V tile raises the running max, all prior contributions must be retroactively rescaled. Step through why this equals the true full-row softmax.
The Signature ML-Breadth Round
1. Why is Flash Attention faster?
2. Why multiply acc and l_i by α = exp(m_old − m_new) when the max grows?
• Initializing m_i to 0 instead of -inf.
• Forgetting to rescale acc (or l_i) → wrong normalization.
• Doing the final /l_i inside the loop instead of once in the epilogue.
• The modern Triton tutorial uses exp2 (base-2, faster) with the scale folded by 1/ln2, and TensorDescriptor loads — teach the algorithm with the portable block-pointer form first.
Online softmax is the bridge to the LLM-inference world — KV cache, prefill/decode, PagedAttention. See vLLM & Distributed Kernels for the serving side.
Integration with PyTorch & torch.compile
Mostly engineering practice — but the autograd question ("how do you make your kernel differentiable?") is a common senior follow-up, and knowing TRITON_INTERPRET=1 shows real hands-on experience.
Framed as methodology, not a quiz: "you wrote a fast kernel — how do you make it differentiable, composable with torch.compile, and how do you debug it?" Naming register_autograd and TRITON_INTERPRET=1 is the signal.
Three Levels of Integration
@torch.compile (PyTorch ≥ 2.3)
Inductor traces through the grid launch and fuses/optimizes around your kernel. Simplest path.
triton_op + wrap_triton (≥ 2.6)
Makes a raw kernel a first-class PyTorch op — composable with tensor subclasses, FlopCounter, CPU fallback, AOTInductor, and torch.compile can trace into it (unlike opaque custom_op).
register_autograd
Over torch.autograd.Function (which has composability footguns under torch.compile). Provide backward + setup_context; backward must be PyTorch ops.
Making a Kernel Differentiable
from torch.library import triton_op, wrap_triton
@triton_op("mylib::mysin", mutates_args={})
def mysin(x):
out = torch.empty_like(x)
wrap_triton(sin_kernel)[(x.numel(),)](x, out, ...)
return out
def backward(ctx, grad):
(x,) = ctx.saved_tensors
return grad * x.cos()
def setup_context(ctx, inputs, output):
(x,) = inputs; ctx.save_for_backward(x)
mysin.register_autograd(backward, setup_context=setup_context)
autograd.Function, which breaks under compile.Conceptual Round
1. Why wrap a kernel in triton_op vs just calling it in a compiled function?
2. Your kernel gives wrong results and you can't attach a GPU debugger. Fastest way to inspect tensors?
Programming model → first kernel → memory & masking → fused softmax → autotuning → blocked matmul → Flash Attention → PyTorch integration. The interview through-line: Triton abstracts away threads so you reason at the tile level about memory movement — and memory movement is what makes kernels fast.
• tl.dot: use input_precision=, not deprecated allow_tf32.
• triton_op/wrap_triton need PyTorch ≥ 2.6; basic kernels in torch.compile ≥ 2.3.
• Prefer register_autograd over torch.autograd.Function for compile-safety.
• For the "is my kernel memory- or compute-bound" question, apply the roofline model.