Chapter 4: Your First Kernel: Vector Addition
Build the canonical Triton kernel end to end: the anatomy of a @triton.jit function, computing per-program offsets with program_id and arange, loading and storing tiles through pointers, masking the ragged tail, choosing BLOCK_SIZE, and launching the grid from PyTorch.
- Topics
- 5
- Videos
- 3
- Min read
- 16
Chapter Overview
Vector addition — out = x + y — is the 'hello world' of GPU programming. It's trivial math, which is exactly the point: with the arithmetic out of the way, every line of the kernel is about the mechanics of GPU programming that you'll reuse in every kernel afterward.
By the end of this chapter you will understand each line of the canonical add_kernel: how tl.program_id tells a program which slice of the data it owns, how tl.arange builds a vector of offsets, how pointer arithmetic plus tl.load/tl.store move whole tiles between DRAM and on-chip memory, why the last program needs a mask, and how BLOCK_SIZE and triton.cdiv set up the launch grid.
This is the template. Fused softmax, layer norm, and matmul are all elaborations of the same skeleton: figure out which tile you own, load it, compute on it in fast memory, store it back. Master vector add and the rest of the course is variations on a theme.
This chapter covers:
- Anatomy of a Triton Kernel: the reusable skeleton
- Program IDs & Offsets: which data does this program own?
- Loads, Stores & Masking: moving tiles safely
- Choosing BLOCK_SIZE: the first performance knob
- Launching the Grid: connecting PyTorch to the kernel
Chapter Roadmap
Click any topic to jump in
Anatomy of a Kernel
The reusable five-step skeleton: id → offsets → mask → load → compute+store.
Program IDs & Offsets
program_id × BLOCK_SIZE + arange gives the tile of indices this program owns.
Loads, Stores & Masking
tl.load/tl.store move tiles; mask = offs < n keeps the ragged tail safe.
Choosing BLOCK_SIZE
The first perf knob — trades per-program overhead against occupancy and register use.
Launching the Grid
Host side: allocate output, grid = cdiv(n, B), launch with [grid]; sync to time it.
Topics
5 topics in this chapter
Chapter 3 introduced Triton's vocabulary of programs, grids, tiles and @triton.jit, but a vocabulary is not a kernel. The practical problem now is writing one that is both correct and fast, and the first kernel nearly everyone writes is vector addition: out[i] = x[i] + y[i] for every index. It is the GPU equivalent of hello world because it has no arithmetic to hide behind. With one FLOP per 12 bytes moved, its speed is set entirely by how well it uses DRAM bandwidth, so every inefficiency in addressing or memory access shows up directly in the timing. Almost every elementwise Triton kernel follows the same five-step skeleton: read the program id, compute offsets, build a mask, load, then compute and store. This topic dissects that skeleton line by line and shows that changing the operation, to ReLU for example, is a one-line edit. On an RTX 2060, the 13-line kernel moves 50 million floats at 273 to 281 GB/s, the same as torch.add and 81 to 84 percent of the 336 GB/s peak. The following topics then examine each step: offsets, masking, block size and the host-side launch.
Definition
A Triton elementwise kernel is a @triton.jit function that receives tensor pointers, a runtime length and tl.constexpr tile sizes, and returns nothing. Each program reads its id, computes the offsets of its tile, masks out-of-range lanes, loads the input tiles, computes the result, and stores it through an output pointer.
The Five-Step Skeleton
A typical Triton kernel does, in order: (1) get the program id; (2) compute the offsets of the tile this program owns; (3) build a mask for any out-of-bounds offsets; (4) tl.load the input tiles into on-chip memory; (5) compute, then tl.store the result back. The kernel body is pure tile algebra — no thread indices, no barriers.
The inputs are passed as pointers (PyTorch tensors decay to their data pointer when launched), plus runtime sizes and tl.constexpr tile dimensions. The kernel writes its results in place to an output pointer; Triton kernels return nothing. This skeleton is what makes Triton readable: you can scan any kernel and immediately find the offset computation, the loads, the math, and the store.
The skeleton separates addressing (steps 1–3, pure index arithmetic independent of the math) from data movement (4 and the store) from computation (5). This separation is why Triton can optimize addressing and movement automatically while you only ever touch the computation line — the part that's actually unique to your kernel.
Sketch the skeleton of a kernel that computes out = relu(x) for a 1D tensor. Which steps differ from vector add?
Related Problems on PixelBank
The skeleton from the previous topic starts with a puzzle. Every program in the grid runs exactly the same code, so how does each one know which part of the data to process? If two programs computed the same offsets, they would duplicate work and race on the same output; if a gap opened between them, part of the output would never be written. Triton resolves this with a single integer. tl.program_id(axis) returns the program's index along a grid axis, and from it each program derives a contiguous range of global indices: start at pid * BLOCK_SIZE, then add the vector tl.arange(0, BLOCK_SIZE). The result, offs, is itself a tile of indices, and every later load, arithmetic op and store acts on all of them at once. Because consecutive programs own consecutive, non-overlapping ranges, the tiling is disjoint and complete, and neighbouring lanes touch neighbouring addresses, which is what makes the loads coalesce. This topic works through the arithmetic for a 1D grid, where program 10 with 256-element tiles owns indices 2560 to 2815. It then extends the idea to 2D tiles with broadcasting, the addressing pattern matmul uses later.
Definition
tl.program_id(axis) returns the calling program's index along one grid axis, from 0 to the grid size minus one. Offsets are the tile of global element indices a program owns, formed as pid * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE) in 1D. In 2D, two such ranges are broadcast and combined with strides to address a BLOCK_M by BLOCK_N tile.
From Program ID to a Tile of Indices
tl.program_id(axis=0) returns this program's index along the given grid axis — an integer from 0 to grid_size−1. Multiplying by BLOCK_SIZE gives the starting offset of this program's tile. Adding tl.arange(0, BLOCK_SIZE) — a compile-time vector [0, 1, …, BLOCK_SIZE−1] — produces offs, the full vector of global indices the program will touch.
offs is a tile, not a scalar: every subsequent tl.load, arithmetic op, and tl.store operates on all BLOCK_SIZE indices at once. This is block-oriented programming made concrete — one expression describes an entire tile's worth of addresses, and the compiler turns it into coalesced, vectorized memory operations.
For an axis of length with tile size , program ids range over and partition into contiguous tiles. The map is a clean tiling: disjoint, covering, and contiguous — which is also what guarantees coalesced memory access along that axis.
With BLOCK_SIZE = 256, what offsets does program 10 compute, and how would you adapt this for a 2D grid where each program owns a BLOCK_M × BLOCK_N tile?
Related Problems on PixelBank
The previous topic gave each program a tile of offsets, but because the grid rounds up, the last tile almost always extends past the end of the data. For 1,000 elements in 256-element tiles, the fourth program's offsets run to 1023, and 24 of its lanes point at memory the tensor does not own. Reading there returns garbage, and writing there corrupts whatever the allocator placed next. The worst part is that it usually fails silently. In the probe on an RTX 2060, an unmasked fill with n = 1,000 wrote into allocator padding and appeared harmless, while n = 1,100 overwrote the first 128 elements of a neighbouring tensor without any error. tl.load and tl.store are where tiles cross between DRAM and registers, and their mask argument is what makes that crossing safe. A masked-off load substitutes the other value; a masked-off store writes nothing. This topic covers both calls, the mask = offs < n idiom, why other matters for reductions, and the cost of masking. On an exact multiple of the block size, a masked add ran within 0.5 percent of an unmasked one. The next topic turns to the block size itself.
Definition
tl.load(ptr + offs, mask, other) reads a tile from global memory, touching only lanes where mask is true and returning other elsewhere; with no other, masked lanes hold undefined values. tl.store(ptr + offs, value, mask) writes only the masked-in lanes. The mask offs < n disables lanes past the end of the data.
Moving Tiles Safely with Masks
tl.load(ptr + offs, mask=mask, other=0.0) reads a tile from global memory: for each lane where mask is true it loads ptr[offs], and where mask is false it substitutes other (default 0) instead of touching memory. tl.store(ptr + offs, value, mask=mask) writes only the masked-in lanes.
The mask exists because the grid rounds up: the last program's offs can exceed n. Without a mask, those lanes would read or write past the end of the tensor — an illegal memory access that corrupts data or crashes the kernel. The idiom mask = offs < n switches off exactly the overhanging lanes. Masking is cheap and non-negotiable for any size that isn't a perfect multiple of BLOCK_SIZE.
The mask is a boolean tile with the same shape as offs, and each lane's memory instruction is predicated on it. On a full tile it is all true; on the last tile it disables lanes. The cost is one comparison per element and no extra memory traffic: on with the masked add took 1,439 µs versus 1,432 µs unmasked, 0.5 percent. Set other deliberately: the Triton docs leave masked lanes undefined without it, and a reduction needs the identity, 0 for a sum and for a max.
A kernel processes n = 1000 elements with BLOCK_SIZE = 256. The last program's offsets are [768..1023]. What goes wrong without a mask, and what does mask = offs < 1000 do?
Theory Exercise
Problem:
A beginner removes the mask 'because it makes the kernel faster' and tests it only on arrays whose length is an exact multiple of BLOCK_SIZE — it passes. Why is this dangerous, and what is the real cost of the mask?
Hints:
- When is offs guaranteed to stay in bounds without a mask?
- What happens the first time n is not a multiple of BLOCK_SIZE?
- How expensive is a mask compared to a memory access?
Related Problems on PixelBank
With the kernel correct, the previous topics leave one free parameter: how many elements each program should own. BLOCK_SIZE is a tl.constexpr, so every value compiles a different kernel, and the choice trades opposing costs. Tiny tiles mean millions of programs, each paying scheduling and indexing overhead for very little data. Huge tiles mean each thread must keep more values in registers, which reduces how many programs fit on a streaming multiprocessor and, past the 255-register limit, forces spills to slow local memory. The usual folklore says to stay small to maximise occupancy, but measurements tell a more useful story. On an RTX 2060 adding 50 million floats, BLOCK_SIZE 32 reached only 132 GB/s; every size from 64 to 16,384 landed between 271 and 282 GB/s; and 65,536 spilled 1,326 registers and collapsed to 61 GB/s. The plateau is wide because a memory-bound kernel needs enough bytes in flight, which deep per-thread work supplies as well as extra warps do. This topic explains both cliffs, the register arithmetic behind them, and how num_warps interacts with tile size. Chapter 5 then automates the search with autotuning.
Definition
BLOCK_SIZE is the compile-time number of elements each program processes, and it must be a power of two for tl.arange. With num_warps w, each thread handles BLOCK_SIZE / (32w) elements. It trades per-program overhead, which falls as tiles grow, against registers per thread, which rise and limit residency or spill.
The First Performance Knob
BLOCK_SIZE sets how many elements each program owns. Small tiles mean many programs and little data per program, so scheduling and indexing overhead dominate. Large tiles give each thread more elements, which raises registers per thread: 26 at 1,024 and 250 at 16,384 on the RTX 2060 with 4 warps. Fewer programs then fit per SM, but each thread keeps more loads in flight, so a memory-bound kernel can stay fast at low occupancy. Past the 255-register cap the compiler spills to local memory and throughput collapses. Use powers of two, start at 512 to 2,048 for elementwise kernels, and benchmark, as Chapter 5 automates.
Two costs move in opposite directions. Per-program overhead per element scales like , so it dominates at tiny . Registers per thread scale like , which limits resident programs: at , , 250 registers 128 threads is 32,000, so only 2 programs fit in a 65,536-register SM, 8 warps out of 32, yet throughput held at 277 GB/s. Bandwidth needs bytes in flight (Little's law), and 128 floats per thread supply that without extra warps, Volkov's point. The real cliff is the 255-register cap, where spills begin.
For a memory-bound vector add, would you expect BLOCK_SIZE = 32 or BLOCK_SIZE = 1024 to perform better, and why isn't 'as big as possible' always right?
Related Problems on PixelBank
The kernel from the previous topics is only half of the program. Something on the host has to allocate the output, decide how many programs to launch, pass the arguments in the right form and tell you when the work is finished. That last point trips up nearly every first benchmark. A Triton launch is asynchronous: add_kernel[grid](...) places the kernel on a CUDA stream and returns as soon as it is queued. Timing that call with a CPU clock measures the launch, not the GPU work. On an RTX 2060, a 50-million-element add read 48 microseconds without synchronization and 2,157 microseconds with it, a 45 times error. Physics provides a sanity check: the add moves 600 MB, which takes at least 1.79 ms at the card's 336 GB/s peak, so any shorter time is wrong. This topic covers the host side end to end: torch.empty_like for the output, a grid written as lambda meta: (triton.cdiv(n, meta['BLOCK_SIZE']),) so it follows the tile size, tensors passed as pointers, and correct timing with torch.cuda.synchronize or triton.testing.do_bench. Chapter 5 builds on this timing to benchmark and autotune kernels.
Definition
Launching a Triton kernel means calling kernel[grid](*args, **constexprs) from host code. The grid is a tuple, or a function of the meta-parameters returning one, giving the number of programs per axis; tensors are passed as device pointers. The call enqueues work asynchronously on the current CUDA stream and returns before the GPU finishes.
Connecting Host Code to the Kernel
On the host (regular PyTorch) you: allocate the output tensor (out = torch.empty_like(x)), define the grid as a function of the meta-parameters (grid = lambda meta: (triton.cdiv(n, meta['BLOCK_SIZE']),)), and launch with add_kernel[grid](x, y, out, n, BLOCK_SIZE=1024). Passing a tensor hands the kernel its data pointer automatically.
The grid is a tuple (1D, 2D, or 3D) giving the number of programs per axis. Writing it as a lambda meta: … lets it depend on the very BLOCK_SIZE you pass (and on autotuned configs later). The launch is asynchronous: it enqueues the kernel and returns immediately, so to time it you must synchronize (torch.cuda.synchronize()) — a detail that matters for the benchmarking in Chapter 5.
The grid count and the async launch are independent concerns. Correctness needs the grid to cover ; measuring correctly needs synchronization because GPU work overlaps host execution. Forgetting cdiv gives wrong results; forgetting synchronize gives wrong timings — two classic first-kernel mistakes.
You wrote a correct add_kernel and time it on 50 million float32 elements with time.perf_counter() around the launch. The benchmark reports about 50 microseconds, which is suspiciously fast. What did you forget, how can you tell from bandwidth alone that the number is wrong, and how do you fix both the grid and the timing?