all_lessons / Triton kernels / lessons / 15 · common operations lesson 15 / 21

Common operations, declarations, call flows

OpenAI Triton, the kernel DSL — not the inference server. We build the mental model from first principles: a Triton kernel is a program that owns a tile, and the compiler — not you — decides how threads, warps, vectorization, and shared memory realize that tile on the GPU.

Linearized thinking

Every Triton kernel walks the same chain. Memorize it; everything else is detail.

  1. Tile owner. pid = tl.program_id(axis) — this program instance owns one tile of work. There are grid-many such instances.
  2. Offsets. From the owner build a vector of element indices: offs = pid * BLOCK + tl.arange(0, BLOCK).
  3. Pointers. From offsets build a tensor of addresses: ptrs = base_ptr + offs * stride. Pointers are tensors, not scalars.
  4. Masked load. Read the tile through a boundary mask: x = tl.load(ptrs, mask=offs < n, other=0.0).
  5. Compute. Do tile/array math in plain Python operators; reductions accumulate in fp32.
  6. Masked store. Write back through the same mask: tl.store(out_ptrs, y, mask=offs < n).

1. The tile programming model from first principles

In CUDA you write the code for one thread. The thread owns one scalar (or a small handful), you compute its global index from blockIdx, blockDim, and threadIdx, and the hardware runs tens of thousands of copies of that scalar program. You — the author — are responsible for the thread-to-data mapping, for coalescing, for staging data through shared memory, and for the warp-level choreography.

In Triton you write the code for one program. A program owns a whole tile: a 1-D vector of BLOCK elements, or a 2-D block of BLOCK_M × BLOCK_N, etc. You express the math over the entire tile at once — x + y, tl.sum(x, axis=1), tl.dot(a, b) — as if it were NumPy. The Triton compiler then decides:

Key idea

Triton hides threads, not the GPU. You still think in tiles, masks, memory traffic, occupancy, and reductions — the things that determine performance. What you give up is hand-placing individual threads and writing explicit __shared__ staging. The unit of thought moves up one level: from a thread that owns a scalar to a program that owns a tile.

ConceptCUDA (scalar-per-thread)Triton (tile-per-program)
Unit of codeone threadone program instance
Ownsa scalar / few registersa tile (vector or block)
Index mathblockIdx*blockDim+threadIdxpid*BLOCK + tl.arange(...)
Thread mappingyou, explicitlythe compiler
Shared memorymanual __shared__ + synccompiler-managed staging
Boundsif (i < n) per threadmask = offs < n per tile

2. A minimal complete kernel: vector add

The "hello world" of Triton. One program owns one BLOCK-sized slice of the vectors; the grid covers all slices.

import torch
import triton
import triton.language as tl

@triton.jit
def add_kernel(
    x_ptr,            # *fp32, first input
    y_ptr,            # *fp32, second input
    out_ptr,          # *fp32, output
    n_elements,       # int, total length
    BLOCK: tl.constexpr,   # compile-time tile size
):
    # 1. tile owner: which BLOCK-slice is mine?
    pid = tl.program_id(axis=0)

    # 2. offsets: the element indices this program touches
    offs = pid * BLOCK + tl.arange(0, BLOCK)

    # 3. mask: the last program may run past the end
    mask = offs < n_elements

    # 4. pointers + masked loads (pointers are tensors of addresses)
    x = tl.load(x_ptr + offs, mask=mask, other=0.0)
    y = tl.load(y_ptr + offs, mask=mask, other=0.0)

    # 5. compute over the whole tile at once
    out = x + y

    # 6. masked store
    tl.store(out_ptr + offs, out, mask=mask)


def add(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
    assert x.is_cuda and y.is_cuda and x.shape == y.shape
    out = torch.empty_like(x)              # allocate output
    n = x.numel()

    # grid: a callable taking the meta-params, returns # of program instances
    grid = lambda meta: (triton.cdiv(n, meta["BLOCK"]),)

    # launch: kernel[grid](args..., constexpr kwargs, num_warps=...)
    add_kernel[grid](x, y, out, n, BLOCK=1024, num_warps=4)
    return out

Notes that matter at interview time:

3. The call flow

What actually happens between add(x, y) and the result tensor:

Python wrapper add(x, y) | v [ allocate output ] out = torch.empty_like(x) | v [ choose grid ] grid(meta) -> (cdiv(n, BLOCK),) # how many program instances | v [ launch ] add_kernel[grid](x, y, out, n, BLOCK=1024, num_warps=4) | v [ JIT specialize ] key = (constexprs, arg dtypes, pointer alignment, ...) | cache hit? -> reuse compiled kernel | cache miss -> compile a new specialization v [ compiler lowers ] tile ops -> Triton IR -> LLVM/PTX (threads, vectorize, smem, pipeline) | v [ run on GPU ] grid-many programs, each handles ONE BLOCK tile | pid 0: elems [0, 1024) | pid 1: elems [1024, 2048) | ... v [ return tensor ] out (enqueued on the current CUDA stream) Stream ordering: the launch is async, but it is enqueued on the same stream as the surrounding PyTorch ops, so the data dependency is preserved. No explicit sync needed before the next stream-ordered op reads `out`.

4. constexpr and JIT specialization

An argument annotated : tl.constexpr is a compile-time constant. The compiler bakes its value into the generated code — so it can unroll loops over it, size tl.arange, and pick vector widths. The cost: each distinct value of a constexpr (and each distinct combination of argument dtypes / pointer-alignment classes) is a separate specialization that must be compiled.

# BLOCK=1024 and BLOCK=2048 compile to two different kernels.
add_kernel[grid](x, y, out, n, BLOCK=1024)   # specialization A
add_kernel[grid](x, y, out, n, BLOCK=2048)   # specialization B (separate compile)

# num_warps / num_stages also key the specialization:
add_kernel[grid](x, y, out, n, BLOCK=1024, num_warps=8)   # yet another variant
Cache

Triton keeps a JIT cache (in-process and on disk, under ~/.triton/cache). The first launch with a given specialization key pays the compile cost; subsequent launches with the same key are essentially free dispatches. This is why a benchmark's first iteration is slow and why you should warm up before timing. It is also why varying BLOCK across thousands of distinct shapes can thrash the cache.

5. Vocabulary you must be able to define

TritonMeaningClosest CUDA intuition
@triton.jitMarks a Python function as a kernel to be JIT-compiled and launched with kernel[grid](...).__global__ function
tl.program_id(axis)Index of this program instance along a grid axis (0, 1, or 2).blockIdx.{x,y,z} — but per program, not per block of threads
tl.arange(0, N)A compile-time-sized vector [0, 1, ..., N-1]; N must be a constexpr power of two.the threadIdx sweep, materialized as data
tl.load / tl.storeRead/write a tile through a tensor of pointers, under a mask.coalesced global loads/stores (vectorization handled for you)
maskBoolean tile selecting valid lanes; off lanes read other and are not stored.per-thread if (i < n) guard
num_warpsWarps of execution resources assigned to each program instance.roughly blockDim / 32 (threads per block)
num_stagesSoftware-pipeline depth: how many loop iterations' loads are prefetched ahead of compute.manual double/triple buffering through shared memory
tl.constexprCompile-time constant argument; drives specialization.a template parameter / #define

6. Pointer arithmetic with strides

Triton does not assume your tensors are contiguous. You pass the strides explicitly and compute addresses yourself. This is what lets one kernel work on transposed views, slices, and non-contiguous layouts without a copy.

Consider scaling each row of a matrix X (M × N) by a per-row scalar. One program owns one row.

@triton.jit
def row_scale_kernel(
    x_ptr, scale_ptr, out_ptr,
    M, N,
    stride_xm, stride_xn,      # element strides of X
    stride_om, stride_on,      # element strides of OUT
    BLOCK_N: tl.constexpr,
):
    row = tl.program_id(axis=0)            # this program owns row `row`
    if row >= M:
        return

    s = tl.load(scale_ptr + row)           # scalar for this row

    # sweep the columns in BLOCK_N-wide chunks
    for start in range(0, N, BLOCK_N):
        offs_n = start + tl.arange(0, BLOCK_N)
        mask = offs_n < N

        # address = base + row*row_stride + col*col_stride
        x_ptrs   = x_ptr   + row * stride_xm + offs_n * stride_xn
        out_ptrs = out_ptr + row * stride_om + offs_n * stride_on

        x = tl.load(x_ptrs, mask=mask, other=0.0)
        tl.store(out_ptrs, x * s, mask=mask)
Pointers are tensors

x_ptr + row * stride_xm + offs_n * stride_xn adds a scalar offset to a vector offs_n, producing a vector of addresses. tl.load gathers from all of them at once. The compiler recognizes the affine pattern and emits coalesced, vectorized loads when the stride permits — but the address math is yours to express.

7. 2-D pointer blocks via broadcasting

To own a 2-D tile, build two 1-D offset vectors and broadcast them into a grid of addresses with None (newaxis) indexing — exactly like NumPy.

@triton.jit
def copy_block_kernel(
    x_ptr, out_ptr,
    M, N,
    stride_m, stride_n,
    BLOCK_M: tl.constexpr,
    BLOCK_N: tl.constexpr,
):
    pid_m = tl.program_id(axis=0)
    pid_n = tl.program_id(axis=1)

    offs_m = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)   # (BLOCK_M,)
    offs_n = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)   # (BLOCK_N,)

    # broadcast to a 2-D tile of addresses: (BLOCK_M, BLOCK_N)
    ptrs = x_ptr + offs_m[:, None] * stride_m + offs_n[None, :] * stride_n

    # 2-D mask: combine two boundary checks with bitwise &
    mask = (offs_m[:, None] < M) & (offs_n[None, :] < N)

    block = tl.load(ptrs, mask=mask, other=0.0)

    out_ptrs = out_ptr + offs_m[:, None] * stride_m + offs_n[None, :] * stride_n
    tl.store(out_ptrs, block, mask=mask)
Where Triton feels different

offs_m[:, None] has shape (BLOCK_M, 1) and offs_n[None, :] has shape (1, BLOCK_N); their sum broadcasts to (BLOCK_M, BLOCK_N). You are writing array math over a whole tile — no nested thread loops, no manual index arithmetic per element. The mask combines the row-bound and column-bound checks with a single &.

8. Masks and the other value

Any lane that is out of bounds, on the wrong side of a causal frontier, or otherwise invalid must be masked — both on load (so you do not read garbage / out-of-bounds memory) and on store (so you do not clobber memory you do not own). On load, other= supplies the value substituted for masked-off lanes.

The fill value must be the identity of the downstream reduction, or it will corrupt the result:

Downstream opCorrect otherWhy
sum / mean0.0adding 0 leaves the sum unchanged
product1.0multiplying by 1 is the identity
max (e.g. softmax)-float("inf")−∞ never wins a max; exp(-inf)=0 so it adds nothing to the softmax denominator
minfloat("inf")+∞ never wins a min
Bug

Using other=0.0 before a tl.max on the padded tail of a softmax row: if every real value is negative, the padding 0.0 becomes the max and the softmax is wrong. The fill leaked into the reduction. Match other to the reduction's identity.

9. Reductions inside a tile

Reductions over a tile use tl.sum, tl.max, tl.min (and friends) with an axis argument, mirroring NumPy. For a 2-D tile shaped (BLOCK_M, BLOCK_N):

@triton.jit
def row_sum_kernel(x_ptr, out_ptr, M, N,
                   stride_m, stride_n,
                   BLOCK_N: tl.constexpr):
    row = tl.program_id(axis=0)
    offs_n = tl.arange(0, BLOCK_N)
    mask = offs_n < N
    x = tl.load(x_ptr + row * stride_m + offs_n * stride_n,
               mask=mask, other=0.0)            # 0.0 is the identity for sum
    acc = tl.sum(x.to(tl.float32), axis=0)      # reduce in fp32
    tl.store(out_ptr + row, acc)
Cross-program reductions

tl.sum reduces only within one program's tile. A reduction spanning more data than one tile holds (a global sum, or a row wider than BLOCK_N handled by multiple programs) needs either a serial loop inside the program (as in §6) or a second launch to combine partial results. There is no global barrier inside a kernel — separate launches are Triton's equivalent of CUDA's grid-wide sync point. Common pattern: kernel 1 writes per-program partials, kernel 2 reduces them.

10. dtype handling

Loads from a bf16/fp16 tensor return bf16/fp16 tiles. Cast to fp32 before any reduction or accumulation, then cast back to the output dtype on store.

x = tl.load(x_ptr + offs, mask=mask, other=0.0)   # bf16 tile
acc = tl.sum(x.to(tl.float32), axis=0)            # accumulate in fp32
tl.store(out_ptr + row, acc.to(x.dtype))          # store back as bf16
Numerical reason

bf16 has ~8 bits of mantissa and fp16 ~11. Summing many such values accumulates rounding error fast — and bf16 in particular will silently stop growing once the running sum dwarfs the next addend (swamping). Accumulating in fp32 (~24-bit mantissa) keeps the partial sums accurate; the single final round-to-bf16 on store is the only lossy step. This is the same reason hardware tensor cores accumulate matmuls in fp32.

11. Choosing num_warps and num_stages

Rule of thumb

Pick num_warps and num_stages from tile size and arithmetic intensity, then autotune over a few candidates rather than guessing. Lesson 02 covers @triton.autotune and the performance reasoning in depth.

12. PyTorch autograd integration

To make a Triton kernel differentiable, wrap forward and backward launches in a torch.autograd.Function. Save what backward needs in ctx; return one gradient per forward input.

class FusedAdd(torch.autograd.Function):
    @staticmethod
    def forward(ctx, x, y):
        out = torch.empty_like(x)
        n = x.numel()
        grid = lambda meta: (triton.cdiv(n, meta["BLOCK"]),)
        add_kernel[grid](x, y, out, n, BLOCK=1024)
        # nothing input-dependent needed for d(x+y); ctx would hold tensors otherwise
        return out

    @staticmethod
    def backward(ctx, grad_out):
        # d(x+y)/dx = 1, d(x+y)/dy = 1  ->  both grads pass through unchanged
        return grad_out, grad_out

# usage
z = FusedAdd.apply(x, y)

backward returns a gradient for each positional input to forward (here x and y), in order; return None for non-tensor or non-differentiable inputs.

Verify

Check correctness with torch.autograd.gradcheck on an fp64 input — the finite-difference reference needs the precision. For kernels that only run in fp32/bf16, fall back to comparing against a pure-PyTorch reference with loose tolerances (e.g. torch.testing.assert_close(out, ref, rtol=1e-2, atol=1e-2) for bf16). Never gradcheck a bf16 kernel directly; the finite differences are pure noise.

Interview checklist

  1. State what one program owns. A 1-D slice? A row? A 2-D BLOCK_M × BLOCK_N block? Everything else follows from this.
  2. Derive the pointer tensors from tl.program_id and tl.arange, multiplying offsets by the passed-in strides (do not assume contiguity); broadcast with [:, None] / [None, :] for 2-D.
  3. Mask every boundary on both load and store, and pick other to be the identity of the downstream reduction (0 for sum, −inf for max).
  4. Cast to fp32 for any reduction/accumulation; store back in the output dtype.
  5. Choose BLOCK / num_warps / num_stages from tile size and arithmetic intensity (and autotune).
  6. Say whether it reduces within one program or needs a multi-pass launch — there is no global barrier inside a kernel.