Triton from zero · Lesson 1 of 7

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.

The restaurant analogy

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:

The core idea

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.

The recipe-card analogy

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:

The grid, animated

N = 1000 elements, BLOCK_SIZE = 256 → 4 programs. Click a segment.

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 tray analogy

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

CodeWhat 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_ptrPointers to memory on the GPU — the actual data lives in a tensor you allocated with torch; you pass a reference.
n_elementsA runtime scalar: “how long is my array?” Can change per launch, no recompile needed.
BLOCK_SIZE: tl.constexprA 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 + offsetsPointer 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:

programcoversstatus
0elements 0–255✔ all real
1elements 256–511✔ all real
2elements 512–767✔ all real
3elements 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_elements is a runtime value: known when you launch the kernel, can be anything (1000, 77, 12345). Compiles once, runs with different values.
  • BLOCK_SIZE: tl.constexpr is 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 recipe-card analogy, continued

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.

Gotcha

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.

Grid calculator

The hatched edge of a segment shows the masked-off (out-of-bounds) part.

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

ConceptStatus
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