↩ Contents Illustrated GPU Kernels for ML · Part 4
Part 4 of 9 · Kernels

First kernels: elementwise, reductions, softmax

The ops at the bottom of the roofline. They are simple enough to write in an afternoon and subtle enough that most implementations leave half the bandwidth on the floor. This is also where the online-softmax recurrence appears — the single idea that FlashAttention is built on.

Follows Parts 1–3.  ·  CUDA and Triton side by side throughout. For the large-tensor cases analysed here, the first target is bytes ÷ bandwidth.

Everything in this part has an arithmetic intensity between 0.2 and 3. For a large enough tensor, Part 3 therefore gives the first target: compulsory bytes divided by achievable bandwidth. Reaching it still requires coalesced traffic, enough work in flight, and little unnecessary movement. A naive elementwise kernel gets maybe 60% of peak; a naive reduction gets 5%.

4.1 The simplest kernel, and why it is not trivial

Start with a copy. Its job is to read n bytes and write n bytes, so on an H100 the floor for a 1 GB tensor is 2×109 / 3.35×1012 = 597 µs. Whether you get near it depends entirely on how much traffic you keep in flight, which is Little's Law from Part 1:

bytes in flight = bandwidth × latency   ⇒   H100: 3.35 TB/s × 273 ns = 915 kB, or 6.9 kB per SM

Two knobs get you there: how many warps are resident, and how many independent loads each thread has outstanding. They multiply.

interactive A bandwidth calculator for a streaming kernel. Move the sliders and watch which combinations reach the ceiling. The important observation: 32 warps × one float4 in flight beats 64 warps × one float, using half the occupancy — and on a B200 the float version cannot reach the ceiling at any occupancy.

In code, the two knobs are the vector width and the unroll factor:

// A grid-stride copy with 16-byte accesses and 4 loads in flight per thread.
__global__ void scale4(int n4, float a, const float4* __restrict__ x, float4* __restrict__ y)
{
    int stride = blockDim.x * gridDim.x;
    for (int i = blockIdx.x*blockDim.x + threadIdx.x; i < n4; i += stride*4) {
        float4 a0, a1, a2, a3;                // four INDEPENDENT loads —
        a0 = x[i];                            // the compiler issues all four
        a1 = (i+stride   < n4) ? x[i+stride  ] : a0;   // before waiting on any
        a2 = (i+stride*2 < n4) ? x[i+stride*2] : a0;
        a3 = (i+stride*3 < n4) ? x[i+stride*3] : a0;
        a0.x*=a; a0.y*=a; a0.z*=a; a0.w*=a;   // scale every loaded vector
        a1.x*=a; a1.y*=a; a1.z*=a; a1.w*=a;
        a2.x*=a; a2.y*=a; a2.z*=a; a2.w*=a;
        a3.x*=a; a3.y*=a; a3.z*=a; a3.w*=a;
        y[i] = a0;
        if (i+stride   < n4) y[i+stride  ] = a1;
        if (i+stride*2 < n4) y[i+stride*2] = a2;
        if (i+stride*3 < n4) y[i+stride*3] = a3;
    }
}
# Triton gets the same effect from a large BLOCK_SIZE: the block is
# lowered to vectorized loads and the loads are issued together.
@triton.jit
def scale(x_ptr, y_ptr, a, n, 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)
    tl.store(y_ptr + offs, a * x, mask=mask)

# BLOCK=4096 with num_warps=8 gives each thread 16 elements in flight.
# This is what triton.autotune searches over — see Part 9.
y = a * x     # dispatches to a hand-tuned elementwise kernel
              # torch.compile will fuse this with its neighbours
The explicit CUDA version shows the independent accesses directly. Triton makes the program tile compact to express, but its indexing still determines coalescing and the generated vector width still needs measurement.
Rule of thumb

For a large, aligned pure-streaming kernel, a useful starting point is: try accesses up to 16 bytes per lane, keep several independent accesses in flight per thread, and launch enough blocks for multiple waves. Adjacent scalar accesses can already coalesce perfectly, and the best vector width, unroll and occupancy still depend on the architecture and workload, so benchmark the combinations rather than treating one as universal.

4.2 Reductions: the classic ladder

A reduction turns n values into one. It is the shape underneath sum, max, norm, softmax, and every loss function, and it is the standard worked example of GPU optimization because the naive version is so bad.

The difficulty is that a reduction is inherently sequential in its dependency structure, so you have to build a tree. How you index that tree determines whether your warps diverge and whether your shared-memory accesses conflict — both of which you now know how to reason about.

interactive Three ways to reduce 256 values, one block of 8 warps. Step through all eight tree levels and watch which warps still have to issue. Interleaved addressing scatters useful lanes across more warps; sequential addressing retires whole warps sooner; the shuffle version takes five register-only steps per warp, then one small shared-memory handoff and a final warp reduction.

The modern answer is the third one. Since Kepler, a warp can exchange registers directly:

// Sum a contiguous set of active lanes starting at lane 0.
// A full warp takes 5 instructions; the result lands in its first lane.
__inline__ __device__ float warpReduceSum(float v, unsigned mask) {
    int lane = threadIdx.x & 31;
    int count = __popc(mask);
    int off = 1;
    while (off < count) off <<= 1;
    for (off >>= 1; off > 0; off >>= 1) {
        float other = __shfl_down_sync(mask, v, off);
        if (lane + off < count) v += other;
    }
    return v;
}

// Two-level: reduce within each warp, stage up to 32 partials in SMEM,
// then have warp 0 reduce those. Works for any block size up to 1024.
__inline__ __device__ float blockReduceSum(float v) {
    __shared__ float s[32];
    int lane = threadIdx.x & 31, wid = threadIdx.x >> 5;
    int lanesThisWarp = min(32, blockDim.x - wid*32);
    unsigned warpMask = lanesThisWarp == 32
        ? 0xffffffffu : ((1u << lanesThisWarp) - 1u);
    v = warpReduceSum(v, warpMask);
    if (lane == 0) s[wid] = v;
    __syncthreads();
    int numWarps = (blockDim.x + 31) >> 5;
    float blockSum = 0.0f;
    if (wid == 0 && lane < numWarps) {
        blockSum = s[lane];
        unsigned finalMask = numWarps == 32
            ? 0xffffffffu : ((1u << numWarps) - 1u);
        blockSum = warpReduceSum(blockSum, finalMask);
    }
    return blockSum;                  // valid in thread 0
}
# In Triton the reduction is a single call; the compiler emits the
# shuffle tree, the shared-memory staging, and the barriers for you.
total   = tl.sum(x, axis=0)
maximum = tl.max(x, axis=0)

# Associative custom reductions are available too:
@triton.jit
def _combine(m0, d0, m1, d1):
    m = tl.maximum(m0, m1)
    return m, d0 * tl.exp(m0 - m) + d1 * tl.exp(m1 - m)
The Triton pane's _combine is the online-softmax operator from §4.3 — it is associative in exact arithmetic, which is why it maps onto this tree.
The two-level block reduction. Stage one is five shuffle instructions for each full warp, with no shared memory and no barrier. Stage two moves up to 32 floats — one per warp — through shared memory and reduces them in a single warp. At the 1024-thread maximum: one barrier, 128 bytes of SMEM traffic, and ten shuffle levels.

The step count is worth stating exactly, because it is the whole complexity story. Within a full warp: log232 = 5 shuffle steps. Within a block: those 5, plus one shared-memory round trip, one barrier, and up to 5 more shuffles for the warp partials. Across the grid: each block writes its single partial to global memory, and a second tiny kernel over the B partials finishes the job — or each block does one FP32 atomicAdd, which saves the launch but can make the result run-to-run nondeterministic because floating-point addition is not associative and block arrival order can change. The two-kernel path adds a logical 8B bytes for writing and rereading the partials; atomics instead perform contended read-modify-writes whose physical traffic depends on the cache and memory system. Tree depth is O(log N) at every level, but only the first level touches the full input; that is the difference between a tuned reduction sitting on bandwidth and a naive one sitting at 5% (Harris 2007).

One variant to know: built as a butterfly with __shfl_xor_sync, the same 5 steps leave the sum in every lane instead of only lane 0 — which is what you want when all threads consume the result, as in the norms of §4.5 (those broadcast through shared memory instead). Same depth either way; the choice is only about where the answer has to end up.

The Volta trap

Pre-Volta code often omitted synchronization inside a warp on the grounds that "warps are lockstep." Independent thread scheduling (Part 1, §1.4.2) removed that guarantee. Every intra-warp exchange must use a _sync intrinsic with an explicit mask, and every lane in that mask must actually reach the instruction. Building the mask from __activemask() is a race, not a fix.

4.3 Softmax, and how to do it in two passes

Softmax needs a maximum for numerical stability, a sum of exponentials for normalization, and then a division. Written naively that is three passes over the data:

Algorithm 2 — safe softmax, 3 passes
m ← maxk xk
d ← Σj exp(xj − m)
yi ← exp(xi − m) / d

Milakov and Gimelshein's observation is that the first two passes can be fused, if you are willing to retroactively correct the running sum every time the maximum increases:

Algorithm 3 — online softmax, 2 passes
m0 ← −∞,  d0 ← 0
for j = 1…V:
    mj ← max(mj−1, xj)
    dj ← dj−1 · exp(mj−1 − mj) + exp(xj − mj)
then yi ← exp(xi − mV) / dV

That bolded factor is the whole trick. When the running max jumps from mj−1 to a larger mj, every term already accumulated in d was scaled by the wrong exponent; multiplying by exp(mj−1 − mj) fixes all of them at once. And because the exponent is always ≤ 0, the correction never overflows.

interactive The online-softmax recurrence, stepped one block at a time. Watch the rescale factor fire whenever a new maximum appears — and watch the final (m, d) agree exactly with the three-pass answer. This is the mechanism FlashAttention uses to process attention scores it never fully materialises.

The state is a pair (m, d), and the combining operator is associative and commutative:

(ma, da) ⊕ (mb, db) = ( M,   da·exp(ma−M) + db·exp(mb−M) )   where M = max(ma, mb)

which means it drops straight into the shuffle tree from §4.2. Every thread keeps its own partial (m, d), and one warp reduction combines them. That is the entire parallel online softmax.

The payoff is memory accesses per element: 4 for the safe 3-pass version, 3 for the online version, and the paper measures a 1.15–1.3× end-to-end speedup for softmax alone. That is a modest win here — and a decisive one in attention, where the "array" is a score matrix you cannot afford to store at all.

When you don't need it

If a whole row fits in shared memory or registers, you can just load it once and do all three passes on chip — which is what Triton's fused-softmax tutorial does, and it is optimal. Online softmax earns its keep precisely when the row does not fit: a 128k-token vocabulary, or an attention row of length 128k. Part 7.

4.4 The exponential is more expensive than you think

From Part 1's instruction table: the special-function unit produces 16 results per clock per SM. On an H100 that works out to about 3.9 trillion exp results/s, against 989.5 TFLOP/s of BF16 matmul — unlike units, but a useful throughput ratio for the operation mix that FlashAttention-3 rounds to 256×.

The throughput gap that shapes every softmax-containing kernel on Hopper. A matmul FLOP and an exponential are not remotely the same unit of work. In FlashAttention's inner loop there are about 512× more matmul FLOPs than exponentials, so at 256× lower throughput the exponentials take roughly half as long as the matmuls themselves — which is why overlapping them is worth so much.

Practical consequences:

  • Use exp2, not exp. The hardware instruction is MUFU.EX2. Computing ex costs an extra multiply by log2e — so fold that constant into the scale you were already applying. Attention kernels multiply QKT by 1/√d · log2e and then call exp2.
  • Prefer __expf/-use_fast_math semantics where accuracy allows; the accurate expf is a multi-instruction sequence.
  • Overlap it with matmul. This is exactly what FlashAttention-3's ping-pong schedule does: while one warpgroup runs its GEMM on the tensor cores, the other runs its softmax on the SFU. Two different units, so they genuinely run concurrently. Part 7.

4.5 Normalization: RMSNorm, LayerNorm, and Welford

RMSNorm is the modern default (Llama, Mistral, Gemma, DeepSeek) because it drops the mean-centering entirely:

y = x / RMS(x) · g,    RMS(x) = √( (1/n) Σ xi2 + ε )

One reduction instead of two, no mean subtraction, same normalization effect in practice. It is entirely memory-bound: for hidden size H it does O(H) FLOPs on 2H·s bytes, an intensity around 0.75.

What normalization costs a real model

Llama-3-70B, 8192 tokens per forward, hidden 8192, BF16. One RMSNorm reads 134 MB and writes 134 MB → 80 µs floor. Two norms per layer × 80 layers = 12.8 ms of pure normalization bandwidth per forward pass. That is why fusing the norm into the residual add, or into the next GEMM's prologue, is worth real money.

// One block per row. H must fit in the block's registers + a reduction.
template<int BLOCK>
__global__ void rmsnorm(const __nv_bfloat16* __restrict__ x,
                        const __nv_bfloat16* __restrict__ g,
                        __nv_bfloat16* __restrict__ y, int H, float eps)
{
    const int row = blockIdx.x;
    x += (size_t)row * H;  y += (size_t)row * H;

    float acc = 0.f;
    for (int i = threadIdx.x; i < H; i += BLOCK) {
        float v = __bfloat162float(x[i]);
        acc = fmaf(v, v, acc);                 // accumulate in FP32 — always
    }
    acc = blockReduceSum(acc);

    __shared__ float inv;
    if (threadIdx.x == 0) inv = rsqrtf(acc / H + eps);
    __syncthreads();

    for (int i = threadIdx.x; i < H; i += BLOCK)   // second pass hits L1/L2
        y[i] = __float2bfloat16(__bfloat162float(x[i]) * inv * __bfloat162float(g[i]));
}
@triton.jit
def rmsnorm(X, G, Y, stride, H, eps, BLOCK: tl.constexpr):
    row  = tl.program_id(0)
    cols = tl.arange(0, BLOCK)
    mask = cols < H
    x = tl.load(X + row*stride + cols, mask=mask, other=0.).to(tl.float32)
    inv = tl.math.rsqrt(tl.sum(x*x, axis=0) / H + eps)
    g = tl.load(G + cols, mask=mask, other=0.).to(tl.float32)
    tl.store(Y + row*stride + cols, (x*inv*g).to(tl.bfloat16), mask=mask)
rms = torch.rsqrt(x.float().pow(2).mean(-1, keepdim=True) + eps)
y   = (x.float() * rms * g.float()).to(x.dtype)
Note the FP32 accumulator in every version. Summing 8192 BF16 squares in BF16 loses most of the answer — BF16 stores 7 fraction bits, or 8 bits of precision including the implicit leading bit.

4.5.1 If you need the mean too: Welford

LayerNorm needs variance, and the textbook formula Var = E[x²] − E[x]² is numerically dangerous: for data with a large mean and small spread it subtracts two nearly equal large numbers. Welford's algorithm avoids that by updating incrementally:

n ← n + 1
δ ← x − mean
mean ← mean + δ/n
M2 ← M2 + δ·(x − mean)   ← the second (x − mean) uses the UPDATED mean
var = M2 / n

And it merges in parallel — the merge below is Chan, Golub and LeVeque's update — which is what makes it usable on a GPU: the merge is associative in exact arithmetic, so it goes through the same shuffle tree:

n = nA + nB,   δ = meanB − meanA
mean = meanA + δ·nB/n
M2 = M2A + M2B + δ²·nAnB/n
In practice

Triton's own LayerNorm tutorial does not use Welford — it does a straightforward two-pass over BLOCK_SIZE chunks in FP32, which is accurate enough for activations that are already roughly zero-mean. Welford matters when you cannot afford the second pass or when the data has a large offset. Know both.

4.6 Fusion is the whole game

For the large-tensor cases modelled in this part, bandwidth is the limiting roof, so the main structural optimization is to do more per transferred byte. Three levels of it, in increasing order of payoff:

  1. Fuse elementwise chains. RMSNorm → SiLU → multiply → residual as one kernel instead of four is a 4× win, as computed in Part 3. This is what torch.compile does automatically, and it is why it helps so much on small models.
  2. Fuse into the GEMM epilogue. The accumulator is already in registers when the mainloop ends. Bias, activation, residual add, and quantization all cost a handful of instructions there, versus two full passes over the output tensor as separate kernels. Part 5.
  3. Fuse across the algorithm. This is FlashAttention: not "fuse these three elementwise ops" but "restructure so the intermediate never exists". Part 7.

4.6.1 What Triton does and does not do for you

Triton handles shared-memory allocation, swizzled layouts, software pipelines, reduction trees, and tensor-core instruction selection. For a masked tl.load, the mask makes out-of-bounds lanes safe and the compiler can vectorize when the pointer layout, alignment and divisibility permit; coalescing still depends on the layout you supplied and should be checked. You choose block sizes, num_warps, num_stages, and the algorithm. Current Triton APIs can express persistent scheduling and explicit warp-specialized producer/consumer pipelines, but the compiler does not invent that algorithmic restructuring for you. Those schedules remain programmer-visible, which is why the fastest attention kernels still spend so much code on them.

For everything in this part, that gap does not matter. A Triton elementwise, reduction or norm kernel will match a good CUDA one, because the ceiling is memory bandwidth and both reach it. Write these in Triton.

Checklist for a memory-bound kernel

1. Compute the byte floor first — bytes ÷ bandwidth — and treat it as the target.
2. Keep accesses coalesced and aligned; benchmark scalar and vector widths up to 16 bytes per lane, with several accesses in flight.
3. Reduce with shuffles, not shared-memory trees.
4. Accumulate in FP32 even when the data is BF16.
5. Fuse anything adjacent. If you read a tensor twice in two kernels, you have doubled your runtime.
6. Measure achieved bandwidth as well as runtime. 85% of peak is a strong result; confirm the remaining gap is worth pursuing.

Next: the op at the other end of the roofline. Matmul, from a naive triple loop to something within 10% of cuBLAS, one measured step at a time.