all_lessons / Triton kernels / lessons / 17 · algorithms & data structures lesson 17 / 21

Algorithms, data structures, and the line of thinking

The whole skill is recognizing when a problem fits the shape Triton is good at — a regular tile, a local reduction, a cheap store — and when it doesn't, so you hand it to CUDA, CUB, cuBLAS, or PyTorch instead.

Linearized thinking
  1. Represent the data structure as pointer arithmetic. A row of a matrix is base + row*stride_m + arange(BLOCK)*stride_n. A CSR row is col_ptr + start + arange(BLOCK). A block-sparse plan is an index table metadata[q_block, slot]. Everything in Triton is ultimately a base pointer plus an offset tensor plus a mask.
  2. Ask the fit question. Can a single program (a) load the region it needs into a tile, possibly with masking, (b) reduce or transform it locally with tl.sum / tl.max / tl.dot / an online update, and (c) store the result with little or no cross-tile coordination?
  3. If yes, Triton fits. You write one tight kernel and fusion saves the HBM round-trips a library would pay.
  4. If no — global ordering, dynamic queues, data-dependent control flow, multi-pass coordination — prefer CUDA / CUB / cuBLAS / PyTorch. Forcing those into Triton means atomics, multiple launches, and wasted lanes. The library already solved it.

The fit map

Before any kernel, the interview answer is a triage. Memorize this table as the first thing you say:

PatternTriton fitReasoning
Elementwise / fused epilogueExcellentPure map: each program owns a tile, no neighbor depends on it. Fusion kills intermediate HBM traffic (bias + activation + cast in one pass).
Row reductions (softmax, norm, max)Excellent when a row fits a tileOne program per row, reduce along the free axis with tl.sum/tl.max. Zero cross-program talk if the row fits BLOCK_N.
Matmul-like dotGood → Excellenttl.dot lowers to tensor cores. Great for fused GEMM epilogues and attention; raw peak GEMM still belongs to cuBLAS.
Global scan / sortUsually poorNeeds multi-pass global coordination and a deterministic order across all programs. Use CUB / Thrust.
Graph traversalUsually poorIrregular frontiers, dynamic work queues, heavy atomics — that is CUDA warp-level programming, not regular tiles.
Block-sparse attentionGoodMetadata picks a list of regular blocks, so the irregular part is precomputed and the kernel does regular block matmuls.
THE THROUGH-LINE

Every "Excellent" row shares a property: the work each program does is independent of what other programs decide at runtime. Every "poor" row breaks it — the answer for one element depends on a global ordering or on a frontier discovered while running. Triton has no efficient story for runtime-discovered, cross-program dependencies; that is precisely the gap CUDA and CUB fill.

1 · Row-wise reduction as a data-structure primitive

A row-major matrix A of shape M×N is already a data structure: the i-th row lives at A_ptr + i*stride_m, and the j-th element of that row at + j*stride_n. There is no pointer chasing — the address is arithmetic. That is why row reductions are the canonical Triton win: assign one program per row, load the row into a tile, reduce.

import triton
import triton.language as tl

@triton.jit
def row_max_kernel(A_ptr, out_ptr,
                   M, N,
                   stride_m, stride_n,
                   BLOCK_N: tl.constexpr):
    row = tl.program_id(0)                  # this program owns one row
    if row >= M:
        return

    # pointer tensor for the whole row (assumes N <= BLOCK_N)
    offs_n = tl.arange(0, BLOCK_N)
    ptrs = A_ptr + row * stride_m + offs_n * stride_n
    mask = offs_n < N

    # other=-inf so masked lanes never win the max
    vals = tl.load(ptrs, mask=mask, other=float("-inf"))

    m = tl.max(vals, axis=0)                # local reduction, no cross-program comm
    tl.store(out_ptr + row, m)

The grid is (M,). The crucial line is other=float("-inf"): masked lanes load a sentinel that can never be the maximum, so the reduction over BLOCK_N lanes equals the reduction over the real N elements. Sum reductions use other=0.0 for the same reason.

WHEN IT FITS

If N ≤ BLOCK_N the row fits one tile, the reduction is entirely register/shared-memory local, and there is zero cross-program communication. One launch, one pass, done. This is the ideal Triton shape.

WHEN THE ROW DOESN'T FIT

If N is huge (say 200k), a single program can't hold the row. Split it: launch a grid of (M, n_chunks), each program reduces its chunk to a partial maximum, write the partials to a scratch buffer of shape M×n\_chunks, then a second kernel reduces the partials per row. The moment you need a second pass, you've left the cheapest regime — it still works, but you're now coordinating across programs, and that is the early-warning sign that bigger versions of the problem belong to a library.

2 · Top-k for small k without sorting

You don't need a sort to find the largest two elements of a row. You need tl.max, tl.argmax, and a mask. The trick: find the max, record its index, overwrite that lane with -∞ using tl.where, find the max again.

@triton.jit
def top2_per_row_kernel(A_ptr,
                        v1_ptr, i1_ptr, v2_ptr, i2_ptr,
                        M, N, stride_m, stride_n,
                        BLOCK_N: tl.constexpr):
    row = tl.program_id(0)
    if row >= M:
        return

    offs = tl.arange(0, BLOCK_N)
    ptrs = A_ptr + row * stride_m + offs * stride_n
    mask = offs < N
    vals = tl.load(ptrs, mask=mask, other=float("-inf"))

    # --- first pick ---
    v1 = tl.max(vals, axis=0)
    i1 = tl.argmax(vals, axis=0)

    # mask out the winning lane, then pick again
    vals2 = tl.where(offs == i1, float("-inf"), vals)
    v2 = tl.max(vals2, axis=0)
    i2 = tl.argmax(vals2, axis=0)

    tl.store(v1_ptr + row, v1)
    tl.store(i1_ptr + row, i1)
    tl.store(v2_ptr + row, v2)
    tl.store(i2_ptr + row, i2)

Generalizing to top-k is a loop: each iteration does one tl.max/tl.argmax over the whole row and one tl.where to evict the chosen lane. That is O(k·N) work per row.

THE SWITCH POINT

The repeated-masking approach scans the full row once per k. It only beats a library selection because everything stays in registers — fusion avoids the extra HBM traffic of writing scores out and reading them back into a separate selection kernel. That advantage is real for tiny k (top-1, top-2, top-5) fused onto a row you were already touching. For larger k, O(k·N) rescans lose to an O(N + k·log k) selection; defer to a real selection / sort (CUB / torch.topk).

3 · Segmented reductions in a CSR-like layout

Compressed Sparse Row (CSR) is the classic irregular structure. The matrix is three arrays: row_ptr (length M+1, the start offset of each row), col_idx (column index of each nonzero), and vals (the nonzeros themselves). Row i's nonzeros are vals[row_ptr[i] : row_ptr[i+1]]. A sparse-matrix × dense-vector product (SpMV) is a segmented reduction: one dot product per row over a variable-length segment.

@triton.jit
def csr_spmv_kernel(row_ptr, col_idx, vals, x_ptr, y_ptr,
                    M, BLOCK: tl.constexpr):
    row = tl.program_id(0)
    if row >= M:
        return

    start = tl.load(row_ptr + row)          # segment bounds for this row
    end   = tl.load(row_ptr + row + 1)

    # load the whole bounded segment at once (assumes nnz_per_row <= BLOCK)
    offs = tl.arange(0, BLOCK)
    j    = start + offs
    mask = j < end
    cols = tl.load(col_idx + j, mask=mask, other=0)
    a    = tl.load(vals    + j, mask=mask, other=0.0)
    xs   = tl.load(x_ptr + cols, mask=mask, other=0.0)  # gather x[col]

    acc = tl.sum(a * xs, axis=0)            # segmented reduction, local
    tl.store(y_ptr + row, acc)

The data structure became pointer arithmetic: start = row_ptr[i], j = start + arange(BLOCK), mask = j < end kills the lanes past the segment, and tl.load(x_ptr + cols) is a gather that translates column indices into dense addresses.

THE LIMITATION — STATE IT EXPLICITLY

One program per row is fine only when rows are short and roughly equal length. Two failures bite:

For unbounded or skewed row lengths, prefer a CUDA warp/block-specialized kernel (assign a warp to short rows, a whole block to long rows, balance by nonzero count) or just call cuSPARSE. Triton has no good primitive for runtime-dynamic per-row work distribution.

4 · Block-sparse attention metadata

Attention has a beautiful property: it is regular at the block level but sparse at the sequence level. For a query block, most key/value blocks contribute nothing (they're masked out by a sliding window, a causal mask, or a learned sparsity pattern). The irregularity is which K/V blocks matter — and that can be precomputed into metadata: for each query block, a list of the K/V block ids it must attend to.

Q block i ---> metadata[i] = [ kv_block 0, kv_block 3, kv_block 4 ] | | | program (q_block=i, slot=0) ------+ | | loads kv_block 0 program (q_block=i, slot=1) -----------------+ | loads kv_block 3 program (q_block=i, slot=2) ----------------------------+ loads kv_block 4 the program streams ONLY the listed blocks; the dense N x N score matrix is never formed. irregular sparsity (which blocks) is resolved by the lookup table; the per-block math stays regular.

So a 2-D grid (q_block, sparse_slot) covers exactly the work that survives the mask. Each program reads its target block id from the table and does a regular dense block matmul:

@triton.jit
def block_sparse_scores_kernel(Q_ptr, K_ptr, S_ptr,
                               kv_block_ids,        # metadata: [n_q_blocks, MAX_SLOTS]
                               n_slots_ptr,         # how many real slots per q_block
                               stride_qm, stride_qd,
                               stride_kn, stride_kd,
                               stride_sq, stride_ss, stride_sm, stride_sn,
                               D: tl.constexpr,
                               BLOCK_M: tl.constexpr,
                               BLOCK_N: tl.constexpr,
                               MAX_SLOTS: tl.constexpr):
    q_block = tl.program_id(0)
    slot    = tl.program_id(1)

    # skip slots beyond this query block's real fan-out
    if slot >= tl.load(n_slots_ptr + q_block):
        return

    # THE METADATA IS THE DATA STRUCTURE: one indirection picks the K/V block
    kv_block = tl.load(kv_block_ids + q_block * MAX_SLOTS + slot)

    offs_m = q_block * BLOCK_M + tl.arange(0, BLOCK_M)
    offs_n = kv_block * BLOCK_N + tl.arange(0, BLOCK_N)
    offs_d = tl.arange(0, D)

    # load Q block [BLOCK_M, D] and K block [BLOCK_N, D]
    q = tl.load(Q_ptr + offs_m[:, None] * stride_qm + offs_d[None, :] * stride_qd)
    k = tl.load(K_ptr + offs_n[:, None] * stride_kn + offs_d[None, :] * stride_kd)

    # regular dense block matmul: [BLOCK_M, D] x [D, BLOCK_N] -> [BLOCK_M, BLOCK_N]
    acc = tl.dot(q, tl.trans(k))            # tensor-core path

    offs_sm = tl.arange(0, BLOCK_M)
    offs_sn = tl.arange(0, BLOCK_N)
    s_ptrs = (S_ptr + q_block * stride_sq + slot * stride_ss
              + offs_sm[:, None] * stride_sm
              + offs_sn[None, :] * stride_sn)
    tl.store(s_ptrs, acc)
THE METADATA IS THE DATA STRUCTURE

The expensive irregular decision — "which blocks interact" — is made once, on the host or in a cheap setup kernel, and frozen into kv_block_ids. The hot kernel then does nothing but regular block matmuls. This is the general recipe for making sparsity Triton-friendly: precompute an index table so the inner loop is dense.

5 · Online (streaming) softmax — the FlashAttention recurrence

The block-sparse kernel above produced score blocks. But a full attention output needs softmax(QKᵀ)·V, and the whole point of FlashAttention is to never materialize the full score matrix. The insight: softmax can be computed incrementally as K/V blocks stream past, keeping only O(1) state per query row.

Per query row the running state is the running max m, the normalizer l, and the accumulator acc (a D-vector). When a new score block arrives:

# state carried across K/V blocks, per query row
#   m   : running max of scores seen so far   (init -inf)
#   l   : running sum of exp(score - m)        (init 0)
#   acc : running sum of p_i * v_i             (init 0, shape [D])

scores  = tl.dot(q, tl.trans(k))               # this block's [BLOCK_M, BLOCK_N] scores

m_new   = tl.maximum(m, tl.max(scores, axis=1))        # new running max
alpha   = tl.exp(m - m_new)                            # rescale factor for OLD state
p       = tl.exp(scores - m_new[:, None])              # new block, stable exponent

l_new   = l * alpha + tl.sum(p, axis=1)                # renormalize + add new mass
acc_new = acc * alpha[:, None] + tl.dot(p, v)          # rescale old acc + add new

m, l, acc = m_new, l_new, acc_new
# ... after the last block:  out = acc / l[:, None]
WHY THIS IS CORRECT

Softmax weights are exp(score - max). The catch is that max is a global quantity you only learn after seeing every block. Online softmax fixes this with retroactive correction: when a later block reveals a larger max, the old accumulator was computed against the old, too-small max, so every old term is off by a factor exp(m_old - m_new) = alpha. Multiplying acc and l by alpha rescales all previously accumulated terms to the new reference max in one shot. Because alpha <= 1 exactly when the max grew, the correction is always a safe down-scaling — no overflow, full numerical stability.

WHY IT MATTERS

This recurrence is why attention streams K/V blocks in O(1) memory per query row instead of O(N) for the score row. It turns attention from memory-bound (materialize, write, re-read the score matrix) into compute-bound (one fused pass). It is the single most important algorithm in modern Triton kernels, and a near-certain interview question — be able to derive alpha from scratch.

6 · Associative scan / cumsum inside a tile

Triton can do prefix operations within a tile. tl.cumsum(x, axis=0) gives a running sum along an axis, and tl.associative_scan(x, axis, combine_fn) generalizes it to any associative operator (running max, running product, segmented combinations). These are perfect for within-block running sums — e.g., computing offsets inside a compacted block, or a local prefix for a fused operation.

offs = tl.arange(0, BLOCK)
x    = tl.load(p + offs, mask=offs < N, other=0.0)
run  = tl.cumsum(x, axis=0)        # in-tile prefix sum, no cross-program coordination
IN-TILE ONLY

tl.cumsum / tl.associative_scan stop at the tile boundary. A global prefix sum across all programs still needs the classic multi-pass scheme: per-block local scans, write block sums, scan the block sums, add the offsets back. That is exactly the multi-pass global coordination Triton is bad at orchestrating — use CUB's device-wide scan. Triton's scan is for the leaf, not the tree.

7 · Seeded dropout with a Philox counter

Fused dropout needs per-element randomness that is (a) deterministic given a seed, so backward can regenerate the same mask without storing it, and (b) decorrelated across elements. Triton exposes tl.rand(seed, offset), a Philox-style counter-based generator: the random value is a pure function of the seed and the element's global offset — no per-element state, no RNG stream to thread through.

@triton.jit
def fused_dropout_kernel(x_ptr, out_ptr, N, p, seed,
                         BLOCK: tl.constexpr):
    pid  = tl.program_id(0)
    offs = pid * BLOCK + tl.arange(0, BLOCK)
    mask = offs < N

    x = tl.load(x_ptr + offs, mask=mask)
    r = tl.rand(seed, offs)                 # deterministic per-offset uniform in [0,1)
    keep = r > p                            # Bernoulli(1 - p)
    out  = tl.where(keep, x / (1.0 - p), 0.0)   # inverted dropout scaling

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

Because the mask is reproducible from (seed, offs), the backward pass recomputes it on the fly instead of reading a full mask tensor from HBM — another fusion win that removes a tensor-sized memory round-trip.

Where Triton is the wrong tool

TaskReach forOne-line reason
Plain huge GEMMcuBLASHand-tuned for every shape and tensor-core generation; you won't beat it on raw peak.
Sort / global scan / radixCUB / ThrustNeeds deterministic global ordering and multi-pass coordination across all blocks.
Highly irregular graph traversalCUDADynamic frontiers, work queues, and atomics — no regular tile to map programs onto.
Fused custom transformer opTritonRegular tiles + local reductions + an epilogue; fusion saves the HBM traffic a library chain would pay.
Prototyping a new model opTritonPython-level iteration speed with near-CUDA performance; no C++/build cycle.

Interview answer template

When asked "would you implement X in Triton?", walk this ladder out loud. It demonstrates exactly the judgment the question is probing:

  1. Describe the data structure. Is it dense strides, CSR (row_ptr/col_idx/vals), block metadata (an index table of block ids), or a mask? Name it precisely.
  2. Choose the program owner. What does one program_id own — a row, a block, a tile, a sparse slot? This decides the grid.
  3. Show the pointer tensors. Write base + id*stride + arange(BLOCK)*stride and the mask. If you can't express the region as pointer arithmetic, that's the first red flag.
  4. State the local primitive. Map / tl.sum / tl.max / tl.dot / online softmax update. This is the work that stays in registers.
  5. Name the cross-tile issue. Does it need a second pass, an atomic, metadata scheduling, or a library fallback? Being honest here is the whole point.
  6. Decide the tool. If steps 3–4 are clean and local, Triton. If step 5 forces global ordering, dynamic queues, or peak GEMM, hand it to CUDA / CUB / cuBLAS / PyTorch — and say why.
THE ONE SENTENCE

"Triton fits when one program can load a regular tile, reduce or transform it locally, and store with little cross-tile coordination; the moment the problem needs runtime-discovered global ordering or dynamic work distribution, I precompute it into metadata or hand it to CUDA / CUB / cuBLAS." Say that and you've answered the whole track.