AI EngineeringZero to ProductionHome·About·What’s new·Contact
Research & Frontier Eng · Part 1

Custom GPU kernels & Triton

A GPU can do far more arithmetic than it can feed itself data. The kernels that win are the ones that move fewer bytes — not the ones that do less math. This lesson builds the roofline model from first principles, shows why a fused softmax beats three library calls, walks a Triton kernel line by line, and reasons about occupancy — all modeled offline in pure Python, with the real GPU code labeled and left to read.

⏱️ ~2.5 hours🧪 5 labs🎯 Advanced→Industry

Learning objectives

  • Classify any kernel as memory-bound or compute-bound using arithmetic intensity and the roofline ridge point.
  • Explain why elementwise ops (softmax, LayerNorm, activations) are memory-bound and why kernel fusion is a data-movement win.
  • Read a Triton kernel and map its tl.load/tl.store/tl.dot block operations to the memory hierarchy.
  • Reason about occupancy — how block size and register/SRAM pressure cap the warps an SM can keep resident.
  • Decide, with a checklist, when to hand-write a kernel versus reach for cuBLAS/cuDNN/FlashAttention.
Where this sitsThis track sits below the ML-systems track. MS3 introduced FlashAttention and online softmax; here we generalize that to the roofline model and the mechanics of authoring a kernel in Triton. MS1 has the raw memory hierarchy if you want the refresher.

1 · Why kernels matter: the memory wall

Start with a benchmark that lied to you. You profile your model and find softmax eating a surprising slice of wall-clock time. So you do the obvious thing: you count the FLOPs. Softmax over a row is a handful of operations per element — a max, an exponential, a divide. Almost nothing. The GEMM next to it does billions of FLOPs and somehow finishes faster per byte touched. You stare at the numbers and they make no sense: the cheap op is the slow one. Nothing about "count the math" explains it, because you're measuring the wrong resource.

Here's the reframe that fixes it, in one sentence: a GPU can do far more arithmetic than it can feed itself data. Think of the ALUs as a huge kitchen full of chefs and HBM (the GPU's main memory) as a single narrow doorway they all fetch ingredients through. Adding chefs doesn't help if they're all queued at the door. For softmax, the "cooking" (the exp, the divide) is trivial, but every element still has to walk through that doorway to be read and walk back out to be written — so the doorway, not the chefs, sets the pace. That is what "memory wall" means: for a large class of real kernels the arithmetic units sit idle, waiting on bytes.

This inverts the beginner instinct. The naive optimizer asks "how do I do less math?" The kernel engineer asks "how do I touch HBM fewer times?" On memory-bound work, shaving FLOPs off an op that's already starving for bandwidth changes nothing — you optimized the chefs while the doorway stayed the same width. The whole discipline of custom kernels, and everything in this lesson — fusion, FlashAttention, tiling, mixed precision — is a set of tricks for moving fewer bytes, not doing less arithmetic. The common, expensive mistake is to profile, see a slow elementwise op, and rewrite its math; you can burn a week that way and gain nothing.

So before you touch a single line, you compute one number that tells you which world you're in. That number is arithmetic intensity, and the picture that reads it is the roofline — introduced next and used in every lab that follows.

A modern accelerator is a bandwidth-starved compute monster. Peak arithmetic throughput has grown far faster than memory bandwidth for two decades. The practical consequence: for a huge fraction of real kernels, the arithmetic units sit idle, waiting for data to arrive from HBM. When you make such a kernel faster, you almost never do it by doing less math — you do it by touching HBM fewer times.

The single number that decides which world you live in is arithmetic intensity (AI): FLOPs performed per byte moved from HBM. Compare it to the machine's ridge point — peak FLOP/s ÷ bandwidth. Below the ridge you are memory-bound (bandwidth caps you); above it you are compute-bound (the ALUs cap you). This is the roofline model, and it is the first thing to compute before optimizing anything.

op FLOPs & bytes FLOP / byte arithmetic intensity AI = F/B compare to ridge peak/bw pick the knob bytes vs FLOPs
🗺️ How to read this diagram

Read it left-to-right, following the arrows: it's the four-step recipe for deciding what to optimize, each box feeding the next.

  • The first box (op FLOPs & bytes) is the raw measurement — count how many FLOPs the op does and how many bytes it moves from HBM.
  • The arrows chain the boxes in order: the middle two form arithmetic intensity (AI = F/B, FLOPs per byte) and then compare to ridge (the machine's peak÷bandwidth break-even).
  • The last box (pick the knob) is the payoff: knowing which side of the ridge you're on tells you whether to cut bytes or cut FLOPs.

In short: Measure FLOPs and bytes, divide to get intensity, compare to the ridge, and let that pick your optimization knob.

The one reframePeak compute has outrun memory bandwidth. So the fastest kernel is usually the one that reads and writes HBM the fewest times, not the one with the fewest FLOPs. Fusion, FlashAttention, and mixed precision are all corollaries of that.

2 · Lab · roofline & arithmetic intensity, offline

You've heard "memory-bound" and "compute-bound" thrown around in code reviews as if everyone can eyeball which is which. They can't — and neither can you, reliably, until you turn the words into a division. The roofline model exists so the judgment stops being a vibe and becomes a calculation you can do on a napkin before you write any kernel.

Two numbers do all the work. The first is arithmetic intensity (AI): how many FLOPs the op performs for every byte it moves from HBM — FLOPs ÷ bytes. Picture it as fuel economy: miles per gallon, but "math per byte." A dense matmul reuses each byte it loads across many multiply-adds, so it has high intensity (a fuel-efficient op). Softmax reads a value, does almost nothing to it, writes it back — one or two FLOPs per byte, terrible intensity. The second number belongs to the machine, not the op: the ridge point = peak FLOP/s ÷ memory bandwidth. It's the break-even intensity where the hardware flips from being bottlenecked by bytes to being bottlenecked by arithmetic.

The subtle part is that the ridge is a property of the GPU you happen to be running on, not the algorithm. On an H100 the ridge sits near ~295 FLOP/byte (as the lab computes), which is startlingly high — it means most elementwise ops, and even many "reasonable-looking" kernels, land well below it and are memory-bound. That single fact explains why so much kernel work is really data-movement work. The mistake to avoid: quoting an op as "memory-bound" in the abstract. Bound relative to what machine? A kernel that's memory-bound on an H100's fat bandwidth can be a different story on hardware with a different FLOP-to-bandwidth ratio. The lab below makes the whole thing concrete: feed it an op's FLOPs and bytes, pick a GPU, and it tells you the side of the ridge you're on and what fraction of peak you can even hope to reach.

Let's make it a number. Given a kernel's FLOPs and its HBM byte traffic, this classifies it against a chosen GPU and reports the attainable throughput. Pure stdlib — it runs on a laptop and just does the roofline arithmetic.

attainable FLOP/s = min(peak, AI × bandwidth) arithmetic intensity (FLOP / byte) → attainable FLOP/s → ridge ≈ 295 bandwidth roof compute roof (peak) softmax AI≈0.5 → memory-bound GEMM AI≈1365 → compute-bound One picture reads the whole model. The sloped blue line is the bandwidth roof; it turns flat (the red compute roof) at the ridge. Left of the ridge you are memory-bound; right of it, compute-bound.
🗺️ How to read this diagram

Read left-to-right: an op's arithmetic intensity places it on the horizontal axis, and the roof directly above it is the best FLOP/s it can ever reach.

  • The sloped blue line is the bandwidth roof — attainable throughput = AI × bandwidth, so it climbs as intensity rises. Any op below the ridge is pinned to this slope.
  • The flat red line is the compute roof (the GPU's peak FLOP/s). Once you pass the ridge point (the dashed vertical, ≈295 FLOP/byte on an H100) the ALUs, not bytes, are the limit.
  • The amber dot (softmax, AI≈0.5) sits far left, stuck low on the bandwidth slope — memory-bound. The green dot (GEMM, AI≈1365) sits right at the compute roof — compute-bound. Fusion's whole job is to slide an op rightward, up the slope, toward the roof.

In short: Where your op lands relative to the ridge decides the cure — left of it, cut bytes; right of it, you're already at the roof.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · roofline classifier (runs, offline)
roofline.pydef roofline(flops, bytes_moved, peak_flops, bw_bytes_per_s):
    """Arithmetic intensity = FLOPs per byte. Below the ridge (peak/bw) => memory-bound."""
    ai = flops / bytes_moved                      # FLOP per byte for the kernel
    ridge = peak_flops / bw_bytes_per_s           # FLOP/byte where the GPU flips
    attainable = min(peak_flops, ai * bw_bytes_per_s)
    bound = "memory-bound" if ai < ridge else "compute-bound"
    return ai, ridge, attainable, bound

# An H100-class GPU: ~990 TFLOP/s (BF16) and ~3.35 TB/s of HBM bandwidth.
PEAK, BW = 990e12, 3.35e12
N = 4096

for name, flops, byts in [
    # softmax over one row: ~3 FLOPs/elem (max, exp, div), read+write the row
    ("softmax row (elementwise)", 3 * N,          2 * N * 4),
    # dense GEMM N^3: 2*N^3 FLOPs, read A,B + write C (all N*N, fp32)
    ("4096-cube dense GEMM",      2 * N**3,        3 * N * N * 4),
]:
    ai, ridge, att, bound = roofline(flops, byts, PEAK, BW)
    print(f"{name:28} AI={ai:8.1f} FLOP/B  ridge={ridge:6.1f}  -> {bound}")
    print(f"{'':28} attainable={att/1e12:8.1f} TFLOP/s  ({att/PEAK*100:.1f}% of peak)")
softmax row (elementwise)   AI=     0.5 FLOP/B  ridge= 295.5  -> memory-bound
                             attainable=     1.7 TFLOP/s  (0.2% of peak)
4096-cube dense GEMM         AI=  1365.3 FLOP/B  ridge= 295.5  -> compute-bound
                             attainable=   990.0 TFLOP/s  (100.0% of peak)

The softmax row lives far below the ridge and can only reach a fraction of a percent of peak — it starves for bandwidth. The big GEMM saturates the ALUs. This is exactly why vendors ship a hand-tuned GEMM (cuBLAS) but you fuse the elementwise glue around it: the GEMM is already at the compute roof, and the elementwise ops only cost you HBM trips.

3 · Kernel fusion: the bandwidth win

Picture a five-step recipe where every step makes you walk to the pantry and back. You fetch the flour, do one thing, return the bowl to the pantry. Then walk back, fetch the same bowl, do the next thing, return it. Five steps, ten trips down the hall — even though the bowl never really needed to leave your hands. That is exactly what a chain of elementwise ops does when each runs as its own GPU kernel: bias, then GELU, then dropout, then residual — every one launches separately, reads the whole activation tensor from HBM, does its little bit of math, and writes the whole tensor back. The intermediate results are round-tripped through the slow doorway again and again for no reason.

Kernel fusion is keeping the bowl on the counter. You merge the chain into a single kernel: read the tensor from HBM once, do all the math while the data lives in fast on-chip registers/SRAM, and write the result back once. The arithmetic is identical — you didn't remove a single FLOP — but the trips to HBM collapse from "one read + one write per op" to "one read + one write total." For a memory-bound chain, where the doorway is the bottleneck, the speedup is essentially the traffic-reduction ratio: fuse five ops and you can cut HBM traffic roughly fivefold, and the kernel runs roughly that much faster.

This is not a niche trick — it's the shape of most of the famous wins. FlashAttention is fusion applied to the attention block (it never writes the giant intermediate score matrix to HBM at all). Fused LayerNorm, fused softmax, fused activation-plus-bias stacks are the same idea at smaller scale. Once you see it, you see it everywhere: the win is the byte you didn't move.

But there's a trap the roofline warns you about, and it catches people who over-apply fusion. Fusion only helps the ops that were memory-bound to begin with. If the dominant cost in your chain is a big matmul that's already compute-bound — already pinned at the ALU roof — then fusing the cheap elementwise glue around it barely moves the total, because those ops were never the wall. The discipline is always the same order: roofline first to find which part is bandwidth-starved, then fuse that part. The lab below turns the traffic math into a number so you can predict the payoff before writing the kernel.

Every kernel launch reads its inputs from HBM and writes its outputs back. A chain of elementwise ops — say x → bias → GELU → dropout → residual — run as separate kernels round-trips the whole activation tensor through HBM once per op. Fusing them into one kernel keeps the intermediate in registers/SRAM and pays the HBM cost once. For a memory-bound chain the speedup is essentially the traffic-reduction ratio. FlashAttention is the famous instance; the same lever applies to LayerNorm, softmax, and activation stacks.

unfused: 5 kernels, 10 HBM trips HBM (slow doorway) bias GELU drop resid … read+write every op fused: 1 kernel, 2 HBM trips HBM one fused kernel bias→GELU→drop→residon-chip (SRAM) read 1× write 1× ~5× less traffic, same math The win is the byte you didn't move. The left chain round-trips the whole tensor through HBM once per op (10 trips); the fused kernel reads once, does every op in SRAM, and writes once (2 trips) — identical arithmetic.
🗺️ How to read this diagram

Compare the two sides across the dashed divider: same five ops, but count the arrows crossing into and out of the red/green HBM bar at the top.

  • On the left (unfused), each blue op box has its own pair of arrows to the red HBM bar — a read down and a write back up. Five ops = ten trips through the slow doorway, and the intermediate tensor never needed to leave.
  • On the right (fused), all five ops collapse into one kernel box that lives on-chip (SRAM). There is exactly one blue read 1× arrow in and one write 1× arrow out of the green HBM bar.
  • The arithmetic is identical — no FLOP removed. The only thing that changed is HBM trips: 10 → 2, roughly a 5× cut for a bandwidth-bound chain (the green payoff line).

In short: Fusion doesn't do less math — it moves fewer bytes by keeping the intermediate on-chip between ops.

Python · fusion traffic model (runs, offline)
fusion.pydef fusion_traffic_gib(tensor_elems, k_ops, bytes_per=2):
    """Unfused: each of k elementwise ops does one read + one write.
    Fused: one read + one write total for the whole chain."""
    per_pass = tensor_elems * bytes_per
    unfused = 2 * k_ops * per_pass       # k ops x (read + write)
    fused   = 2 * per_pass               # one read + one write
    return unfused / 1024**3, fused / 1024**3

u, f = fusion_traffic_gib(tensor_elems=8192 * 8192, k_ops=5)
cut = (u - f) / u * 100
print(f"unfused (5 ops): {u:.2f} GiB HBM traffic")
print(f"fused  (1 op) : {f:.2f} GiB HBM traffic")
print(f"traffic cut: {cut:.0f}%  -> ~{u/f:.0f}x for a bandwidth-bound chain")
unfused (5 ops): 2.50 GiB HBM traffic
fused  (1 op) : 0.50 GiB HBM traffic
traffic cut: 80%  -> ~5x for a bandwidth-bound chain
Fusion is not free mathFusion helps memory-bound chains. If the dominant op is already compute-bound (a big matmul), fusing cheap elementwise ops around it barely moves the needle — the roofline already told you the GEMM is the wall. Always roofline first, then fuse the memory-bound part.

4 · A Triton kernel, walked

For years, "write a fused kernel" was a sentence that ended most conversations. It meant CUDA C++: managing threads and warps by hand, allocating shared memory yourself, worrying about which thread reads which byte so the hardware can coalesce the accesses, and debugging with tools that assume you already think in grids and blocks. The idea for the kernel was often ten lines; the CUDA to express it safely was two hundred. So most engineers who knew the fusion win existed still couldn't reach for it — the cost of authoring was the barrier, not the concept.

Triton (from OpenAI) lowers that barrier by letting you write GPU kernels in Python at the level of blocks — tiles of data — instead of individual threads. You say "each program instance handles one row" and "load this block into fast memory"; Triton's compiler decides how to map that onto threads and warps, how to lay out SRAM, and how to coalesce the memory accesses so neighboring lanes read neighboring addresses. You describe the tiling; it handles the plumbing. This is why so much of the modern fused-kernel ecosystem, including FlashAttention variants, is authored in Triton rather than raw CUDA.

A few vocabulary anchors before you read the code, because the mental model matters more than the syntax. A @triton.jit function is the kernel — the body that runs on the GPU, compiled just-in-time. tl.program_id(0) is "which instance am I?" — the launch fires many copies of the kernel in parallel, and each asks its ID to figure out which slice of data it owns (here, its row). tl.arange(0, BLOCK) builds the vector of column offsets that instance will touch; tl.load pulls that tile from HBM into on-chip memory once; the math runs on-chip; tl.store writes the result back once. The mask handles the ragged edge when a row's length isn't a clean multiple of the tile.

Watch for the fusion signature as you read: exactly one tl.load and one tl.store, with all the softmax work — max, subtract, exp, sum, divide — sandwiched between them and never spilling back to HBM. That is section 3's principle made literal in code: one read, one write, everything fused in the middle. The block below is real GPU code, here to read, not to run in this offline lesson — but you'll be able to reason about every line, and the next lab decides the BLOCK size it should use.

Writing a fused, tiled kernel traditionally meant CUDA C++. Triton (from OpenAI) lets you write GPU kernels in Python: you express the computation over blocks (tiles), and Triton's compiler handles thread scheduling, SRAM allocation, and memory coalescing. Much of the modern fused-kernel ecosystem — including FlashAttention variants — is authored this way. The block below is real GPU code, here to read, not to run in this offline lesson: a fused softmax over each row of a matrix.

one program instance = one row (tl.program_id) HBM row X[row, :] on-chip (SRAM) x − max(x) exp(x) e / sum(e) HBM row Y[row, :] tl.load 1 read tl.store 1 write mask = cols < n_cols handles the ragged tile edge one read · all math on-chip · one write = fusion Fusion, made literal. A single tl.load brings the row-tile into SRAM; every softmax step runs on-chip; a single tl.store writes it back — exactly one read and one write, no HBM round-trips in the middle.
🗺️ How to read this diagram

Read left-to-right, following the two blue arrows: data comes in from HBM once, is transformed entirely on-chip, and goes back to HBM once.

  • The left box is the input row in slow HBM. tl.load (the first blue arrow) pulls that row-tile into fast on-chip SRAM — the one and only read.
  • The middle box is on-chip work: subtract the max (numerical stability), exp, then divide by the sum. All of the softmax math happens here, in registers/SRAM, touching HBM zero times.
  • The right box is the output row; tl.store (the second blue arrow) writes it back once. The mask note is how the kernel handles a row whose length isn't a clean multiple of the tile.

In short: One tl.load in, all math on-chip, one tl.store out — the fusion pattern of section 3 spelled out in Triton primitives.

Python (Triton) · fused row-softmax kernel ▶ needs a GPU + Triton/PyTorch
triton_softmax.py# >>> NEEDS A GPU + Triton/PyTorch. Does NOT run in this offline lesson. <<<
# pip install triton torch    # and a CUDA GPU
import triton
import triton.language as tl

@triton.jit
def softmax_row_kernel(X, Y, stride, n_cols, BLOCK: tl.constexpr):
    row = tl.program_id(0)                       # one program per row
    cols = tl.arange(0, BLOCK)
    ptr = X + row * stride + cols
    mask = cols < n_cols
    x = tl.load(ptr, mask=mask, other=-float("inf"))  # row tile -> SRAM (one read)
    x = x - tl.max(x, axis=0)                    # numerically stable softmax, on chip
    e = tl.exp(x)
    y = e / tl.sum(e, axis=0)
    tl.store(Y + row * stride + cols, y, mask=mask)   # write once

# Host launch (also needs a GPU):
# import torch
# x = torch.randn(1823, 781, device="cuda")
# y = torch.empty_like(x)
# BLOCK = triton.next_power_of_2(x.shape[1])
# softmax_row_kernel[(x.shape[0],)](x, y, x.stride(0), x.shape[1], BLOCK=BLOCK)
Read it, don't run itNotice the shape: tl.load pulls one row-tile into SRAM once, all the softmax math (max, exp, sum, divide) happens on-chip, and tl.store writes the result once. That is fusion made explicit: one read, one write, everything in between fused. The offline model in the next lab decides the BLOCK size the kernel uses.

5 · Occupancy: block size vs resident warps

Here's a result that feels like a bug the first time you hit it. You've got a working Triton kernel, and the obvious way to make it faster is to give each program more work — so you bump BLOCK from 256 to 1024, expecting fewer launches and better efficiency. You run it and it gets slower. You didn't change the math, you didn't add memory traffic, you just made each tile bigger, and the kernel regressed. Every kernel author trips over this once; the explanation is occupancy, and it's the reason "bigger tile" is not the same as "faster kernel."

The intuition first. A GPU hides the slowness of HBM not by making any single memory read fast, but by keeping many groups of threads (warps) resident on each SM at once — when one warp stalls waiting for bytes from the doorway, the SM instantly switches to another warp that's ready to compute. It's a call center: while one agent is on hold, the others keep taking calls, so the line never goes quiet. But each SM has a fixed budget of registers and shared memory, and every resident block spends from that budget. A bigger BLOCK uses more registers and SRAM per block, so fewer blocks — fewer warps — fit at once. Push the tile too big and you have a call center with three agents: the moment they're all on hold, everyone waits, and the memory-bound kernel stalls.

Occupancy is the name for that ratio: resident warps ÷ the hardware maximum. It's capped by whichever resource runs out first — registers, shared memory, or a hard per-SM block limit — so the binding constraint is a min over those three. Now the paradox dissolves: the big block regressed because it dropped occupancy below the point where the SM could hide HBM latency, and a memory-bound kernel lives or dies on latency hiding.

One honest caveat, so you don't over-index on the number: occupancy is a means, not the goal. A compute-bound kernel with lots of independent work per thread (high instruction-level parallelism) can run near the roof at modest occupancy — it doesn't need a deep bench of warps because it's rarely waiting. But for the memory-bound kernels that dominate LLM inference, keeping enough warps resident to cover the doorway latency is usually the whole game. The mistake is chasing 100% occupancy blindly, or its opposite — grabbing the largest block that compiles. The right move, which the lab models offline, is to pick the largest tile that still keeps occupancy above the floor where latency stays hidden.

A kernel's BLOCK size is not free to pick. Each SM has a fixed budget of registers and shared memory (SRAM). The more each block consumes, the fewer blocks (and warps) the SM can keep resident at once. Occupancy is the ratio of resident warps to the hardware maximum. Low occupancy means the SM can't hide memory latency by switching to other warps while one stalls on HBM — so a bigger tile that spills registers can be slower than a smaller one that keeps the SM full.

Occupancy is not a goal in itself — a compute-bound kernel with enough instruction-level parallelism can run fast at modest occupancy. But for the memory-bound kernels that dominate LLM inference, keeping enough warps resident to hide HBM latency is usually the game. This lab models the tradeoff offline.

small BLOCK → many warps resident one SM · fixed register/SRAM budget stalled warp? switch to a ready one latency hidden → high occupancy large BLOCK → few warps resident same budget, fatter blocks all stalled? SM goes idle latency exposed → low occupancy occupancy = resident warps ÷ hardware max pick the largest tile that stays above the occupancy floor Bigger tile ≠ faster kernel. A small block leaves many warps resident, so the SM hides HBM latency by switching to a ready warp. A big block spills the fixed budget, few warps stay resident, and a stall exposes the doorway.
🗺️ How to read this diagram

Compare the two SMs across the dashed line: both have the same fixed register/SRAM budget, but the block size decides how many warps fit inside it.

  • On the left, small blocks pack the SM with many small green warps. When one stalls waiting on HBM, the SM instantly switches to another that's ready — the memory latency is hidden, occupancy is high.
  • On the right, a bigger BLOCK means each block eats more of the budget, so only two fat red blocks fit. Now if they all stall on memory at once, there's no ready warp to switch to and the SM goes idle — latency is exposed.
  • The payoff line defines occupancy = resident warps ÷ hardware max. The sweet spot is the largest tile that still keeps occupancy above the floor — not the biggest block that compiles.

In short: A fixed budget means bigger tiles → fewer resident warps → less latency hiding, which is why a larger block can run slower.

Python · occupancy from register/SRAM budget (runs, offline)
occupancy.pyimport math

def occupancy(block_threads, regs_per_thread, sram_per_block,
              regs_per_sm=65536, sram_per_sm=101376, max_warps_per_sm=64,
              max_blocks_per_sm=32):
    """Resident blocks are limited by whichever runs out first: registers,
    shared memory, or the hard block cap. Occupancy = resident warps / max warps."""
    warps_per_block = math.ceil(block_threads / 32)
    regs_per_block = block_threads * regs_per_thread
    by_regs  = regs_per_sm // regs_per_block
    by_sram  = sram_per_sm // sram_per_block if sram_per_block else max_blocks_per_sm
    blocks = min(by_regs, by_sram, max_blocks_per_sm)
    warps = blocks * warps_per_block
    return blocks, warps, warps / max_warps_per_sm

best = None
for block in (64, 256, 1024):
    blocks, warps, occ = occupancy(block, regs_per_thread=32, sram_per_block=8192)
    print(f"BLOCK={block:>4}: {warps:>2} warps/SM resident -> occupancy {occ*100:4.0f}%")
    if occ >= 0.5:
        best = block
print(f"sweet spot: largest block that stays >=50% occupancy = {best}")
BLOCK=  64: 64 warps/SM resident -> occupancy 100%
BLOCK= 256: 32 warps/SM resident -> occupancy  50%
BLOCK=1024:  8 warps/SM resident -> occupancy  12%
sweet spot: largest block that stays >=50% occupancy = 256

6 · When to write a kernel vs use a library

The most expensive kernel is the one you didn't need to write. There's a seductive failure mode for engineers who've just learned all of the above: a profiler flags a slow op, and — armed with roofline and Triton — you spend a week hand-crafting a beautiful fused kernel, only to discover cuBLAS was already faster, or torch.compile would have fused it for free, or the op wasn't even on the hot path. The skill this section teaches is mostly the skill of restraint: knowing when the answer is "use the library and go home."

The reason restraint usually wins is that the vendor libraries represent thousands of engineer-hours of tuning against specific hardware, and they own the compute-bound territory outright. cuBLAS and cuDNN are at or near the compute roof for dense matmul and convolution — you will not beat them, and trying is a trap because the roofline already told you those ops are ALU-limited, not something a clever fusion can rescue. FlashAttention owns attention at any sequence length. torch.compile and the fused optimizers sweep up most of the standard elementwise glue automatically. Against all of that, a hand-written kernel is not just effort spent — it's a permanent liability: code that must be re-tuned for the next GPU generation, tested, and maintained by someone after you've moved on.

So the space where a custom kernel actually earns its keep is narrow and specific: a memory-bound pattern (roofline-proven — bytes, not FLOPs, are the wall), that no library fuses for you, sitting on the hot path where the win is worth the maintenance, and expressible as tiles that fit SRAM. Miss any one of those and the honest recommendation flips back to "use the library." The common mistake is to reason from "this op is slow" straight to "so I'll write a kernel," skipping the two questions in between — which wall, and does something already fuse it. The table and checklist below turn that judgment into a decision you can defend in a design review, and the final triage lab encodes it as a small truth table over (bound, library-covered, hot-path).

Custom kernels are a sharp tool and a maintenance liability. The default is: use the library. cuBLAS/cuDNN own dense GEMM and convolutions; FlashAttention owns attention; torch.compile and the fused optimizers cover most elementwise glue. You write a kernel only when you have a specific memory-bound pattern the libraries don't fuse and you've proven with a roofline that bytes — not FLOPs — are the wall.

a slow op on the hot path? no LEAVE IT yes roofline: memory-bound? no (compute) USE LIBRARY (cuBLAS) yes a library fuses this pattern? yes AUTO-FUSE no HAND-WRITE (Triton) Only one leaf writes a kernel. Three questions — hot path? memory-bound? library-fused? — filter every slow op, and only the memory-bound, hot, un-fused case (the green leaf) earns a hand-written kernel.
🗺️ How to read this diagram

Follow the arrows top-to-bottom: each diamond-style question routes the op left or right, and every path ends in a coloured verdict.

  • First gate — on the hot path? If no, the op goes straight to the red LEAVE IT; don't spend effort where the win doesn't matter.
  • Second gate — memory-bound (roofline)? A compute-bound op routes to the amber USE LIBRARY: cuBLAS/cuDNN already sit at the compute roof and you won't beat them.
  • Third gate — does a library fuse this exact pattern? If yes, the amber AUTO-FUSE (torch.compile) leaf. Only if it's hot, memory-bound, and un-fused do you reach the green HAND-WRITE leaf — the single case where a Triton kernel earns its maintenance cost.

In short: A slow op has to pass all three gates — hot, memory-bound, un-fused — before hand-writing a kernel is the right call; every other path defers to a library.

SituationReach for
Dense matmul / convcuBLAS / cuDNN — already at the compute roof
Attention (any length)FlashAttention / fused-attn library
Standard elementwise chaintorch.compile / fused kernels — auto-fused
Novel memory-bound fusion the libs missWrite it (Triton first, CUDA if needed)
Compute-bound and vendor lib existsDo not hand-write — you won't beat it
The checklistWrite a kernel only when all hold: (1) roofline says memory-bound; (2) no library fuses this exact pattern; (3) the op is on the hot path and worth the maintenance; (4) you can express it as tiles that fit SRAM. Otherwise, use the library and spend your time elsewhere.
✓ Knowledge check

A colleague benchmarks a LayerNorm and finds it slow, so they propose rewriting the mean/variance math with fewer operations. Why is this likely the wrong lever, and what is the right one?

Show answer
LayerNorm is a low-arithmetic-intensity elementwise op: it does a handful of FLOPs per element but reads and writes the whole activation tensor to HBM. It is memory-bound — the runtime is dominated by byte traffic, not the mean/variance arithmetic. Cutting FLOPs optimizes the part that isn't the bottleneck. The right lever is to fuse LayerNorm with its neighbours (or use a fused-LayerNorm kernel) so the tensor is read once and written once, cutting HBM trips. Roofline it first: below the ridge, cut bytes, not FLOPs.
✓ Knowledge check

You increase a Triton kernel's BLOCK size expecting it to go faster (more work per program), but it slows down. Give the most likely cause.

Show answer
A bigger block consumes more registers and shared memory per block, so the SM can keep fewer blocks/warps resident — occupancy drops. With too few resident warps, the SM can no longer hide HBM latency by switching to a ready warp while another stalls on memory, so the memory-bound kernel stalls. The fix is to pick the largest block that still keeps enough warps resident (often past a register-spill cliff, smaller is faster).

✓ Checkpoint — you can move on when you can…

  • Compute a kernel's arithmetic intensity and classify it against the roofline ridge point.
  • Explain why elementwise ops are memory-bound and why fusion is a data-movement win, not a math one.
  • Read a Triton kernel and map its load/compute/store to the SRAM/HBM hierarchy.
  • Reason about occupancy: how block size trades against resident warps and latency hiding.
  • Apply the checklist to decide when to hand-write a kernel versus use a library.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Classify an op: memory- or compute-boundBeginner

Context: Every kernel optimization starts by asking which wall you are hitting — bandwidth or ALUs. That answer decides whether you cut bytes or cut FLOPs.

Your task: Write a helper that takes an op's FLOPs and bytes moved and classifies it against a machine's FLOP-per-byte balance.

Requirements:

  • Arithmetic intensity = FLOPs / bytes_moved
  • Machine ratio (ridge) = peak_FLOP/s / HBM_bytes/s
  • Compute-bound when intensity exceeds the ridge, else memory-bound
  • Show a large matmul is compute-bound and an elementwise add is memory-bound

💡 Hint: Softmax, LayerNorm, and activations are the memory-bound ops fusion targets.

Show solution

Arithmetic intensity vs the machine's FLOP:byte ratio decides the wall. Runnable:

def bound(flops, bytes_moved, tflops=990.0, hbm_tb_s=3.35):
    intensity = flops / bytes_moved                 # FLOP per byte
    ridge = (tflops * 1e12) / (hbm_tb_s * 1e12)      # machine FLOP:byte balance
    kind = "compute-bound" if intensity > ridge else "memory-bound"
    return f"AI={intensity:8.1f}  ridge={ridge:6.1f}  -> {kind}"

print("4096-cube matmul", bound(flops=2*4096**3, bytes_moved=3*4096**2*4))
print("elementwise add ", bound(flops=4096**2,   bytes_moved=3*4096**2*4))
# matmul is compute-bound; the add is memory-bound (why fusion helps)

The matmul does thousands of FLOPs per byte and saturates the ALUs; the add does a fraction of a FLOP per byte and starves for bandwidth. Softmax and LayerNorm behave like the add — which is exactly what kernel fusion targets.

Exercise 2 · Roofline: attainable throughputIntermediate

Context: The roofline caps achievable FLOP/s at min(peak, intensity × bandwidth). Plotting an op against it shows how much performance you are leaving on the table.

Your task: Implement the roofline cap and report attainable TFLOP/s for a low- and a high-intensity op on a chosen GPU.

Requirements:

  • Attainable = min(peak_flops, intensity × bandwidth)
  • Report the ridge-point intensity (peak / bandwidth)
  • Show the low-intensity op is stuck far below peak
  • Show the high-intensity op is near peak

💡 Hint: Below the ridge you are bandwidth-limited; the attainable line is intensity times bandwidth.

Show solution

The roofline caps attainable throughput at min(peak, intensity×bandwidth). Runnable:

def attainable_tflops(intensity, peak_tflops=990.0, bw_tb_s=3.35):
    bw_bytes = bw_tb_s * 1e12
    mem_bound = intensity * bw_bytes / 1e12          # TFLOP/s allowed by bandwidth
    return min(peak_tflops, mem_bound)

ridge = (990.0 * 1e12) / (3.35e12)                   # intensity at the corner
print(f"ridge point intensity = {ridge:.1f} FLOP/byte")
for name, I in [("softmax (low AI)", 0.5), ("big GEMM (high AI)", 1365.0)]:
    a = attainable_tflops(I)
    print(f"{name:20}: attainable = {a:7.1f} TFLOP/s  ({a/990*100:.1f}% of peak)")
# low-AI op is stuck near 0% of peak; high-AI op saturates the ALUs

Below the ridge, attainable throughput is intensity × bandwidth — so a low-intensity op is pinned far below peak no matter how fast the ALUs are. Fusion's whole job is to raise intensity and slide the op rightward toward the compute roof.

Exercise 3 · Fusion traffic-reduction modelAdvanced

Context: For a memory-bound elementwise chain, fusion's speedup is roughly the traffic-reduction ratio: K ops that each read+write collapse into one read and one write.

Your task: Model the HBM traffic saved by fusing K elementwise ops into a single kernel, and express it as a percentage cut.

Requirements:

  • Unfused traffic = 2 × K × tensor_bytes
  • Fused traffic = 2 × tensor_bytes
  • Report unfused, fused, and the saving in GiB
  • Express the saving as a percentage traffic cut

💡 Hint: No new math — just fewer trips to HBM; the percentage cut is the expected speedup.

Show solution

Fusion collapses K read+write passes into one. Runnable:

def fusion_saving_gib(tensor_elems, k_ops, bytes_per=2):
    per_pass = tensor_elems * bytes_per
    unfused = 2 * k_ops * per_pass        # k ops x (read + write)
    fused   = 2 * per_pass                # one read + one write
    saved = (unfused - fused) / 1024**3
    return unfused/1024**3, fused/1024**3, saved

u, f, s = fusion_saving_gib(tensor_elems=8192*8192, k_ops=5)
print(f"unfused={u:.2f} GiB  fused={f:.2f} GiB  saved={s:.2f} GiB")
print(f"traffic cut: {s/u*100:.0f}%  (~{u/f:.0f}x for a bandwidth-bound chain)")

Fusing 5 memory-bound ops cuts HBM traffic ~80% — a ~5× speedup for a bandwidth-bound chain, with no change to the arithmetic. That is the entire point of fusion: fewer trips to HBM.

Exercise 4 · Occupancy from a register/SRAM budgetExpert

Context: A block that spills registers starves the SM of resident warps, and a memory-bound kernel then cannot hide HBM latency. Occupancy quantifies that.

Your task: Given per-SM register and shared-memory budgets and a per-block cost, compute how many blocks fit and the resulting occupancy across a sweep of block sizes.

Requirements:

  • Resident blocks = min(regs_per_sm // regs_per_block, sram_per_sm // sram_per_block)
  • Warps per block scale with block size (32 threads per warp)
  • Occupancy = resident warps / hardware max warps per SM
  • Report the largest block that stays at or above 50% occupancy

💡 Hint: The binding constraint is whichever of registers or SRAM runs out first.

Show solution

Occupancy is limited by whichever of registers/SRAM runs out first. Runnable:

import math

def occupancy(block, regs_per_thread=32, sram_per_block=8192,
              regs_per_sm=65536, sram_per_sm=101376, max_warps=64, max_blocks=32):
    warps_per_block = math.ceil(block / 32)
    by_regs = regs_per_sm // (block * regs_per_thread)
    by_sram = sram_per_sm // sram_per_block
    blocks = min(by_regs, by_sram, max_blocks)
    warps = blocks * warps_per_block
    return warps, warps / max_warps

best = None
for block in (64, 128, 256, 512, 1024):
    warps, occ = occupancy(block)
    tag = "  <-- register pressure" if occ < 0.5 else ""
    print(f"BLOCK={block:>4}: {warps:>2} warps -> {occ*100:4.0f}% occupancy{tag}")
    if occ >= 0.5:
        best = block
print(f"largest block at >=50% occupancy: {best}")

As the block grows, per-block register use climbs and the SM keeps fewer blocks resident, so occupancy falls. For a memory-bound kernel that needs resident warps to hide HBM latency, the sweet spot is the largest block that stays above the occupancy floor — not the biggest block you can compile.

Exercise 5 · Offline tile planner for a Triton softmaxProfessional

Context: Before touching Triton, the fused-softmax kernel is a tiling decision: pick a power-of-two BLOCK that fits SRAM, count tiles per row, and only then encode it as GPU code.

Your task: Write an offline planner that chooses the largest power-of-two BLOCK fitting in SRAM and the row, and reports tiles per row; keep the real Triton kernel labeled needs-GPU.

Requirements:

  • Max elements per tile = SRAM_bytes / bytes_per_element
  • BLOCK is the largest power of two ≤ both the SRAM limit and the row length
  • tiles per row = ceil(N / BLOCK)
  • Keep the Triton kernel as reference code labeled needs-GPU

💡 Hint: The kernel only encodes the tiling you decide here; the planner is pure arithmetic.

Show solution

Decide the tiling offline; the Triton kernel only encodes it. Runnable planner:

def tile_plan(n_cols, sram_kib=100, bytes_per=4):
    max_elems = sram_kib * 1024 // bytes_per
    block = 1
    while block * 2 <= min(max_elems, n_cols):
        block *= 2                        # largest power-of-two block that fits
    tiles = -(-n_cols // block)           # ceil division
    return block, tiles

block, tiles = tile_plan(n_cols=8192)
print(f"BLOCK={block}, tiles/row={tiles}")
# needs GPU + Triton -- the SAME tiling as a kernel (documented Triton API):
# import triton, triton.language as tl
# @triton.jit
# def softmax_row(X, Y, stride, n, BLOCK: tl.constexpr):
#     row = tl.program_id(0)
#     cols = tl.arange(0, BLOCK)
#     x = tl.load(X + row*stride + cols, mask=cols < n, other=-float("inf"))
#     x = x - tl.max(x, axis=0)
#     e = tl.exp(x); y = e / tl.sum(e, axis=0)
#     tl.store(Y + row*stride + cols, y, mask=cols < n)

The arithmetic that matters — how big a tile fits SRAM and how many tiles cover a row — is decided on a laptop. The kernel is a faithful transcription, which is why you can reason about the whole design without a GPU in front of you.

Exercise 6 · Kernel vs library: an optimization triageIndustry scenario

Context: A team hands you five slow ops and a deadline. Blindly hand-writing kernels burns weeks; the roofline plus a library-coverage check tells you which (if any) are worth it.

Your task: Write a triage function that, given each op's arithmetic intensity, whether a vendor library covers it, and whether it is on the hot path, recommends library / fuse / hand-write / leave-it.

Requirements:

  • Compute-bound with a vendor lib → use the library (won't beat it)
  • Memory-bound but covered by an auto-fuser (torch.compile) → let it fuse
  • Memory-bound, on hot path, no lib fuses this exact pattern → hand-write (Triton)
  • Not on the hot path → leave it and move on
  • Return a per-op recommendation with a one-line reason

💡 Hint: The decision is a small truth table over (bound, lib-covered, hot-path) — encode it directly.

Show solution

Triage is a small truth table over (bound, lib-covered, hot-path). Runnable:

def triage(name, intensity, ridge, lib_covers, auto_fuses, hot_path):
    memory_bound = intensity < ridge
    if not hot_path:
        return name, "LEAVE IT", "not on the hot path -- not worth the effort"
    if not memory_bound and lib_covers:
        return name, "USE LIBRARY", "compute-bound + vendor lib -- you won't beat cuBLAS/cuDNN"
    if memory_bound and auto_fuses:
        return name, "AUTO-FUSE", "memory-bound but torch.compile fuses this -- let it"
    if memory_bound and not lib_covers:
        return name, "HAND-WRITE", "memory-bound, hot, no lib fuses it -- Triton kernel"
    return name, "USE LIBRARY", "covered by a vendor library"

RIDGE = 295.0
ops = [
    ("dense GEMM",        1365.0, True,  False, True),
    ("layernorm chain",     0.6,  False, True,  True),
    ("novel fused mask",    0.4,  False, False, True),
    ("startup reshape",     0.2,  False, True,  False),
]
for name, ai, lib, fuse, hot in ops:
    n, rec, why = triage(name, ai, RIDGE, lib, fuse, hot)
    print(f"{n:18} -> {rec:12} ({why})")

Only the novel memory-bound op with no library coverage earns a hand-written kernel. The GEMM goes to cuBLAS, the standard chain to the auto-fuser, and the off-hot-path op is left alone. That triage is worth more than raw kernel skill: most of the wins are choosing not to write a kernel.

© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in