Writing kernels in Triton
Running pip install triton does not check whether the GPU can run its
kernels.
Triton's README lists support for "NVIDIA GPUs
(Compute Capability 8.0+)"
(https://github.com/triton-lang/triton/blob/main/README.md , checked
2026-09-01). TRITON_INTERPRET=1 runs kernels on the CPU for correctness
checks but does not provide GPU timings.
This lesson implements a row softmax and an FP32 matmul as programs over blocks of data, beside the CUDA versions from day 47 and day 44, then compares what each language requires you to specify.
The unit is a block of data
Earlier CUDA kernels in this course define one thread's work and start by
computing its index. A Triton kernel defines one block of data.
tl.program_id(0) selects a block of the problem, and later operations act
on arrays with that block's shape; the source contains no threadIdx.
Day 47's softmax gives one 256-thread block one row of 1024 columns. The
block walks the row in a strided loop to find the maximum, reduces 256
partial maxima through shared memory in a
halving tree, walks the row again for the sum, reduces again, then walks it
a third time to divide. Five __syncthreads() in forty lines, two of them
inside halving loops, and every one is a place the kernel can be wrong.
The Triton version loads the row as one value. tl.max(x, axis=0) and
tl.sum(num, axis=0) are single calls on that value. No shared array is
declared, no barrier is written, and the halving tree does not appear.
It still happens. The compiler picks the warp count from
num_warps, allocates the shared memory, chooses the swizzle that keeps
the reduction off a bank conflict, and emits the
barriers. You provide two numbers instead: the block shape and the number of
warps.
Diagram: where the barriers went. Two stacked bands over the same 1024-column softmax row, with a vertical line separating what you write on the left from what runs on the right. Band 1, day 47 in CUDA: 256 thread boxes, a strided-loop arrow across the row, then two shared-memory halving trees drawn in full on both sides of the line. Caption "40 lines, 5 barriers, 2 trees, all written by you." Band 2, day 86 in Triton: one program box holding the whole row as a single 1024-wide value, with
tl.maxandtl.sumas one node each on the left of the line. Caption "11 lines, no barrier in the source." Band 2b, greyed, on the right of the line under band 2: the same lanes, the same shared memory, the same two trees, withBLOCK_COLSandnum_warpsdrawn as the only two inputs feeding them. Caption "the same machinery, sized by two compile-time numbers." Alt text: "The same 1024-column softmax row twice. Forty lines of CUDA spend five barriers and two shared-memory trees. Eleven lines of Triton spend none, and the compiler emits the same machinery from two numbers."
Triton's tuning parameters
Optimising a CUDA kernel can mean moving loads around: give a thread eight
outputs instead of one, widen four scalar loads into a float4, pad a
tile to dodge a bank conflict. Day 44 is four kernels
of exactly that, and each one changes the inner loop.
In Triton, the inner loop is one tl.dot. A small set of compile-time values
replaces the manual load schedule: the block shape, num_warps, and
num_stages, which is how many
iterations of the K loop the compiler keeps in flight at once. That last
one is the software pipeline day 74 builds by hand out
of cp.async and a barrier, written here as an
integer.
Because they are compile-time constants, one kernel source is a family of
kernels, and picking the member is a search. triton.autotune runs that
search for you: hand it a list of triton.Config objects and a key, and
the first launch at each new key times every configuration and caches the
winner. The exercise below turns this page's hand-written sweep into one.
Day 44 explains where the autotuner's candidate shapes come from. Its 128
by 128 block tile with an 8 by 8 micro-tile maps to BLOCK_M,
BLOCK_N and the register budget behind them, and the reason its
occupancy fell by half while it got faster is the reason a bigger
BLOCK_M here is often the right answer. You still choose the same values,
but the compiler implements them.
Two kernels, five constants and one string
Full program in
triton_softmax_matmul.py,
under code/day86-triton/. The environment selects one of two modes. Under
TRITON_INTERPRET=1, every kernel runs on the CPU through Triton's
interpreter and the program prints no timings.
GPU mode requires compute capability 8.0 and exits with status 1 below that version.
The softmax is the whole kernel:
@triton.jit
def softmax_kernel(in_ptr, out_ptr, row_stride, n_cols,
BLOCK_COLS: tl.constexpr):
# One program owns one row. Day 47's CUDA version hands one 256-thread
# block the same row, walks it three times, and reduces it twice
# through shared memory behind five written barriers. Here the row is
# one BLOCK_COLS-wide value and both reductions are one call each.
#
# BLOCK_COLS is a power of two at or above n_cols, so the tail lanes are
# masked. They load -inf, which loses the max and contributes exp(-inf)
# = 0 to the sum. A 0.0 pad would win the max on an all-negative row.
row = tl.program_id(0)
cols = tl.arange(0, BLOCK_COLS)
mask = cols < n_cols
x = tl.load(in_ptr + row * row_stride + cols, mask=mask,
other=-float("inf"))
num = tl.exp(x - tl.max(x, axis=0))
tl.store(out_ptr + row * row_stride + cols, num / tl.sum(num, axis=0),
mask=mask)
The matmul's signature carries the whole tuning surface. Five arguments are
tl.constexpr, so five numbers decide which kernel gets compiled:
@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,
BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr,
BLOCK_K: tl.constexpr, GROUP_M: tl.constexpr,
IEEE: tl.constexpr):
GROUP_M controls the block order. The compiler owns the shared-memory
layout, but not the order in which blocks run. The
lines below walk GROUP_M tile rows before moving right, so the blocks
resident at the same moment share more of A and B in L2 than a plain
row-major walk would:
pid = tl.program_id(0)
tiles_m = tl.cdiv(m, BLOCK_M)
tiles_n = tl.cdiv(n, BLOCK_N)
per_group = GROUP_M * tiles_n
first_m = (pid // per_group) * GROUP_M
rows_here = min(tiles_m - first_m, GROUP_M)
pid_m = first_m + ((pid % per_group) % rows_here)
pid_n = (pid % per_group) // rows_here
And the K loop, which is where the string in the heading lives:
# The accumulator is a value, not a buffer: no __shared__, no barrier,
# no double buffer. IEEE is a compile-time constant, so exactly one of
# these two branches survives into the compiled kernel.
acc = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32)
for step in range(0, tl.cdiv(k, BLOCK_K)):
left = k - step * BLOCK_K
a = tl.load(a_ptrs, mask=offs_k[None, :] < left, other=0.0)
b = tl.load(b_ptrs, mask=offs_k[:, None] < left, other=0.0)
if IEEE:
acc = tl.dot(a, b, acc, input_precision="ieee")
else:
acc = tl.dot(a, b, acc, input_precision="tf32")
a_ptrs += BLOCK_K * stride_ak
b_ptrs += BLOCK_K * stride_bk
input_precision decides "How to exercise the Tensor Cores for f32 x f32",
its allowed NVIDIA values are "tf32", "tf32x3", "ieee", "bf16x3"
and "bf16x6", and the default is "tf32"
(https://triton-lang.org/main/python-api/generated/triton.language.dot.html
, checked 2026-09-01). So a Triton FP32 matmul left alone uses
tensor cores, and day 44's
cuBLAS call deliberately does not: it pins
CUBLAS_COMPUTE_32F under CUBLAS_DEFAULT_MATH. Compare the two as they
come and the comparison measures that enum rather than the language.
The program runs both modes explicitly.
Counted rather than guessed, with the command in the README, these are non-blank non-comment lines from the signature to the end of the body:
| Kernel | CUDA | Triton |
|---|---|---|
| row softmax | 40, day 47's softmaxRows |
11 |
| FP32 matmul, register tiled | 52, day 44's matmulRegTile2d |
33 |
the same, with float4 loads |
75, day 44's matmulRegTile2dVec4 |
33 |
The softmax has about one quarter as many lines. The matmul does not, because Triton still requires the pointer arithmetic, the masks and the block ordering are the same work in either language.
Results
Interpreter and T4 gate verified; the GPU path remains blocked. The interpreter ran with Triton 3.7.1 and torch 2.13.0+cu126, passed softmax and both matmul modes, and exited 0.
GPU mode identified the Tesla T4 at compute capability 7.5, printed the documented 8.0 floor and exited 1 before launching a kernel, as designed. No compute-capability-8 card has run the timing sweep.
| Transcript | What it settles | Result |
|---|---|---|
interpret-2026-09-02.txt, CPU |
both kernels against the float64 reference | pass, exit 0 |
t4-gate-2026-09-02.txt, T4 node |
the 8.0 refusal, exit status 1 | pass |
run-*.txt, sm_80 or newer |
the same checks, then the timing sweep | blocked |
The blocked environment used the same free Colab T4 at compute capability
7.5 as day 79 and Compiler Explorer.
Compute capability is the versioned hardware feature
set used for this gate. The access snapshot in
FACT-SHEET.md section 4 also records paid Colab L4 and A100 options and an
isolated five-second Modal T4 run at about $0.011, with an sm_80 card priced
above it.
Four things the first sm_80 run can falsify
- Open until an sm_80 run. The prediction is that the tf32 matmul is measurably less accurate than the ieee one, by at least a factor of a hundred on the normalised error the program prints, because tf32 keeps ten explicit mantissa bits against f32's twenty-three. The program gates on the direction and not the factor. If the two errors come back equal, one of the runs did not use the requested multiplier.
- Open until an sm_80 run. The prediction is that the tf32 matmul is at least twice as fast as the ieee one at 2048 cubed on any card at compute capability 8.0 or newer. This is the one speed claim made anywhere on the page, it compares a kernel against itself with one argument changed, and it is gated.
- Partly held. The interpreter passed softmax, and its ieee and tf32
normalized errors were identical at
1.86e-07, a ratio of 1. That confirms the precision gap collapses in the interpreter. Agreement with a real GPU softmax remains untested. - Open until an sm_80 run. The prediction is that the best of the
three shapes is not the smallest.
128, 128, 32moves fewer bytes per output than64, 64, 32for the same reason day 44's 128 by 128 tile beat its 16 by 16 one, and it should win at 2048 cubed even though it asks for more registers and leaves fewer warps resident, which is the occupancy trade day 45 put a number on.
This page does not compare Triton and CUDA timings from different GPUs. Day 44's results used a T4 at compute capability 7.5, which cannot run this Triton GPU path. A ratio across different machines would not isolate the language.
Run it yourself
The GPU path needs compute capability 8.0 or newer. Triton supports Linux; use WSL2 on Windows. The setup options are listed at /setup/learn-cuda-without-a-gpu.
The correctness half costs nothing anywhere:
TRITON_INTERPRET=1 python3 triton_softmax_matmul.py
Exercise
Replace the three hand-written shapes in CONFIGS with a triton.autotune
decorator over a wider space, at least eight triton.Config objects,
keyed on the problem size. Report which shape it picks at 2048 cubed and
whether it beats the best row the shipped sweep prints.
Time: 30 to 45 minutes. Submit: autotuned.py, the chosen config,
the two times, and one sentence on why the first launch was slow.
Check: the harness runs the correctness half unchanged, both kernels against the float64 reference with the length-dependent tolerance from day 66 printed beside its own arithmetic, so a config that breaks the masks fails before it posts a time. On the measurement side it wants the autotuned time within five percent of the best hand-written row or better; a submission that is slower than a three-shape sweep has a bug in its key or is timing the search. On a card below compute capability 8.0 it prints the card and exits 1 without judging your kernel.
Hint 1
The three shapes in the file form a manual search. The autotuner must know when its cached answer no longer applies. Identify which problem dimensions can change the winning shape.
Hint 2
key=["m", "n", "k"]. The search runs once per distinct key and caches
the winner, so the first launch at a new size pays for every config in
your list. Look at where do_bench sits relative to that first launch.
Solution
The decorator goes above @triton.jit, takes configs=[...] and
key=["m", "n", "k"], and the constexpr arguments it owns come out of the
launch call. The first launch at each new key times every config, so it is
slow by roughly the size of your list, and a timing loop that includes it
reports the search rather than the kernel. Warm up once at the size you
intend to measure.
Expect a ratio near one against the best hand-written row. Eight shapes may not improve much on three shapes chosen using day 44's constraints. The autotuner repeats the search when the problem-size key changes, while the fixed list does not.
Pitfalls
Installation succeeded, then the kernel failed. Triton's supported
hardware is "NVIDIA GPUs (Compute Capability 8.0+)". pip does not check
the GPU, so the failure occurs when the kernel runs.
Use TRITON_INTERPRET=1 for the correctness half, or move to a card at 8.0
or newer.
pip install torch triton==3.8.0 does not resolve. torch 2.13.0's own
metadata reads triton==3.7.1; platform_system == "Linux" and python_version < "3.15" (https://pypi.org/pypi/torch/json , checked
2026-09-01), and the project pins triton==3.8.0. Take torch's pin or
install torch first and upgrade Triton with --no-deps; the README gives
both lines and asks the run to record which one it used.
Your Triton matmul beat your CUDA matmul under different precision modes.
tl.dot's input_precision defaults to "tf32", so an FP32 Triton
matmul reaches for tensor cores while day 44's cuBLAS comparison
deliberately does not.
Pin the string, or say in the caption which
multiplier each side used. This is day 44's own CUBLAS_COMPUTE_32F_FAST_TF32
pitfall arriving from the other direction.
You padded the softmax tail with zero. tl.load's other fills the
masked lanes, and a 0.0 pad wins the maximum on a row whose real values
are all negative, which silently changes every output on that row. The
neutral pad for a maximum is negative infinity, and it stays neutral in the
sum below it because exp takes it to zero.
You timed something under TRITON_INTERPRET=1. The setting "causes
all Triton kernels to bypass compilation and be simulated by the
interpreter using numpy equivalents of Triton operations", and "the
interpreter processes each Triton program instance sequentially"
(https://triton-lang.org/main/programming-guide/chapter-3/debugging.html ,
checked 2026-09-01). A millisecond figure from that describes Python.
The shipped program refuses to print one.
Your bfloat16 kernel works on the GPU but fails in the interpreter.
Same page: "It does not support operations on bfloat16 numeric types. To
perform operations on bfloat16 tensors, use tl.cast(tensor) to convert
the tensor to float32."
The interpreter supports fewer cases than the GPU compiler.
Go deeper
- Triton README, the compatibility section and the environment variables,
which is where the 8.0 floor and
TRITON_INTERPRETare stated: https://github.com/triton-lang/triton/blob/main/README.md (checked 2026-09-01) - Triton programming guide, chapter 3, "Debugging", on what the interpreter does and does not support: https://triton-lang.org/main/programming-guide/chapter-3/debugging.html (checked 2026-09-01)
triton.language.dot, forinput_precisionand its five allowed values: https://triton-lang.org/main/python-api/generated/triton.language.dot.html (checked 2026-09-01)- The upstream fused-softmax and matmul tutorials, which take both kernels further than this page does: https://triton-lang.org/main/getting-started/tutorials/index.html (checked 2026-09-01)
- Programming Massively Parallel Processors, 4th edition, chapter 6, on
the tiling and thread-granularity choices
BLOCK_MandBLOCK_Nare standing in for: https://shop.elsevier.com/books/programming-massively-parallel-processors/hwu/978-0-323-91231-0
Next
Day 87 wraps a kernel as a PyTorch operator with a backward pass. Day 89 compares Triton, cuTile, and hand-written CUDA without using unverified benchmark claims.
The kernel fusion argument from day 48 is one body here rather than one launch fewer, which is why an online softmax folded into a GEMM is the shape most Triton in the wild takes. That kernel is day 97, and reading the PTX it compiles to is how you inspect the compiler's choices.