Spread seven files on the table and read them line by line. When the same kernels move from CUDA to Triton — how does the code fold? What details disappear under the compiler, and what stays in your hands?
program = one block, not one thread.CUDA's threadIdx.x didn't vanish — it just slipped under the compiler. Triton is block-level SPMD: the thread parallelism inside a block is decided by the compiler from num_warps.
@triton.jit def vector_add_kernel(x_ptr, y_ptr, out_ptr, n, BLOCK_SIZE: tl.constexpr): pid = tl.program_id(axis=0) offsets = pid * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE) mask = offsets < n 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)
int idx = blockIdx.x * blockDim.x + threadIdx.x; if (idx < n) out[idx] = x[idx] + y[idx];
threadIdx.x in CUDA — where did it go in Triton?tl.arange(0, 1024) is a vector pointing to "all 1024 indices this block handles." Which thread handles which lane — Triton decides.Reducing 67M elements — CUDA v4 = 1.039 ms, Triton = 1.097 ms (5% slower). Python → autotune cache → JIT cache → argument binding → cuLaunchKernel eats ~50–100 µs. For tiny kernels that overhead can exceed the compute time. → Launch 30 element-wise ops as separate Triton kernels and you're done.
Autotune runs configs sequentially against the same output buffer. Leftover stale partial sums from previous attempts mix into the result. Fix — reset_to_zero=["partial_ptr"]. It's faintly mentioned in the docs; miss it and you debug for hours.
Change BLOCK_SIZE and num_programs changes too. Size the partial buffer for the maximum case, then slice it to the prefix that matches the chosen config.
@triton.autotune(
configs=AUTOTUNE_CONFIGS,
key=["n_elements"],
reset_to_zero=["partial_ptr"], # ← matters
)
BLOCK_SIZE=1024, mask out the last 24. The trick is other=-float("inf"). If OOB lanes hold -inf, tl.max is unaffected and exp(-inf)=0 so sum doesn't get contributions either. Mask logic melts into the data values.BLOCK_SIZE. Clever not to use N directly.BLOCK_SIZE = _next_pow2(N), N=513–1024 all bucket into 1024. Cache-friendly autotune key design — the last skill you pick up when learning Triton.offs = tl.arange(0, BLOCK_SIZE) # 0..1023 mask = offs < n_cols # only first 1000 True x = tl.load(in_row + offs, mask=mask, other=-float("inf")) # OOB → -inf
Change only the order in which output C tiles are visited, and L2 reuse changes dramatically. Row-major eviction sweeps B's columns. Group-wise traversal lets the same B columns be reused.
Tile 0→1→2→… same row of A, different column of B. If B doesn't fit in L2, it gets swept out.
Tiles 0→1→2→3 reuse the same B column four times. L2 efficiency ↑.
blockIdx.x is just linear hardware order. You have to write the math by hand at the top of the kernel. ② That math is painful to read. ③ Change GROUP_SIZE_M and you recompile. In Triton it's an autotune parameter and a standard idiom.| variant | TFLOPS | note |
|---|---|---|
| our CUDA v3 (FMA only) | 3.9 | register blocking |
| torch.matmul (cuBLAS + TF32) | 25.8 | years of NVIDIA tuning |
| Triton fp32 | 28.9 | cuBLAS + 12% |
| our CUDA v4 (WMMA fp16) | 18.5 | hand-written mma |
| cuBLAS fp16 | 51.8 | — |
| Triton fp16 | 54.0 | cuBLAS + 4% · 40 lines |
acc = acc * alpha[:, None] + tl.dot(p.to(v.dtype), v)
In CUDA this logic stretches over 30+ lines. Complexity collapses when the abstraction is at the right height.
| impl | time (ms) | speedup |
|---|---|---|
| CUDA FA v1 (fp32) | 3.045 | 1.00× |
| Triton FA (fp16) | 0.496 | 6.14× |
① tl.dot uses Tensor Cores (our CUDA v1 is fp32 FMA). ② Autotune sweeps 6 configs of (BLOCK_M, BLOCK_N, num_warps, num_stages) — sweeping that by hand in CUDA means 6 recompiles. ③ tl.trans, 2-D pointer broadcasts, swizzled smem layouts — all automatic.
Filling with -inf still computes the full QKᵀ. FA-v2's real win comes from pulling the upper-triangle K tiles out of the iteration itself.
def flash_attention_mha_fwd_kernel(..., IS_CAUSAL: tl.constexpr, # ← key BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr): if IS_CAUSAL: end_n = tl.minimum(N, (pid_m+1) * BLOCK_M) else: end_n = N
→ Compile separate kernels for IS_CAUSAL=True and =False. The if doesn't exist at runtime. Equivalent to C++ template specialization.
| (B, H, N, d) | ours | SDPA | ratio |
|---|---|---|---|
| (1, 32, 2048, 128) | 0.784 | 0.613 | 0.78× |
| (1, 32, 4096, 128) | 2.964 | 2.559 | 0.86× |
| (16, 12, 512, 64) | 0.249 | 0.282 | 1.13× |
One 268-line file at 78–90% of cuDNN. What's missing — async copy, persistent kernel, warp specialization — all still experimental in Triton.
@custom_op( "triton_training::flash_attention_mha", mutates_args=(), device_types="cuda", ) def flash_attention_mha_op(q, k, v, is_causal=False): return triton_flash_attention_mha(q, k, v, is_causal=is_causal) @flash_attention_mha_op.register_fake def _fake(q, k, v, is_causal=False): return torch.empty_like(q) # ← shape decl for Dynamo
When torch.compile traces a model, it uses FakeTensors (shape, dtype, device — no data). Our Triton kernel can't run on those. Instead, we declare "this op's output shape is this" → Dynamo doesn't break the graph. Without it, fullgraph=True fails.
Not a "high-level DSL." A language that sits at exactly the right abstraction height. The five below go under the compiler; the five above stay in your hands.
threadIdx.x is gonetl.sumtl.dot picks by dtypeprogram_id, grid=lambda metatl.load(mask=...)tl.constexpr*.ptx under TRITON_CACHE_DIR.Code ref · triton_kernels/ all 7 files · L4 sm_89, CUDA 13.0, PyTorch 2.11, Triton 3.6.