ML Interview Notes
40 min read16 sections
Part 8 · Compilation and runtime · 08-03

Writing a Triton kernel; CUTLASS at a glance

Status
SOURCE PINNED
Primary sources
  • vllm/model_executor/layers/
  • csrc/cutlass_extensions/
  • python/sglang/kernels/
Edition pins
vllm a556f3f · sglang 7d89325

Fused normalization removes intermediate materialization, but a byte ratio is not automatically a speedup. This chapter compares CUDA and Triton reductions, derives explicit dtype-aware traffic models, and examines CUTLASS/CuTe without treating one framework's abstraction as a universal performance boundary.

§1

The problem

Take the two lines every transformer block runs twice per layer:

PSEUDOCODE - fp32-intermediate residual RMSNorm contract pseudocode
s = x.float() + residual.float()
v = s.square().mean(dim=-1, keepdim=True)
out = (s * torch.rsqrt(v + eps)).to(weight.dtype) * weight
residual_out = s.to(x.dtype)  # preserve the pre-norm residual

Kernel count and physical HBM traffic need a trace. For bf16 inputs/weights with fp32 intermediates, the logical materialization accounting follows the explicit reference in §8.2:

Logical reference bytes per d=4096 row, not measured HBM. Weight reads are included. These grouped rows are not a CUDA kernel count.
StepReadsWritesBytes
Two input castsbf16 x, residualtwo fp32 rows49,152
Residual addition and residual output castfp32 rowsfp32 sum, bf16 residual73,728
Square, mean, epsilon and rsqrtfp32 sum and intermediatesfp32 square and scalars49,172
Normalize, cast, weight multiplyfp32 sum, scalar, bf16 weightintermediates and output81,924
Logical reference total253,976
Fused payloadx, residual, weightresidual, output40,960
Match the operation

The Gemma kernel below computes normalization with 1 + weight and initially has no residual input; the later fused-add variant adds residual semantics. Those are distinct APIs. The logical byte ratio above is about 6.2, not a guaranteed latency ratio. Caches, precision placement and existing eager fusion determine actual savings.

At 2048 rows, 253,976 logical bytes per row correspond to about 155 microseconds at peak 3.35 TB/s, versus about 25 microseconds for the fused payload if each row reloads weight. A warm shared weight can reduce HBM traffic. These are conditional payload estimates: the final norm lacks residual semantics, and no measured multi-layer saving follows.

6.2×
logical reference / fused payload
0.625
FLOP/byte, arithmetic intensity
Scalar
use the reduction's instruction roofline

SGLang's answer in CUDA is python/sglang/kernels/jit/csrc/elementwise/rmsnorm.cuh — 371 lines holding four kernel variants, a warp-level path for $d \le 256$, a CTA path, a double-load path for pre-Blackwell, a wide path for Blackwell, hand-rolled two-level reductions, explicit __shared__ staging, and packed 16-byte vector types. It is excellent code. It is also exactly the kind of code you will not write for the eleventh fused operation you need this quarter. Triton exists for the other ten.

§2

Mental model: what Triton takes away

CUDA makes you name a thread. Everything else follows: you compute which elements that thread owns from blockIdx and threadIdx, you choose a vector width so the loads coalesce, you reduce inside a warp with shuffles, you stage the per-warp partials through __shared__, you place two __syncthreads() around that staging, and you get one of them wrong the first time.

Triton makes you name a block of elements. You write tl.arange(0, BLOCK_N) and operate on the whole vector; the compiler decides how many threads back it, how to vectorise the loads, which lanes carry which elements, and how to lower tl.sum into a shuffle tree plus shared-memory staging plus the barriers. You still choose the parallel decomposition — what one program owns, and how many programs there are — because that is a modelling decision no compiler can make for you. You stop choosing everything below it.

Here is the same reduction, both ways, from the two files this chapter reads.

Figure 1 — one RMSNorm row reduction: CUDA threads versus a Triton block. Left is rmsnorm_cta_double at d=4096 (256 threads, 8 warps, 16 elements each); right is _gemma_rmsnorm_kernel with BLOCK_N = 4096. Both compute the same sum of squares. The shaded band on the right is what the compiler generates.

CUDA thread-level code versus Triton block-level code for one RMSNorm row Two columns. The left column, labelled CUDA, shows a row of 4096 elements split across 256 threads at 16 elements each, then a warp shuffle reduction, then a shared-memory staging step with two syncthreads barriers, then a final warp reduction, all written by hand. The right column, labelled Triton, shows the same row as a single block-valued vector, a single tl.sum call, and a shaded band beneath indicating that the thread mapping, shuffle tree, shared memory staging and barriers are all generated by the compiler. CUDA — you name a thread Triton — you name a block x[row, 0:4096] in HBM 4096 bf16 elements threadIdx.x = 0 .. 255 · 16 elements each · two 16 B loads 1. per-thread partial: 8 fp32x2 unpacks, hand-unrolled warp::reduce_sum → 8 warp partials __shared__ float smem[32]; smem[warp_id] = partial __syncthreads() warp 0 reduces the 16 partials, writes rsqrt to smem __syncthreads() every thread reads norm_factor = smem[warp_id] all six boxes: written by you rmsnorm.cuh, 371 lines, 4 kernel variants x_ptr + row*stride_row + cols*stride_col cols = tl.arange(0, BLOCK_N) — one block value x = tl.load(..., mask=cols < n_cols, other=0.0).to(tl.float32) var = tl.sum(x * x, axis=0) / n_cols 1. the whole reduction, one line generated by the Triton compiler thread mapping for num_warps=16 load vectorisation and coalescing warp shuffle reduction tree shared-memory staging of partials both barriers you write 26 lines and pick num_warps minimax_m3_rmsnorm.py, 148 lines, 2 kernels

The CUDA column is real code. Here is its reduction, verbatim:

python/sglang/kernels/jit/csrc/elementwise/rmsnorm.cuh:L53-L103 SGLang
__global__ __launch_bounds__(kDim / 16) void rmsnorm_cta_double(const RMSNormParams __grid_constant__ params) {
  using namespace device;
  using Float2 = packed_t<Float>;
  using Storage = AlignedVector<Float2, 4>;

  constexpr auto kNumThreads = kDim / 16;
  constexpr auto kNumWarps = kNumThreads / kWarpThreads;

  const auto& [input, weight_ptr, output, input_stride, output_stride, num_tokens, eps] = params;
  const auto gmem = tile::Memory<Storage>::cta(kNumThreads);
  __shared__ float smem[32];
// ...
  float sum_of_squares = 0.0f;
#pragma unroll
  for (auto j = 0u; j < 4u; ++j) {
    const auto [x, y] = cast<fp32x2_t>(input_first[j]);
    sum_of_squares += x * x + y * y;
  }
#pragma unroll
  for (auto j = 0u; j < 4u; ++j) {
    const auto [x, y] = cast<fp32x2_t>(input_second[j]);
    sum_of_squares += x * x + y * y;
  }

  sum_of_squares = warp::reduce_sum(sum_of_squares);
  float norm_factor;
  if constexpr (kNumWarps == 1) {
    norm_factor = math::rsqrt(sum_of_squares / kDim + eps);
  } else {
    const auto warp_id = threadIdx.x / kWarpThreads;
    smem[warp_id] = sum_of_squares;
    __syncthreads();
    if (warp_id == 0) {
      const auto tx = threadIdx.x;
      const auto local_sum = tx < kNumWarps ? smem[tx] : 0.0f;
      sum_of_squares = warp::reduce_sum(local_sum);
      smem[tx] = math::rsqrt(sum_of_squares / kDim + eps);
    }
    __syncthreads();
    norm_factor = smem[warp_id];
  }

Every construct there was introduced in §0.3: warp shuffles, shared memory, barriers, packed vector loads, __launch_bounds__. Nothing is gratuitous. Note kDim / 16 — the thread count is a compile-time function of the hidden size, which is why this kernel is a template instantiated per shape, and why python/sglang/kernels/ops/layernorm/norm.py:L129-L144 raises "jit rmsnorm: unsupported hidden_size=..." for shapes nobody instantiated.

§3

Writing the kernel

Now the Triton version of the same operation. This is not illustrative code — it is in-tree, tested, and shipping:

python/sglang/kernels/ops/layernorm/minimax_m3_rmsnorm.py:L18-L43 SGLang
@triton.jit
def _gemma_rmsnorm_kernel(
    x_ptr,
    w_ptr,
    out_ptr,
    n_cols,
    stride_row,
    stride_col,
    eps,
    BLOCK_N: tl.constexpr,
):
    row = tl.program_id(0)
    cols = tl.arange(0, BLOCK_N)
    mask = cols < n_cols
    x = tl.load(x_ptr + row * stride_row + cols * stride_col, mask=mask, other=0.0).to(
        tl.float32
    )
    var = tl.sum(x * x, axis=0) / n_cols
    rstd = 1.0 / tl.sqrt(var + eps)
    w = tl.load(w_ptr + cols, mask=mask, other=0.0).to(tl.float32)
    out = x * rstd * (1.0 + w)
    tl.store(
        out_ptr + row * n_cols + cols,
        out.to(out_ptr.dtype.element_ty),
        mask=mask,
    )

Nine constructs, in order:

@triton.jit

Compiled kernel language

The JIT compiles supported Python-like syntax, not arbitrary Python objects. Triton provides static/device print and assert tools; interpreter mode supports debugging within its documented limits.

tl.program_id(0)

Which program am I

The index of this instance in the launch grid. Here the grid is one-dimensional over rows, so row is the token index. Roughly a thread block, but you never say how many threads.

tl.arange

A block value

tl.arange(0, BLOCK_N) is a vector of BLOCK_N integers living in registers across the whole program. Every arithmetic op on it is elementwise over all lanes at once.

mask + other

The bounds guard

cols < n_cols is a block of booleans. Masked lanes issue no memory transaction on load and no write on store, and take other=0.0 as their value.

tl.constexpr

Compiled per value

BLOCK_N is baked into the generated code. Two different BLOCK_N values are two different compiled kernels. That is what lets tl.arange have a static shape.

.to(tl.float32)

Accumulate wide

bf16 has 8 mantissa bits. Summing 4096 squares in bf16 loses the tail entirely. Upcast on load, downcast on store — the same discipline §0.5 derives.

tl.sum(axis=0)

The reduction

One call replaces the entire right half of Figure 1. The compiler picks the shuffle tree, the shared-memory staging and the barriers from num_warps and BLOCK_N.

strides as arguments

Not shapes

stride_row and stride_col are passed in, so the kernel reads non-contiguous views — the file's docstring says exactly why: per-head q/k norms over qkv.split views.

element_ty

Output dtype from the pointer

out_ptr.dtype.element_ty makes one kernel body work for fp16 and bf16 without a dtype flag. Triton specialises on the pointer type at compile time.

The launch

python/sglang/kernels/ops/layernorm/minimax_m3_rmsnorm.py:L88-L115 SGLang
def _num_warps(block_n: int) -> int:
    if block_n >= 4096:
        return 16
    if block_n >= 1024:
        return 8
    return 4


def gemma_rmsnorm(x: torch.Tensor, weight: torch.Tensor, eps: float) -> torch.Tensor:
    """Gemma RMSNorm = normalize(x) * (1 + weight), fp32 math, single pass."""
    orig_shape = x.shape
    n = orig_shape[-1]
    x2 = x.reshape(-1, n)
    m = x2.shape[0]
    out = torch.empty((m, n), dtype=x.dtype, device=x.device)
    block_n = triton.next_power_of_2(n)
    _gemma_rmsnorm_kernel[(m,)](
        x2,
        weight,
        out,
        n,
        x2.stride(0),
        x2.stride(1),
        eps,
        BLOCK_N=block_n,
        num_warps=_num_warps(block_n),
    )
    return out.reshape(orig_shape)

kernel[(m,)](args...) is the whole launch API. The subscript is the grid; the call is the arguments. num_warps is a reserved keyword Triton consumes rather than passing to your kernel — it is how you tell the compiler how many threads to spend on one program, and it is the one occupancy knob you keep from CUDA.

The masking idiom, with numbers

tl.arange requires a power-of-two length, so the launcher rounds the hidden size up with triton.next_power_of_2(n) and the kernel masks the excess. This is the thing everyone gets wrong first, in two ways: forgetting other=0.0 (masked lanes then hold garbage and poison the sum), and dividing by BLOCK_N instead of n_cols (the mean is then wrong by a constant factor that looks like a bad epsilon). Notice line 35 divides by n_cols, the true width.

Figure 2 — the grid, the block and the mask over a real tensor. Input [257, 6144] bf16 — the exact shape SGLang's test parametrises. The grid is 257 programs; each holds a 8192-lane block over a 6144-wide row, so 2048 lanes are masked.

Triton grid and mask structure over a 257 by 6144 tensor A tall tensor of 257 rows and 6144 columns. The launch grid has 257 programs, one per row. Program 0 is expanded to show a block of 8192 lanes: lanes 0 to 6143 are active and loaded, lanes 6144 to 8191 are masked and take the value zero. The masked quarter costs registers and reduction work but no HBM traffic. Below, 257 programs are shown mapping onto 132 streaming multiprocessors, giving roughly two waves. x2 = x.reshape(-1, 6144) → grid = (257,) row 0 row 1 row 2 ... row 256 257 rows × 6144 cols · bf16 program 0 cols = tl.arange(0, BLOCK_N) with BLOCK_N = next_power_of_2(6144) = 8192 lanes 0 .. 6143 — mask true, loaded 6144 .. 8191 other=0.0 12 KiB read per row var = tl.sum(x * x, axis=0) / n_cols — the tree spans all 8192 lanes reduction cost 8192/6144 = 1.33× · HBM cost 1.00× · divide by 6144, not 8192 257 programs dispatched over 132 SMs wave 1: programs 0..131 wave 2: programs 132..256 (125 SMs busy) num_warps = 16 → 512 threads per program → 32 KiB of fp32 block value in registers the decode failure mode batch 1 decode: m = 1 → grid = (1,) → ONE program → 1 of 132 SMs busy → 0.76% of the machine the bandwidth bound says 9.8 ns for 32 KiB; one SM cannot come near it, and the launch alone costs microseconds A grid-stride loop schedules multiple rows; num_tokens=1 still launches only one block.

The fused-add variant, _gemma_fused_add_rmsnorm_kernel at python/sglang/kernels/ops/layernorm/minimax_m3_rmsnorm.py:L46-L85, is the same kernel with a second input pointer, a second stride pair, and one extra store. That store is the whole point of fusing: it emits the pre-norm sum for the next block's residual add, so the sum is never re-read from HBM by a separate kernel.

§4

How to know if the kernel is good

You now have a kernel. Is it any good? The procedure is three steps and it does not require a profiler.

  1. Count bytes at the relevant memory level. Fused residual RMSNorm reads x, residual and weight and writes sum and output: 40,960 logical bytes at d=4096 bf16. If the shared weight is warm in L2, the input/output HBM component is 32,768 bytes per row. State which assumption applies rather than silently omitting weight.
  2. Count FLOPs. One add for the residual, one multiply and one add for the sum of squares, one multiply by rstd, one by the weight: $F \approx 5d = 20480$ FLOP per token.
  3. Divide, and place it on the roofline. $I = F/Q = 20480/32768 = 0.625$ FLOP/byte.

This norm uses scalar fp32 arithmetic and reductions, so peak bf16 tensor-core FLOPs is the wrong compute ceiling. Low arithmetic intensity motivates minimizing traffic, but occupancy, vectorization, reduction layout, launch latency and registers also matter. Fusion is one important optimization, not the only possible one.

$$t_{\min} = \frac{Q}{\beta} = \frac{32768\ \text{B}}{3.35\times10^{12}\ \text{B/s}} = 9.78\ \text{ns per token}$$

This conditional warm-weight payload bound scales to about 20 microseconds for 2048 rows. For a valid HBM accounting model, payload divided by measured time gives effective bandwidth; if most accesses hit L2, that ratio is not physical HBM bandwidth. Also inspect device counters, small-batch latency and end-to-end fraction before deciding optimization is complete.

No GPU here

This book derives bounds; it does not measure achievement. Every figure above is arithmetic from the H100 SXM spec sheet and the model shapes — none of it was run. A real kernel on real silicon has runtime at or above an applicable minimum-time bound, and bandwidth at or below its applicable ceiling. A faster apparent result signals different traffic/cache assumptions. the lab's job, with triton.testing.do_bench and Nsight Compute. Never quote a bound as a measurement.

The bound also tells you when to stop. If you measure 24 µs against a 20 µs bound you are at 83% of peak bandwidth and there is at most 17% left in the entire kernel; go find a different bottleneck. That is a far more useful answer than "it got 8% faster when I changed BLOCK_N".

§5

Autotuning: three strategies, all in these repos

BLOCK_N, num_warps and num_stages trade off against each other in ways that depend on the shape, the dtype and the architecture, and nobody can predict the winner. All three strategies for dealing with that are in these trees.

1. Online autotuning

python/sglang/kernels/ops/attention/dsa/triton_sparse_mla.py:L27-L41 SGLang
# The best (BLOCK_N, num_warps, num_stages) is shape- and arch-sensitive, so
# autotune over a grid keyed on the attention shape. Benchmarked once per key
# (a one-time stall on the first prefill of each new shape), then cached.
_AUTOTUNE_CONFIGS = [
    triton.Config({"BLOCK_N": bn}, num_warps=w, num_stages=ns)
    for bn in (32, 64, 128)
    for w in (1, 2, 4)
    for ns in (1, 2)
]


@triton.autotune(
    configs=_AUTOTUNE_CONFIGS,
    key=["topk", "H", "DIM"],
    prune_configs_by={"early_config_prune": _prune_configs},

Three things to take from this. The config list is a Cartesian product — $3 \times 3 \times 2 = 18$ configs — each of which must be separately compiled and separately timed the first time a new key appears. The key is the tuple of runtime arguments whose values change the right answer: ["topk", "H", "DIM"]. Change topk and Triton re-benchmarks; change the batch size, which is not in the key, and it reuses the cached choice. Picking the key is a modelling decision: too narrow and you tune for a shape that behaves differently, too wide and you stall on every new request shape. prune_configs_by lets you drop configs that cannot work — a config needing more shared memory than the SM's 228 KB, for instance — before they are compiled.

vLLM's Mamba kernels use the explicit form, nine hand-written configs:

vllm/model_executor/layers/mamba/ops/ssd_bmm.py:L14-L63, L56-L62 vLLM
@triton.autotune(
    configs=[
        triton.Config(
            {"BLOCK_SIZE_M": 128, "BLOCK_SIZE_N": 256, "BLOCK_SIZE_K": 64},
            num_stages=3,
            num_warps=8,
        ),
        triton.Config(
            {"BLOCK_SIZE_M": 64, "BLOCK_SIZE_N": 256, "BLOCK_SIZE_K": 32},
            num_stages=4,
            num_warps=4,
        ),
# ... seven more, down to BLOCK_SIZE_M=64, BLOCK_SIZE_N=64, num_stages=4, num_warps=2
    ],
    key=["chunk_size", "K", "IS_CAUSAL"],
)

The shape of that list is itself instructive: big M×N tiles get more warps and fewer pipeline stages, small tiles get two warps and five stages. num_stages is the depth of the software pipeline over the K-loop — how many BLOCK_K slabs are in flight in shared memory at once. More stages hides more latency and costs more shared memory, which is why the small-tile configs can afford five.

2. Offline tuning, checked into the repo

Autotuning at serving time is a latency spike on the critical path. vLLM's MoE kernel dodges it entirely: it ships 330 JSON files of pre-tuned configs under vllm/model_executor/layers/fused_moe/configs/, one per (expert count, intermediate size, device, dtype), each mapping a batch size to a config.

configs/E=8,N=14336,device_name=NVIDIA_H100_80GB_HBM3,dtype=fp8_w8a8.json — first 17 lines vLLM
{
    "1": {
        "BLOCK_SIZE_M": 64,
        "BLOCK_SIZE_N": 64,
        "BLOCK_SIZE_K": 128,
        "GROUP_SIZE_M": 64,
        "num_warps": 8,
        "num_stages": 5
    },
    "2": {
        "BLOCK_SIZE_M": 64,
        "BLOCK_SIZE_N": 128,
        "BLOCK_SIZE_K": 256,
        "GROUP_SIZE_M": 64,
        "num_warps": 4,
        "num_stages": 3
    },

Read the keys as batch sizes. At $M = 1$ the tuner chose a 64×64 tile with 5 stages; by $M = 2$ it wants 64×128×256 with 3. The tile that is right for one token is not the tile that is right for two, which is the entire reason this file exists. get_moe_configs (vllm/model_executor/layers/fused_moe/fused_moe.py:L1110-L1122) looks the file up by name, and when it misses:

vllm/model_executor/layers/fused_moe/fused_moe.py:L1165-L1171 vLLM
    # If no optimized configuration is available, we will use the default
    # configuration
    logger.warning_once(
        "Using default MoE config. Performance might be sub-optimal! "
        "Config file not found at %s",
        ", ".join(config_file_paths),
    )

If you see that line in a production log, you are running an untuned MoE. The mechanism is credited in-source to SGLang PR #2628 (vllm/model_executor/layers/fused_moe/fused_moe.py:L1109) — a rare, explicit cross-pollination between the two projects.

3. Cache the tuning, and shout when it happens late

vllm/env_override.py:L107-L113 vLLM
# Enable Triton autotuning result caching to disk by default.
# Without this, Triton re-runs autotuning on every process restart,
# adding significant latency to the first inference request.
# This writes autotuning results to TRITON_CACHE_DIR.
# It can still be overridden by setting TRITON_CACHE_AUTOTUNING=0
# in the environment.
os.environ.setdefault("TRITON_CACHE_AUTOTUNING", "1")

And after warmup, vLLM installs hooks so that any compile or autotune during serving is an incident: vllm/utils/jit_monitor.py:L119-L141 raises or logs "Triton kernel JIT compilation during inference: <name>. This causes a latency spike; consider extending warmup to cover this shape/config.". Run with --jit-monitor-mode=error to make it fatal. For benchmarking, the opposite knob exists: VLLM_TRITON_FORCE_FIRST_CONFIG=1 monkey-patches the autotuner to take the first valid config, "to eliminate autotuning variability when measuring kernel performance" (vllm/envs.py:L1177-L1184).

§6

Reading a production kernel: fused_moe_kernel

Everything above appears at scale in vLLM's Triton MoE GEMM, vllm/model_executor/layers/fused_moe/fused_moe.py:L298-L610. It is a grouped GEMM: each block of rows belongs to one expert, and the block must fetch that expert's weight matrix. Read it in five moves.

Move 1 — a one-dimensional grid, decoded into a 2-D tile index, deliberately out of order.

vllm/model_executor/layers/fused_moe/fused_moe.py:L385-L396 vLLM
    # -----------------------------------------------------------
    # Map program ids `pid` to the block of C it should compute.
    # This is done in a grouped ordering to promote L2 data reuse.
    pid = tl.program_id(axis=0)
    num_pid_m = tl.cdiv(EM, BLOCK_SIZE_M)
    num_pid_n = tl.cdiv(N, BLOCK_SIZE_N)
    num_pid_in_group = GROUP_SIZE_M * num_pid_n
    group_id = pid // num_pid_in_group
    first_pid_m = group_id * GROUP_SIZE_M
    group_size_m = min(num_pid_m - first_pid_m, GROUP_SIZE_M)
    pid_m = first_pid_m + ((pid % num_pid_in_group) % group_size_m)
    pid_n = (pid % num_pid_in_group) // group_size_m

The naive mapping pid_m = pid // num_pid_n sweeps a whole row of output tiles before moving down, so consecutive concurrent blocks share A rows but touch every column of B. The grouped ordering walks a GROUP_SIZE_M-tall column of tiles instead, so the blocks resident at any moment share both a small set of A rows and a small set of B columns, and both stay in L2. GROUP_SIZE_M is in the tuned JSON above — 64 at batch 1, 1 at batch 4. This is a pure Triton-level optimisation with no CUDA analogue in the source; it is scheduling, and Triton lets you express scheduling because you own the grid.

Move 2 — the gather, and the early exit. The row indices are not pid_m * BLOCK + arange; they are loaded from the alignment tensor that moe_align_block_size (vllm/model_executor/layers/fused_moe/moe_align_block_size.py:L11-L103) produced, so that each block's rows all belong to one expert:

vllm/model_executor/layers/fused_moe/fused_moe.py:L404-L421, L419-L421 vLLM
    offs = tl.arange(0, BLOCK_SIZE_M).to(tl.int64)
    num_tokens_post_padded = tl.load(num_tokens_post_padded_ptr)
    if pid_m * BLOCK_SIZE_M >= num_tokens_post_padded:
        return
    if not naive_block_assignment:
        offs_token_id = pid_m * BLOCK_SIZE_M + offs
        offs_token = tl.load(sorted_token_ids_ptr + offs_token_id)
# ...
    offs_token = offs_token.to(tl.int64)

    token_mask = offs_token < num_valid_tokens

Three idioms in ten lines. The grid is sized for the worst case and programs return early when the actual token count is smaller — a device-side bound that avoids relaunching with a different grid. offs_token is a block of indices read from memory, which is how you write a gather in Triton: use a block value as an offset. And token_mask — the padding tokens the aligner inserted carry an out-of-range index, so the same mask that guards the loads also silently drops them from the store at the end. Below, at L423-L440, a block whose expert is -1 (not on this rank) writes zeros and returns.

Move 3 — the K-loop with a 2-D mask.

vllm/model_executor/layers/fused_moe/fused_moe.py:L519-L537 vLLM
        accumulator = tl.zeros((BLOCK_SIZE_M, BLOCK_SIZE_N), dtype=tl.float32)
    for k in range(0, tl.cdiv(K, BLOCK_SIZE_K)):
        # Load the next block of A and B, generate a mask by checking the
        # K dimension.
# ...
        else:
            a = tl.load(
                a_ptrs,
                mask=token_mask[:, None] & (offs_k[None, :] < K - k * BLOCK_SIZE_K),
                other=0.0,
            )
            b = tl.load(b_ptrs, mask=offs_k[:, None] < K - k * BLOCK_SIZE_K, other=0.0)

token_mask[:, None] & (offs_k[None, :] < ...) is the RMSNorm mask idiom in two dimensions: [:, None] and [None, :] broadcast two 1-D block values into a [BLOCK_SIZE_M, BLOCK_SIZE_K] boolean. The row mask handles padded tokens, the column mask handles a K that is not a multiple of BLOCK_SIZE_K. The accumulator is fp32 regardless of input dtype — the same discipline as the .to(tl.float32) in the norm kernel, for the same reason.

Move 4 — the epilogue, all of it before a single store.

vllm/model_executor/layers/fused_moe/fused_moe.py:L577-L610 vLLM
    if use_int8_w8a16:
        accumulator = accumulator * b_scale
    elif (use_fp8_w8a8 or use_int8_w8a8) and not (group_k > 0 and group_n > 0):
        accumulator = accumulator * a_scale * b_scale
# ...
    if HAS_BIAS:
        accumulator += bias[None, :]

    # Router (MoE) weight multiplication:
    # This multiplication MUST be performed in float32 before any precision
    # conversion to ensure numerical stability, which is especially critical
    # on ROCm platforms.
    if MUL_ROUTED_WEIGHT:
        moe_weight = tl.load(
            topk_weights_ptr + offs_token,
            mask=token_mask,
            other=0,
        )
        accumulator *= moe_weight[:, None]

    # Final precision conversion:
    # Cast once at the end to the desired compute/output dtype.
    accumulator = accumulator.to(compute_type)

    # -----------------------------------------------------------
    # Write back the block of the output
    offs_cn = pid_n * BLOCK_SIZE_N + tl.arange(0, BLOCK_SIZE_N)
    c_ptrs = c_ptr + stride_cm * offs_token[:, None] + stride_cn * offs_cn[None, :]
    c_mask = token_mask[:, None] & (offs_cn[None, :] < N)
    tl.store(c_ptrs, accumulator, mask=c_mask)

Dequantisation, bias and the router weight are all applied to the fp32 accumulator in registers, and the tile touches HBM exactly once. This is epilogue fusion, hand-written. Hold on to it — it is the concept CUTLASS turns into a type system in the next section.

Move 5 — the launch. All the if use_fp8_w8a8 and if HAS_BIAS branches you just read are tl.constexpr, so each combination compiles to a separate kernel with the dead branches gone. The grid is a lambda over the chosen config, because the grid depends on the block sizes the tuner picked:

vllm/model_executor/layers/fused_moe/fused_moe.py:L836-L839 vLLM
    grid = lambda META: (
        triton.cdiv(EM, META["BLOCK_SIZE_M"])
        * triton.cdiv(B.size(1), META["BLOCK_SIZE_N"]),
    )

Everything in this kernel maps back to the twenty-six-line norm: program_id, arange, masked load/store, constexpr specialisation, an fp32 accumulator, and a grid you compute yourself. The only genuinely new primitive is tl.dot; the emitted MMA instruction family depends on dtype, shape, layout, hardware and compiler version. Inspect generated PTX/SASS rather than assuming WGMMA.

§7

Compiled or Triton: how each engine decides

§0.3 left this pair for here. SGLang's split-KV merge exists twice — a compiled op in the external sgl_kernel wheel and a Triton kernel in-tree — and the wrapper picks:

python/sglang/srt/layers/attention/merge_state.py:L12-L46, L34-L46 SGLang
# Automatically fallback to the Triton kernel in some cases
# (e.g., for AMD GPUs, when the head dimension is not a multiple
# of 4 or 8, and in FP8 precision)
def _supported_dtypes(o: torch.Tensor) -> bool:
    return o.dtype in [torch.float32, torch.half, torch.bfloat16]


def _supported_headdim(o: torch.Tensor) -> bool:
    headdim = o.shape[2]  # [NUM_TOKENS, NUM_HEADS, HEAD_SIZE]
    if o.dtype == torch.float32:
        return headdim % 4 == 0
    return headdim % 8 == 0
# ...
    if (
        _is_cuda
        and _supported_dtypes(prefix_output)
        and _supported_headdim(prefix_output)
    ):
        return merge_state_v2(
            prefix_output, prefix_lse, suffix_output, suffix_lse, output, output_lse
        )
    else:
        # Fallback to Triton kernel
        return merge_state_triton(
            prefix_output, prefix_lse, suffix_output, suffix_lse, output, output_lse
        )

The predicate selects the compiled CUDA path for eligible aligned inputs and Triton otherwise. Selection is not proof of universal speed superiority. Both have supported shape/dtype/backend limits; Triton does not automatically support every shape or platform. Benchmark the eligible alternatives while preserving the operation's numerical contract.

SGLang has generalised this into a registry. python/sglang/kernels/spec.py:L29-L51 enumerates provenance as an enum — TORCH, TORCH_COMPILE, TRITON, JIT, AOT, CUTE_DSL, FLASHINFER, DEEPGEMM, AITER, TORCH_NPU and more — and python/sglang/kernels/README.md:L39-L64 describes the contract: registration records metadata only, and no Triton compile or JIT build happens until a kernel is actually called. Every operator carries a required forward_native, "the pure-torch correctness reference every other implementation is checked against" (python/sglang/kernels/README.md:L75-L76). If you write a Triton kernel for either engine, that is the shape of the contract you are joining.

§8

CUTLASS at a glance

CUTLASS/CuTe expose detailed tile, pipeline and epilogue abstractions. These are not exclusive capabilities: Triton's official persistent-matmul tutorial includes persistent scheduling, TMA, warp specialization and fused epilogues on supported hardware. Compare the exact version, kernel, layout control and authoring complexity; no fixed "last 20%" performance advantage follows from choosing one language.

CUTLASS is a C++ template library, not a compiler. Its organising idea is a hierarchy of tiles, each level a template parameter you instantiate rather than a runtime argument you pass.

Figure 3 — the CUTLASS tile hierarchy, with vLLM's actual SM90 FP8 configuration. Shapes are read from scaled_mm_sm90_fp8_dispatch.cuh; the instruction level is Hopper's warpgroup MMA, selected by the collective builder rather than named in vLLM's source.

Loading…

The composition is explicit in vLLM's own code. A GEMM is a mainloop plus an epilogue, both built from the same tile shape:

csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm.cuh:L57-L84 vLLM
  using CollectiveEpilogue =
      typename cutlass::epilogue::collective::CollectiveBuilder<
          cutlass::arch::Sm90, cutlass::arch::OpClassTensorOp, TileShape,
          ClusterShape, cutlass::epilogue::collective::EpilogueTileAuto,
          ElementAcc, float, ElementC, StrideC, AlignmentCD, ElementD, StrideD,
          AlignmentCD, EpilogueSchedule, EVTCompute>::CollectiveOp;

  static constexpr size_t CEStorageSize =
      sizeof(typename CollectiveEpilogue::SharedStorage);
  using Stages = typename cutlass::gemm::collective::StageCountAutoCarveout<
      static_cast<int>(CEStorageSize)>;

  // clang-format off
  using CollectiveMainloop =
      typename cutlass::gemm::collective::CollectiveBuilder<
          cutlass::arch::Sm90, cutlass::arch::OpClassTensorOp,
          ElementAB, cutlass::layout::RowMajor, AlignmentAB,
          ElementAB, cutlass::layout::ColumnMajor, AlignmentAB,
          ElementAcc, TileShape, ClusterShape,
          Stages,
          KernelSchedule>::CollectiveOp;
  // clang-format on

  using KernelType = enable_sm90_or_later<cutlass::gemm::kernel::GemmUniversal<
      cute::Shape<int, int, int, int>, CollectiveMainloop, CollectiveEpilogue,
      cutlass::gemm::PersistentScheduler>>;

StageCountAutoCarveout<sizeof(CollectiveEpilogue::SharedStorage)> is the whole philosophy in one line: the pipeline depth of the mainloop is computed at compile time from how much of the SM's 228 KB of shared memory the epilogue already claimed. Triton's num_stages is a number you guess and then autotune; CUTLASS's is a number the type system solves for. That is the trade — less exploration, more compile time, and a much steeper authoring curve.

The tile shapes are selected by C++ dispatch on the problem geometry rather than by benchmarking. The SM90 FP8 path has seven configurations; at $M > 128$ it takes TileShape = Shape<_128, _128, _128> with a Ping-Pong schedule, at $M \ge 8192$ and $K \ge 6144$ it takes Shape<_256, _128, _128> Cooperative, and for $M \in (16, 64]$ with $N \le 1280$ it takes Shape<_64, _16, _256> with ClusterShape = Shape<_1, _4, _1> and operands swapped (csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm90_fp8_dispatch.cuh:L102-L110, L122-L130, L148-L166). A 16-wide N tile is a decode-shaped GEMM: almost no rows, so what matters is streaming B.

Epilogue fusion as a type

The MoE kernel fused its epilogue by writing the arithmetic before the store. CUTLASS makes the epilogue a compile-time expression tree — an epilogue visitor tree, Sm90EVT — so any composition of broadcasts, elementwise ops and casts is a type. §4.3 owns the scaled-GEMM epilogue and quotes the tree; what matters here is what fusion buys, stated by the vendored loader itself:

csrc/cutlass_extensions/epilogue/broadcast_load_epilogue_c3x.hpp:L33-L48 vLLM
//
// This file is a modified excerpt of
// include/cutlass/epilogue/fusion/sm90_visitor_load_tma_warpspecialized.hpp
// from https://github.com/NVIDIA/cutlass v3.5.0
// It has been modified to support either row/column or scalar broadcasting
// where the tensor being loaded from is always passed in via a device pointer.
// This lets one compiled kernel handle all cases of per-tensor or
// per-channel/per-token quantization.
//
// This interface also allows the scales to be passed in as tensors that
// consistently reside on the device, which avoids an issue with a previous
// implementation where scalars needed to be on the CPU since they
// were passed in via float values. This created a potential performance hazard
// if scales were initially on the device, and caused torch.compile graphs
// breaks when moving scales to the CPU.
//

One compiled kernel covering per-tensor, per-channel and per-token scaling — and note the last clause: the naive alternative moved a scalar to the CPU and broke the §8.2 graph. Kernel design and compiler behaviour are the same problem.

CuTe: layouts as algebra

Underneath sits CuTe, whose one idea is that a tensor's shape and stride form a Layout object you can compose, permute and coalesce at compile time. vLLM's helpers are a clean miniature:

csrc/cutlass_extensions/cute_utils.cuh:L13-L56, L51-L61 vLLM
template <size_t... I, typename Layout>
CUTE_HOST_DEVICE static constexpr auto permute_layout(Layout l) {
  static_assert(rank(l) == sizeof...(I), "Invalid permutation, rank mismatch");
  return cute::make_layout(cute::get<I>(l)...);
}

// is the layout f(x) = x
template <typename Layout>
CUTE_HOST_DEVICE static constexpr bool is_identity_layout() {
  if constexpr (std::is_same_v<Layout, void>) {
    return true;
  } else {
    constexpr auto coalesced_layout = coalesce(Layout{});
    if constexpr (rank(coalesced_layout) == 1 &&
                  stride<0>(coalesced_layout) == 1) {
      return true;
    }
    return false;
  }
}
// ...
template <typename T, typename Elements>
CUTE_HOST_DEVICE static constexpr auto create_auto_vectorizing_copy() {
  constexpr auto bits = sizeof_bits_v<T> * Elements{};
  if constexpr (bits % 128 == 0) {
    return AutoVectorizingCopyWithAssumedAlignment<128>{};
  } else if constexpr (bits % 64 == 0) {

if constexpr eliminates branches inside each compiled specialization. Runtime dispatch can still choose among C++ template instantiations, as the earlier wrapper does. Triton JIT also specializes compile-time values. Compare build time, binary size, cache misses and supported specializations, not "templates have no runtime dispatch".

Unverified

CUTLASS itself is not in either tree at these SHAs. vLLM fetches it at build time — set(CUTLASS_REVISION "v4.4.2") and a FetchContent_Declare from github.com/nvidia/cutlass at CMakeLists.txt:L485-L513 — so CollectiveBuilder, Sm90EVT, the warp-specialised mainloop and the exact wgmma atom the builder selects cannot be read here. Everything above is read from vLLM's own extensions and instantiations; the builder internals should be checked against CUTLASS v4.4.2 before you rely on them. SGLang's compiled kernels are similarly out of tree (the sgl_kernel wheel), which is why this chapter reads SGLang's JIT CUDA under python/sglang/kernels/jit/csrc/ instead.

Machete is the worked example of all of this: a mixed-precision W4A16 GEMM built on CUTLASS, "a spiritual successor to the Marlin kernel but optimized for Hopper architectures" (csrc/libtorch_stable/quantization/machete/Readme.md:L1-L3), whose type pairs and tile schedules are code-generated by generate.py so that "we can generate multiple type pairs and different tile shapes using the same kernel template" (csrc/libtorch_stable/quantization/machete/Readme.md:L41-L43). Its static shape-to-schedule heuristic and its prepacked weight layout are §4.3's to explain, and it explains them there.

§9

Which file do I open

Where each technology lives, at the pinned SHAs. File counts are measured by grep over the checkouts, not benchmarks.
You wantOpenFiles
A Triton kernel to copy, vLLMvllm/v1/attention/ops/ (attention variants), vllm/model_executor/layers/fused_moe/, vllm/model_executor/layers/mamba/ops/196 total with @triton.jit
A Triton kernel to copy, SGLangpython/sglang/kernels/ops/ — 77 attention, 21 moe, 7 quantization, 3 layernorm, 3 sampling203 under kernels/ops/, 239 across python/
The simplest complete Triton kernelpython/sglang/kernels/ops/layernorm/minimax_m3_rmsnorm.py148 lines
A production Triton GEMMvllm/model_executor/layers/fused_moe/fused_moe.py:L298-L6101
CUTLASS FP8 / INT8 GEMMscsrc/libtorch_stable/quantization/w8a8/cutlass/c3x/19
CUTLASS mixed-precision (Machete)csrc/libtorch_stable/quantization/machete/9 sources + generate.py + Readme
vLLM's own CUTLASS extensionscsrc/cutlass_extensions/, csrc/libtorch_stable/cutlass_extensions/6 + 8
Readable CUDA in SGLangpython/sglang/kernels/jit/csrc/ — 25 subtrees25
Triton you did not writewhatever Inductor emits — §8.2
§10

Worked trace: from decorator to cubin

Follow one call to gemma_rmsnorm(x, weight, eps) with x of shape [257, 6144], bf16.

  1. gemma_rmsnorm reshapes to [257, 6144], allocates out, computes block_n = triton.next_power_of_2(6144) = 8192 and num_warps = _num_warps(8192) = 16.
  2. The subscript _gemma_rmsnorm_kernel[(257,)] binds the grid and returns a launcher.
  3. Specialisation. Triton builds a key from everything that changes the generated code: the tl.constexpr values (BLOCK_N=8192), the pointer dtypes (bf16 in, bf16 out, fp32 weight), num_warps and num_stages, and divisibility properties of the integer arguments — a stride divisible by 16 licenses wider vector loads than one that is not. Calls with different runtime n_cols may share a specialization when padded BLOCK_N and other key properties match. Verify actual JIT keys; hidden size is not always a constexpr.
  4. Cache lookup in TRITON_CACHE_DIR. vLLM redirects this into VLLM_CACHE_ROOT when compile caching is on and otherwise Triton uses ~/.triton/cache (docs/usage/security.md:L376); SGLang sets it under its own cache root at python/sglang/srt/environ.py:L1658. A hit skips everything below.
  5. Compilation on a miss: Python AST → Triton IR → TritonGPU IR (where the layout assignment that decides which lane holds which element happens) → LLVM IR → PTX → cubin via ptxas. All stages land in the cache directory next to the cubin.
  6. Launch of 257 blocks × 512 threads. On a warm cache the Python-side cost is a dict lookup and a driver call; that residual cost is what §8.1 removes.

To see the output of step 5, the reliable route is to read the cache directory. Each compiled kernel gets a hashed subdirectory holding the IR at every stage:

shell — inspect a compiled Triton kernel shell
# run your kernel once with a private, empty cache
export TRITON_CACHE_DIR=/tmp/tritoncache
rm -rf $TRITON_CACHE_DIR
python -c "import torch, sglang.kernels.ops.layernorm.minimax_m3_rmsnorm as m; \
  x=torch.randn(257,6144,device='cuda',dtype=torch.bfloat16); \
  w=torch.randn(6144,device='cuda',dtype=torch.float32); m.gemma_rmsnorm(x,w,1e-6)"

# every stage of the pipeline is on disk, one directory per specialisation
find $TRITON_CACHE_DIR -type f | sed 's|.*/||' | sort | uniq -c
#   .ttir   Triton IR            .ttgir  TritonGPU IR (layouts assigned)
#   .llir   LLVM IR              .ptx    PTX          .cubin  the binary
#   .json   metadata: num_warps, shared bytes, register count

# SASS, the actual machine code, needs the CUDA toolkit
nvdisasm -c $TRITON_CACHE_DIR/*/*.cubin | head -60

The .json is the file to read first: it carries the shared-memory bytes and the register count per thread, which is what decides how many of your blocks fit concurrently on an SM. The Python object also exposes the same artefacts — a compiled kernel handle carries an asm mapping with "ttir", "ttgir", "llir" and "ptx" keys — but the exact attribute names move between Triton releases, so the cache layouts and handle attributes are both version-dependent inspection aids.

§11

Pitfalls and war stories

the classic

Dividing by BLOCK_N

tl.sum(x*x, axis=0) / BLOCK_N instead of / n_cols. At $d = 4096$ they are equal and every test passes; at $d = 6144$ the variance is 25% low and the model degrades subtly. The masking bug that survives your unit test is the one that hurts.

undefined lanes

Forgetting other=

Masked loads without an explicit value leave inactive lanes undefined. A reduction can then produce wrong finite values or NaN. Use zero for a sum-of-squares reduction and test non-power-of-two widths.

int32 overflow

Strides times offsets

vLLM casts explicitly: "Cast to int64 to prevent overflow in stride*offset products (e.g. stride_cm * offs_token can exceed int32 for large token counts)"fused_moe.py:L417-L419. Symptom is a wild-pointer write at large batch that vanishes when you shrink the test.

compiler bugs

They are real

"TD gather/load feeding tl.dot with a non-block-aligned K miscompiles (~74% of output elements wrong) on real HW; this is a compiler-codegen issue, not a Python-maskable boundary gap"fused_moe.py:L847-L859. vLLM guards it and logs a fallback. Always diff against a torch reference on the shape you actually run.

latency spikes

Compiling during serving

"Triton kernel JIT compilation during inference: ... This causes a latency spike; consider extending warmup to cover this shape/config."vllm/utils/jit_monitor.py:L119-L141. A new sequence length reaching an unspecialised kernel stalls a live request.

benchmark hygiene

L2 lies to you

SGLang's norm benchmark sets NUM_LAYERS = 4 # avoid L2 effect (test/registered/kernels/benchmark/layernorm/bench_norm.py:L34) and cycles distinct tensors. Time one 32 KiB tensor in a loop and you will measure L2, not HBM, and beat your own bandwidth bound.

A one-program-per-row grid exposes only one block at m=1. The quoted CUDA grid-stride loop also uses min(num_tokens, max_occupancy*kNumSM), which is one block at num_tokens=1. It caps/reschedules many rows, not splits one row across SMs. More splitting can add synchronization overhead, so choose from measured end-to-end impact.

Match Gemma normalization before testing a GPU kernel

Use a contiguous and a noncontiguous input of non-power-of-two width, check the exact 1+weight Gemma convention, and keep reduction epsilon explicit. A GPU test should then add empty and wide inputs, supported aliases/strides, extreme finite values, NaN/Inf policy and dtype-specific error bounds. This CPU oracle does not test CUDA/Triton code generation or performance.

Independent CPU reference; not an engine or GPU benchmark
import numpy as np

rng = np.random.default_rng(4)
base = rng.normal(size=(3, 14))
x = base[:, ::2]  # noncontiguous width 7
w = rng.normal(size=7)
eps = 1e-6
assert not x.flags.c_contiguous
def gemma_norm(value, weight):
    return value / np.sqrt(np.mean(value**2, axis=-1, keepdims=True) + eps) * (1+weight)
strided = gemma_norm(x, w)
contiguous = gemma_norm(np.ascontiguousarray(x), w)
np.testing.assert_allclose(strided, contiguous)
padded = np.pad(x, ((0, 0), (0, 1)))
right_variance = np.sum(padded**2, axis=-1, keepdims=True) / x.shape[-1]
np.testing.assert_allclose(right_variance, np.mean(x**2, axis=-1, keepdims=True))
wrong_variance = np.mean(padded**2, axis=-1, keepdims=True)
assert not np.allclose(right_variance, wrong_variance)
print("Strided Gemma reference and true-width reduction contract pass.")
§12

Hands-on

1. Check correctness against a torch reference before anything else. SGLang's test for the kernel this chapter read is the template:

test/registered/kernels/ops/layernorm/test_minimax_m3_rmsnorm.py:L29-L50 SGLang
def _gemma_rmsnorm_ref(x: torch.Tensor, weight: torch.Tensor) -> torch.Tensor:
    orig_dtype = x.dtype
    x_f = x.float()
    variance = x_f.pow(2).mean(dim=-1, keepdim=True)
    out = x_f * torch.rsqrt(variance + EPS)
    out = out * (1.0 + weight.float())
    return out.to(orig_dtype)


@pytest.mark.parametrize("shape", [(1, 512), (64, 6144), (257, 6144)])
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
@torch.inference_mode()
def test_gemma_rmsnorm_matches_reference(shape, dtype):
    torch.manual_seed(0)
    x = torch.randn(*shape, device=DEVICE, dtype=dtype)
    weight = torch.randn(shape[-1], device=DEVICE, dtype=torch.float32)

    got = gemma_rmsnorm(x, weight, EPS)
    ref = _gemma_rmsnorm_ref(x, weight)

    torch.testing.assert_close(got, ref, atol=2e-2, rtol=2e-2)

Read the parametrisation as a checklist: (1, 512) is the degenerate one-program grid, (64, 6144) exercises the mask, (257, 6144) makes the grid non-uniform across SMs, and a separate test at L52-L65 feeds base[:, ::2] to prove the stride arguments are real. Copy this shape set for anything you write.

2. Time it against the bound.

shell — run the norm kernel benchmark, then compute achieved bandwidth shell
python ~/sglang/test/registered/kernels/benchmark/layernorm/bench_norm.py
# Account for exact residual/Gemma semantics, dtypes, weight reads and cache state.
# Measure cold/warm data and model-level impact, not only an effective GB/s number.

# Separate checkout: run vLLM's own benchmark by absolute path.
python ~/vllm/benchmarks/kernels/benchmark_moe.py --help

Note that run_benchmark in python/sglang/kernels/jit/benchmark/utils.py:L81-L98 uses triton.testing.do_bench_cudagraph, not do_bench — it captures the launch into a graph so the measurement is kernel time, not launch time. Which of the two you want depends on the question you are asking; §8.1 explains the difference.

3. Separate tuning from execution. Use private TRITON_CACHE_DIR roots and compare cold compile/tune time, warm selected-kernel time and end-to-end startup independently. Forcing the first config changes both tuning and the chosen kernel; their latency difference is not just the tuning bill. Reset mutable outputs between autotune trials and validate every candidate before comparing performance.

§13

Exercises

  1. Read and answer. Open python/sglang/kernels/ops/layernorm/minimax_m3_rmsnorm.py. The store in _gemma_rmsnorm_kernel uses out_ptr + row * n_cols + cols while the load uses x_ptr + row * stride_row + cols * stride_col. Why is the asymmetry correct, and what would break if a caller passed a non-contiguous out?
    Answer

    The output is always freshly allocated by the launcher — torch.empty((m, n), ...) at L102 — so it is contiguous by construction and its row stride is exactly n_cols. The input may be a strided view (the docstring names qkv.split views and the test feeds base[:, ::2]), so it needs real stride arguments. A caller who passed a non-contiguous out would have their data scattered to the wrong addresses with no error; the kernel has no way to detect it. The fused-add variant has the same asymmetry on res_out_ptr.

  2. Derive. Compute the arithmetic intensity and bandwidth-bound time for a fused SiLU-and-multiply over Llama-3-8B's MLP intermediate ($d_{\text{ff}} = 14336$, bf16) for a 2048-token chunk. Then say whether autotuning BLOCK_SIZE could plausibly give you 2×.
    Answer

    The bf16 payload is 3*2048*14336*2 = 176,160,768 bytes, giving 52.6 microseconds at peak HBM under a cold-streaming assumption. Exponential/division cost needs the appropriate instruction throughput, not a tensor-core FLOP count. A factor-two improvement requires current time at least twice a valid lower bound, or changing the bytes/work. Fusion into neighboring GEMMs can be expressed in multiple frameworks, including Triton; validate its actual reuse and synchronization cost.

  3. Predict, then verify. _num_warps returns 16 for BLOCK_N >= 4096. Predict what happens to correctness and to performance if you force num_warps=1 for BLOCK_N=8192. Then check your prediction against the Triton IR by dumping .ttgir for both.
    Answer

    Changing num_warps should preserve mathematical semantics for supported compilations, though reduction order may change low bits. One warp increases per-thread work, but liveness, rematerialization and layout determine registers and spilling. It may be slower, spill or fail resource limits; no exact 10x penalty follows. Inspect compiled metadata/generated code, then measure and test tolerances.

  4. Read and answer. In fused_moe_kernel, GROUP_SIZE_M is 64 in the tuned config for batch 1 and 1 for batch 4 (configs/E=8,N=14336,device_name=NVIDIA_H100_80GB_HBM3,dtype=fp8_w8a8.json). Explain the reversal.
    Answer

    GROUP_SIZE_M changes tile visitation and cache reuse. The two config values show tuner choices, not why they won. A/B reuse, scheduling and noise are hypotheses. Hold all other parameters fixed and repeat a group-size sweep with warmed kernels and separate cold/warm data to establish causality.

  5. Design. You need a fused kernel that reads the KV cache, dequantises FP8 to bf16, and writes a contiguous bf16 buffer for an attention backend that cannot read FP8. Triton or CUTLASS? Justify with an intensity calculation.
    Answer

    Triton. The operation reads $N$ bytes of FP8 and writes $2N$ bytes of bf16 with about one multiply per element, so $I \approx 1/3$ FLOP/byte — three orders of magnitude below the ridge, with no matrix multiply anywhere in it. There is nothing for a tensor core to do, so CUTLASS's entire value proposition (mainloop pipelining feeding an MMA atom) is inapplicable, while its costs (compile time, authoring difficulty) are not. The addressing is paged and irregular, which is exactly what Triton's block-of-indices gather handles well — see the offs_token = tl.load(sorted_token_ids_ptr + ...) idiom. Reach for CUTLASS when there is a dense GEMM in the middle and the last 20% of its FLOP/s matters.

§14

Key takeaways

  • Triton moves the unit of work from a thread to a block of elements. You keep the two decisions that need domain knowledge — what one program owns, and how many programs there are — and give up the six that need hardware bookkeeping: thread mapping, vectorisation, shuffle trees, shared-memory staging, and both barriers.
  • The mask is the API for shapes that are not powers of two, and it costs registers and reduction width, not bandwidth. At $d = 6144$ with BLOCK_N = 8192 you pay 1.33× the in-register reduction and 1.00× the HBM traffic. Divide by the real width, and always pass other=0.0 when the block feeds a reduction.
  • Use a valid memory-level and instruction-level model. Include dtypes and weight reads, distinguish logical bytes from HBM/L2 traffic, and compare scalar reductions against scalar resources. Runtime lies above a valid minimum-time bound; tuning can improve occupancy, launches and layouts as well as fusion.
  • The three autotuning strategies are all live in these repos and they solve different problems. @triton.autotune with a cache key adapts online and stalls on new keys; vLLM's 330 checked-in JSON configs adapt offline and never stall; TRITON_CACHE_AUTOTUNING plus the JIT monitor make the remaining stalls visible instead of mysterious.
  • Reach for CUTLASS when there is a dense GEMM in the middle, the last 20% of its FLOP/s matters, and the epilogue must fuse. Its tile hierarchy and expression-tree epilogues are compile-time types, which buys full specialisation with no runtime dispatch and costs compile time, binary size, and a much harder authoring experience. For anything memory-bound or irregularly addressed, the answer is Triton.
  • Both engines treat compiled CUDA and Triton as interchangeable backends behind one signature, with a pure-torch reference as the correctness oracle. SGLang makes this explicit in KernelBackend and forward_native; vLLM does it per call site. Writing a kernel means joining that contract, not just producing a fast function.
§15

Further reading

  • Triton tutorials — vector add, fused softmax, matrix multiplication, layer norm. The fused-softmax and layer-norm lessons are the same shape as the kernel in §3 and take under an hour.
  • Tillet, Kung and Cox, "Triton: An Intermediate Language and Compiler for Tiled Neural Network Computations" (MAPL 2019) — the original paper. The block abstraction and the layout-assignment pass are the durable parts; the surface syntax has moved on.
  • NVIDIA/CUTLASS, pinned to v4.4.2 by CMakeLists.txt:L486. Start with the CuTe quickstart under media for layouts, then the repository's own examples for the collective-builder pattern you saw in scaled_mm.cuh.
  • SGLang RFC #29630 and its finale PR #32072 — the reorganisation that produced python/sglang/kernels/, the KernelBackend enum, and the JIT/AOT split this chapter reads.
  • SGLang PR #2628 — the tuned MoE config mechanism, credited in vLLM's own source at vllm/model_executor/layers/fused_moe/fused_moe.py:L1109. Worth reading for the methodology of producing the JSON files.
  • Neighbours: §0.3 for every CUDA construct in Figure 1, §0.4 for the roofline this chapter places kernels on, §3.3 for a Triton attention kernel read line by line, §4.3 for Marlin, Machete and the scaled-GEMM epilogue, and §8.2 for the Triton you never see because Inductor wrote it.

The Inference Engineering Course · code read at vllm@a556f3fccb and sglang@7d893255c3, pinned 2026-08-21. See SOURCES for the full provenance record.

Explore the library

Reading preferences

Appearance
18 px