The Triton Mental Model
Why GPUs exist, what a kernel is, and the two paradigm shifts that will rewire how you think about code. Everything you need to understand Triton — not just copy it.
▶ How to run the code (Colab · T4 GPU)
Create a Colab notebook with a GPU runtime (Runtime ▸ Change runtime type ▸ T4 GPU), then in the first cell run:
!nvidia-smi
!pip install -q triton
Paste the kernel code from any lesson into the next cell. No GPU at hand? Run locally — the lesson scripts
fall back to Triton’s CPU interpreter automatically
(source /home/tensor/.venv/bin/activate, then
cd decode-lab/artifacts and python lesson1_vector_add.py).
1Why GPUs exist — and why it matters
Your CPU has maybe 8 very smart cores. Your GPU has thousands of dumb-but-fast cores. The entire story of Triton is about knowing which jobs suit which.
CPU = a team of 8 master chefs. Each can cook anything: soufflés, molecular gastronomy, dishes that change mid-cooking. Each chef is expensive — and there are only 8 of them.
GPU = a brigade of 4,000 line cooks. Each one can only follow a simple recipe card exactly — but there are 4,000 of them, and they all read their cards simultaneously.
“Peel 10,000 potatoes” is perfect for the line cooks. “Invent a signature dish” is perfect for the master chefs.
“Peeling 10,000 potatoes” is what we call data-parallel work: the same operation applied to a huge amount of data. Adding 1 to every element of a million-element array. Matrix multiplication. Attention. This is ~99% of deep learning’s compute — which is why GPUs train models.
Triton is how you write your own recipe cards for the line cooks.
2The paradigm shift: write one, run many
This is THE idea. Read it twice:
In Triton, you write the code for ONE unit of work. The GPU runs that code on THOUSANDS of units simultaneously.
In normal Python you write code that processes one thing at a time:
for i in range(1000): # the CPU does step i, then step i+1, then...
out[i] = x[i] + y[i] # ...one element per loop iteration
In Triton you write a kernel — a function decorated with @triton.jit:
@triton.jit
def add_kernel(x_ptr, y_ptr, output_ptr, n_elements, BLOCK_SIZE: tl.constexpr):
pid = tl.program_id(axis=0) # "which copy am I?"
block_start = pid * BLOCK_SIZE
offsets = block_start + tl.arange(0, BLOCK_SIZE)
mask = offsets < n_elements
x = tl.load(x_ptr + offsets, mask=mask)
y = tl.load(y_ptr + offsets, mask=mask)
tl.store(output_ptr + offsets, x + y, mask=mask)
…and then you launch it with a grid:
add_kernel[grid](x, y, out, N, BLOCK_SIZE=256) # grid = (4,)
The grid is the magic number — it says how many copies of this function to run at once.
The GPU photocopies your kernel grid[0] times (4 here) and runs them in parallel.
You write one recipe card. The kitchen manager (the [grid] syntax) photocopies it 4 times
and hands one to each of 4 line cooks. Each cook runs the same code, but needs to know which
portion of the potatoes is theirs — that’s what pid = tl.program_id(axis=0) is for: their
badge number.
Every program computes its own pid, so every program knows its own chunk. Same code,
different data. That’s the whole game. Play with it below — click a program to see what it owns:
3The second paradigm shift: tile code, not scalar code
Here’s what trips up almost every Triton beginner (me included, back in the day). Look at
tl.arange(0, BLOCK_SIZE):
offsets = block_start + tl.arange(0, BLOCK_SIZE)
tl.arange(0, 256) is a vector of 256 indices: [0, 1, 2, ..., 255].
So offsets is also a vector — [768, 769, ..., 1023] for program 3.
That means every operation in your kernel works on a whole block of data at once, not one element:
x = tl.load(x_ptr + offsets) # loads 256 elements in ONE operation
tl.store(output_ptr + offsets, x + y, mask=mask) # adds 256 pairs in ONE operation
The scalar mindset is a line cook picking up one potato at a time. Triton’s mindset is a line cook
grabbing a whole tray of 256 potatoes at once. The tray (a BLOCK_SIZE-sized
vector) is your unit of work. Your kernel never says “do this to element i” — it says “do this to
this tray of elements”.
And that’s exactly why Triton is fast: the compiler takes your tray-level operations and maps them onto
the GPU’s vector hardware and its thousands of threads automatically. You never manage threads.
In raw CUDA you’d write threadIdx.x, blockIdx.x, and worry about warps and shared memory —
in Triton you just describe tiles of data, and the compiler handles the machinery.
4The anatomy of a kernel: the 5-step universal pattern
Every Triton kernel you’ll ever write follows this skeleton:
1. "Which program am I?" → pid = tl.program_id(axis=0)
2. "Which data do I own?" → offsets = pid * BLOCK + tl.arange(0, BLOCK)
3. "Am I out of bounds?" → mask = offsets < n_elements
4. "Read my data" → x = tl.load(ptr + offsets, mask=mask)
5. "Write my result" → tl.store(ptr + offsets, result, mask=mask)
Step 5 can be a huge computation (a matrix multiply, an attention score) — but the skeleton is always the same.
Line by line, in plain English
| Code | What it really means |
|---|---|
@triton.jit | “Compile this function into a GPU kernel.” Think: mark this as a recipe card, not a normal Python function. |
x_ptr, y_ptr, output_ptr | Pointers to memory on the GPU — the actual data lives in a tensor you allocated with torch; you pass a reference. |
n_elements | A runtime scalar: “how long is my array?” Can change per launch, no recompile needed. |
BLOCK_SIZE: tl.constexpr | A compile-time constant: the tray size. Baked into the recipe card. (§6) |
tl.program_id(axis=0) | My badge number in the grid (0, 1, 2, …). axis=0 because grids can be 2D or 3D — Lesson 2. |
tl.arange(0, BLOCK_SIZE) | A vector [0, 1, ..., BLOCK_SIZE-1]. The tray’s indices. |
x_ptr + offsets | Pointer arithmetic — “the addresses of my tray’s elements”. A tensor of pointers. |
tl.load(...) / tl.store(...) | Move data between GPU memory and the program’s working registers. |
mask=... | A guard: only touch elements where the mask is true. (§5) |
5Why the mask? The ragged-edge problem
This is the one thing beginners usually get wrong. Suppose N = 1000 and BLOCK_SIZE = 256:
| program | covers | status |
|---|---|---|
| 0 | elements 0–255 | ✔ all real |
| 1 | elements 256–511 | ✔ all real |
| 2 | elements 512–767 | ✔ all real |
| 3 | elements 768–1023 | ✘ elements 1000–1023 DON’T EXIST |
Element 1000 of a 1000-element array is out of bounds. If program 3 tried to read it, you’d get garbage (or a crash). The mask acts as a safety fence:
mask = offsets < n_elements # [768..999] → True, [1000..1023] → False
And you pass that same mask to load and store, so out-of-bounds elements are simply
skipped. Every real Triton kernel has a mask somewhere — because tile sizes almost never divide the data
size evenly.
6tl.constexpr vs runtime arguments
A subtle but critical thing:
def add_kernel(x_ptr, y_ptr, output_ptr, n_elements, BLOCK_SIZE: tl.constexpr):
n_elementsis a runtime value: known when you launch the kernel, can be anything (1000, 77, 12345). Compiles once, runs with different values.BLOCK_SIZE: tl.constexpris a compile-time value: known when the kernel is compiled.
Why does this matter? Because Triton is a metaprogramming language — values marked
tl.constexpr can control the program’s structure, like the size of a tl.arange,
which must be fixed at compile time. The compiler needs to know the tray size to generate the vector instructions.
The tray size is printed on the recipe card — it’s part of the card. If you want a different tray size, the kitchen has to re-print the cards (recompile). But the number of potatoes is runtime data: it changes every day without re-printing anything.
tl.arange(0, BLOCK_SIZE) requires BLOCK_SIZE to be a
tl.constexpr — you literally cannot pass a runtime value there, because the length of a vector
must be known at compile time. If you ever see an error complaining that arange needs a constexpr,
this is why.
The tl. prefix everywhere just means “this function comes from Triton’s language library” —
it’s the standard name for all Triton operations, exactly like np. is NumPy.
7Playground: the grid calculator
Change N and BLOCK_SIZE and watch the grid re-shape itself. This is exactly the
interaction from homework #1 and #2 — play with it first, then run it on the GPU.
8Homework — seriously, do this
Run lesson1_vector_add.py on Colab, then try each of these by modifying the code.
Hints are collapsible — try it first, peek only if stuck.
1 · Change BLOCK_SIZE to 128, then to 1024 (with N = 1000)
Does the result stay correct? Watch the printed grid: cdiv(1000, 128) = 8 programs,
cdiv(1000, 1024) = 1 program. The mask is doing its job — try N = 999, an odd number, too.
2 · Make N an odd number like 999
Still correct — because of the mask. Every block except the last one is full;
the last block only stores the elements offsets < 999.
3 · Change x + y to x * y, and fix the check
One-line changes: tl.store(output_ptr + offsets, x * y, mask=mask) and
expected = x * y.
4 · The real test: write scale_kernel from scratch
Multiply x by a runtime scalar scalar and write to out — without
looking at the file. If you can do this, you understand Lesson 1.
@triton.jit
def scale_kernel(x_ptr, out_ptr, n_elements, scalar, BLOCK_SIZE: tl.constexpr):
pid = tl.program_id(axis=0)
offsets = pid * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
mask = offsets < n_elements
x = tl.load(x_ptr + offsets, mask=mask)
tl.store(out_ptr + offsets, x * scalar, mask=mask)
scale_kernel[(triton.cdiv(N, BLOCK_SIZE),)](x, out, N, 3.0, BLOCK_SIZE=256)
5 · Brain-bender: remove the mask when N is divisible by BLOCK_SIZE
With N = 1024 and BLOCK = 256 every block is exactly full, so the
kernel works without a mask. That’s why many production kernels require sizes divisible by the block —
and mask only when they can’t guarantee it. (Try removing the mask on this page’s calculator with N = 1024:
no hatched edge appears. That’s the “mask-free” case.)
9Your learning journey
| Concept | Status |
|---|---|
| CPU vs GPU, data-parallelism | ✔ covered |
Kernel = recipe card, grid = copies | ✔ covered |
pid / program_id — which copy am I | ✔ covered |
Blocks & tiles — tray-level code with tl.arange | ✔ covered |
load / store / masks / pointer arithmetic | ✔ covered |
tl.constexpr vs runtime args | ✔ covered |
| Next: 2D tiles, broadcasting, and your first matrix multiply | ⏳ Lesson 2 |