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.
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.dotblock 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.
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.
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.
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.
- AI below the ridge → memory-bound: the doorway is the limit; the cure is fewer bytes (fuse, tile, use lower precision).
- AI above the ridge → compute-bound: the ALUs are the limit; you're already near the roof and a vendor library probably owns this op.
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.
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.
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.
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 onewrite 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.
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
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.
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.
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. Themasknote 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.
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)
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.
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
BLOCKmeans 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.
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.
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 greenHAND-WRITEleaf — 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.
| Situation | Reach for |
|---|---|
| Dense matmul / conv | cuBLAS / cuDNN — already at the compute roof |
| Attention (any length) | FlashAttention / fused-attn library |
| Standard elementwise chain | torch.compile / fused kernels — auto-fused |
| Novel memory-bound fusion the libs miss | Write it (Triton first, CUDA if needed) |
| Compute-bound and vendor lib exists | Do not hand-write — you won't beat it |
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
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
✓ 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.
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.
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.
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.
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.
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.
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.