Attention & GPU kernels
Attention is famous for being O(n²) — but on a real GPU its bottleneck isn't the arithmetic, it's the memory. This lesson drops below the concept to the silicon: the GPU's SM/SRAM/HBM hierarchy, why naive attention is bandwidth-bound, and how FlashAttention — tiling plus online softmax — avoids ever writing the N×N score matrix to HBM. You'll model the byte traffic, prove online softmax equals batch softmax, and roofline a kernel — all offline in pure Python.
Learning objectives
- Hold a working mental model of a GPU: SMs, the HBM↔SRAM hierarchy, and bandwidth as the wall.
- Explain why naive attention is memory-bound, not compute-bound — the N×N matrix dominates.
- Describe FlashAttention: tiling + online softmax → O(N) memory and far fewer HBM round-trips.
- Reason with the roofline / arithmetic-intensity model to classify a kernel as memory- or compute-bound.
- Explain kernel fusion, and what Triton is (GPU kernels in Python) at a glance.
30-second recap
- Transformers: Attention lets every token look at every other token; stacking attention and feed-forward layers is the architecture behind every modern LLM.
- The KV-cache: During decoding the model stores each token's attention keys and values so it doesn't recompute them; that cache is what fills GPU memory.
1 · A mental model of the GPU essential
Start with a bill that shocks people the first time they see it. A team buys the biggest GPU they can afford — a card advertised at nearly a petaflop of matrix math — runs their long-context model on it, and profiles the hot loop. The GPU is 80% idle. Not broken, not misconfigured: idle. The arithmetic units are sitting there waiting, hands in their pockets, for data to arrive from memory. That gap between "how fast the chip can compute" and "how fast it can be fed" is the single most important thing to understand about modern accelerators, and it's why this section starts not with math but with a map of where data lives.
Here's the analogy that makes it click. Think of a chef (the compute units) working at a tiny cutting board (on-chip SRAM) next to a giant walk-in fridge down the hall (HBM). The chef is superhumanly fast with a knife — but every ingredient has to be walked from the fridge to the board and back. If the recipe makes the chef fetch one carrot, slice it, walk it back, fetch one onion, slice it, walk it back, the kitchen's throughput is set by the hallway, not the knife. A good kitchen loads a whole tray onto the board once and does all the chopping there. That is the entire discipline of GPU kernel design in one image: minimize trips down the hall.
Now the precise version. A GPU is not one big processor — it's a few dozen to ~130 Streaming Multiprocessors (SMs), each an independent cluster of arithmetic units with its own sliver of extremely fast on-chip memory. That fast memory comes in two tiers: registers (per-thread scratch, tens of KB) and SRAM / shared memory (a few hundred KB per SM). Surrounding the whole chip is HBM (High-Bandwidth Memory) — the gigabytes where your weights and activations actually live. HBM sounds fast, and in absolute terms it is (~2–3 TB/s), but it is roughly ten times slower than SRAM and it is physically far from the compute. Beyond HBM is host RAM over PCIe or NVLink, an order of magnitude slower again — a place you never want to visit on the hot path.
The common mistake that this model exists to kill: assuming "faster GPU" or "faster matmul library" automatically means "faster model." It often doesn't, because the bottleneck usually isn't the math — it's the bytes. Read the table below not as trivia but as a hierarchy of cost: every step down the list is bigger but slower and further away, and a fast kernel's whole job is to stay as high up this list as it possibly can.
| Level | Size (order of) | Speed (order of) | Role |
|---|---|---|---|
| Registers / SM | tens of KB | ~fastest | per-thread scratch |
| SRAM / shared mem (on-chip) | ~a few hundred KB per SM | ~10–20 TB/s | the tile you compute on |
| HBM (device memory) | 40–192 GB | ~2–3.35 TB/s | where weights & activations live |
| Host RAM (over PCIe/NVLink) | hundreds of GB | ~tens of GB/s | off-device; avoid on the hot path |
Two facts drive everything below. First, SRAM is tiny but ~10× the bandwidth of HBM and sits right next to the compute. Second, every trip to HBM is expensive — modern GPUs can do far more arithmetic per second than they can feed themselves data. So the game a kernel plays is: get data into SRAM once, do as much math on it as possible, and touch HBM as little as you can.
This is the machine every later idea rests on. Read it left→right as a memory hierarchy: compute happens on the left, and each box to the right is bigger but slower and further away.
- SMs (compute) — the Streaming Multiprocessors that actually do the arithmetic. They can only work on data that's already close to them.
- SRAM (on-chip) — tiny (a few hundred KB per SM) but extremely fast (~10–20 TB/s). This is the scratchpad a kernel computes out of; the whole trick of a fast kernel is to keep data here.
- HBM (device) — gigabytes of GPU memory at ~2–3 TB/s. Big, but reaching it is the slow, expensive step. Every arrow crossed toward the right costs time.
- Host RAM — off the GPU entirely, over PCIe/NVLink at tens of GB/s. You never want to touch this on the hot path.
In short: Speed drops and size grows as you move right. A fast kernel drags data left into SRAM once and does all its math there, instead of repeatedly reaching right into slow HBM.
2 · Naive attention, byte by byte essential
Here's a mystery you may have hit yourself. You learned that attention is O(n²), so you reasonably assume the way to make it faster is to make the arithmetic faster — swap in a snappier matmul, use tensor cores harder, tune the GEMM. You do all that, and the kernel barely moves. Meanwhile someone hands you "FlashAttention," which does the exact same math, and it's several times faster on long sequences. If the FLOPs are identical, where did the speed come from? The answer is the whole point of this section: naive attention doesn't spend its time computing — it spends its time moving bytes.
To see why, follow the intermediate results, not the formulas. Attention is three steps: compute scores S = QKᵀ, turn them into probabilities P = softmax(S), then weight the values O = P·V. The trap is that a textbook implementation runs each step as a separate kernel launch, and a separate launch can only communicate with the next one through HBM — the slow fridge down the hall. So the giant S matrix gets written all the way out to HBM, then read all the way back for softmax, written again, and read a third time for the final matmul. The compute in each step is quick; the walking-down-the-hall between steps is where the clock burns.
And that intermediate is genuinely giant. S is N×N — it has one entry for every pair of tokens. At N=4096 that's ~16.7 million numbers, tens of megabytes at fp16, and it gets round-tripped through the slowest memory on the chip several times. Crucially, its size grows with the square of the sequence length: double the context and you quadruple this traffic. The actual math done per byte, by contrast, is small — each score is one dot product, each softmax entry one exponential. That lopsided ratio (few FLOPs, many bytes) has a name we'll formalize in §6: low arithmetic intensity, and it is the signature of a memory-bound kernel.
The reframe to carry out of this section: attention's cost, on real silicon, is not the O(n²) multiply-adds — it's the O(n²) bytes of the score matrix crossing the HBM boundary again and again. Once you believe that, the fix becomes obvious in shape even before you see it: stop writing the N×N matrix to HBM at all. The diagram traces exactly which trips cost you.
This shows naive attention and exactly why it's slow. Follow the arrows: each box is a separate step, and the two amber boxes in the middle are the problem.
- Q,K,V — the inputs, read from HBM once. So far so cheap.
- S = QKᵀ (N×N in HBM) — the scores form a big N×N matrix that gets written out to HBM. For a 4096-token sequence that's a 4096×4096 array — tens of MB.
- softmax(S) (N×N in HBM) — a second kernel reads that matrix back, softmaxes it, and writes it again. More slow HBM traffic on the same giant intermediate.
- O = P·V — finally the probabilities are read yet again to produce the output.
In short: The N×N matrix is written and read from HBM several times. That round-tripping — not the math — is what makes naive attention memory-bound. FlashAttention (next diagram) never writes it.
3 · Lab · an HBM-traffic estimator intermediate
Claims about performance are cheap; a number you computed yourself is not. We just argued that naive attention is slow because of bytes, not math — but "slow because of bytes" is the kind of sentence you can nod along to and still not truly believe. So in this lab we stop hand-waving and count. No GPU, no benchmark harness, no timing noise — just arithmetic on how many bytes each approach shoves across the HBM boundary. When you see the ratio grow from 17× to 257× as the sequence lengthens, the phrase "memory-bound" stops being jargon and becomes something you can defend in a design review.
The mental model for reading the estimator is a ledger with two columns. Both approaches pay the same unavoidable cost: read Q, K and V in once, and write the output out once — that's the O(N·d) traffic, linear in sequence length. The difference is entirely in the middle. The naive path adds two more line items — write-then-read the N×N scores, write-then-read the N×N probabilities — and because those terms are N×N they scale as O(N²). FlashAttention simply has no such line items: the N×N matrix is computed inside SRAM, tile by tile, and never lands in HBM at all. So the model is really "linear traffic that both pay" plus "quadratic traffic that only the naive path pays."
Why model bytes instead of timing the code? Because for a memory-bound kernel, time is basically bytes ÷ bandwidth — the arithmetic hides under the memory transfers, so bytes moved is a faithful proxy for wall-clock time, and it's deterministic (no warm-up, no thermal throttling, no scheduler jitter to average out). That is the quiet superpower of these offline labs: they let you reason about GPU performance on a laptop, because the thing that dominates the runtime is countable without the hardware. Read the code as a byte ledger, then watch the ratio in the output explode with N.
Read it as two side-by-side ledgers — naive on the left, FlashAttention on the right — and compare row for row.
- The top green row is identical on both sides: read Q, K, V once and write the output once — the unavoidable O(N·d) traffic that is linear in sequence length.
- The two red rows on the left are the naive path's extra line items — writing then reading the N×N score matrix and the N×N probability matrix. Because they're N×N they scale as O(N²), so they dominate the bill as N grows.
- On the right those two items are gone: the dashed box says the N×N matrices are computed in SRAM tile by tile and never touch HBM, so the flash total stays at O(N·d) — the payoff.
In short: Both paths pay the linear line; only naive also pays the quadratic one — subtract the two red rows and you get FlashAttention's traffic, and roughly its speedup.
hbm_traffic.pydef bytes_moved(seq_len, d_head, dtype_bytes=2, tile=128):
N, d = seq_len, d_head
# Naive: read Q,K,V once, WRITE the N*N scores to HBM, READ them back for
# softmax, WRITE softmax probs, READ them for the V-matmul, WRITE output.
qkv = 3 * N * d * dtype_bytes
scores_rw = 2 * (N * N * dtype_bytes) # write + read scores
probs_rw = 2 * (N * N * dtype_bytes) # write + read softmax probs
out_w = N * d * dtype_bytes
naive = qkv + scores_rw + probs_rw + out_w
# FlashAttention: tile K/V; Q,K,V each streamed once, output written once.
# The N*N matrix is NEVER written to HBM -- it lives in on-chip SRAM per tile.
flash = 3 * N * d * dtype_bytes + N * d * dtype_bytes
return naive, flash
for N in (1024, 4096, 16384):
naive, flash = bytes_moved(N, d_head=64)
ratio = naive / flash
print(f"N={N:>6}: naive={naive/1e6:8.2f} MB flash={flash/1e6:6.2f} MB "
f"naive/flash={ratio:5.1f}x")
N= 1024: naive= 8.91 MB flash= 0.52 MB naive/flash= 17.0x
N= 4096: naive= 136.31 MB flash= 2.10 MB naive/flash= 65.0x
N= 16384: naive= 2155.87 MB flash= 8.39 MB naive/flash=257.0x
This lab turns "memory-bound" into a number: it counts the bytes moved through HBM for the naive path versus FlashAttention, at three sequence lengths. Nothing here touches a GPU — it's just bookkeeping of how many bytes each approach reads and writes.
qkvis the cost both paths share: read Q, K and V once (3 * N * delements,dtype_byteseach — here 2 bytes for fp16/bf16).- The naive path adds
scores_rwandprobs_rw: it writes then reads the N×N score matrix, and again for the softmax probabilities. That's the2 * (N * N * dtype_bytes)terms — and because they're N×N, they grow with the square of the sequence length. - The
flashline has no N×N term at all — only the O(N·d) inputs and output. The N×N matrix is computed in SRAM per tile and never written to HBM. - The loop prints both totals in megabytes and their ratio for N = 1024, 4096, 16384.
What the output means: Naive traffic explodes with N (17× → 65× → 257× more bytes than flash by 16K tokens), because its cost is O(N²) while flash is O(N·d). Since attention is memory-bound, that byte ratio is roughly the speed ratio.
Try this: Change d_head to 128, or add N=65536 to the loop, and watch the naive/flash gap widen further — the square term dominates everything at long context.
The gap widens with N because naive traffic grows as O(N²) (the score/prob matrices) while flash traffic grows as O(N·d). At a 16K context the naive path moves ~250× more bytes — and since attention is memory-bound, roughly that much more time on HBM.
4 · FlashAttention: tile + online softmax advanced
Everything so far has been diagnosis; this is the cure. We proved naive attention wastes its life writing and re-reading a giant N×N matrix through slow HBM. The obvious wish is: what if we just… didn't? What if the score matrix never had to exist in HBM at all? FlashAttention is the clean answer to exactly that wish, and the beautiful part is that it is not an approximation — it computes bit-for-bit the same attention, just without ever parking the big intermediate in slow memory. It won a place in essentially every serious inference stack for one reason: on modern hardware, not touching HBM is the speedup.
The core idea is tiling, and the everyday analogy is grading a stack of exams too tall to fit on your desk. You don't need all the papers spread out at once to compute the class average — you take a manageable stack (a tile), work through it, keep a running total and a running count, set it aside, and grab the next stack. At the end your running numbers give the exact average as if every paper had been laid out simultaneously. FlashAttention does the same with attention: load a block of queries and a block of keys/values into SRAM, compute that block's partial scores on chip, fold the result into a running output, discard the block, and move on. The N×N scores are still computed — but one small tile at a time, in fast on-chip memory, and never written to HBM. Only the O(N·d) inputs and output ever touch device memory, so peak memory drops from O(N²) to O(N) and the HBM round-trips collapse.
But tiling hides a real danger, and it's worth naming because it's the thing that makes FlashAttention non-trivial. Softmax is not a per-element operation you can just chop up — it needs the whole row, because it subtracts the row's maximum (for numerical stability) and divides by the sum of all the exponentials. If you naively softmax each tile in isolation, you get the wrong answer: each tile normalized against its own local max and sum, not the global ones. So the question becomes: how do you compute a row-wide softmax when you're only ever holding one slice of the row at a time? That is the puzzle online softmax solves, and it's the mathematical heart of the whole method — introduced just below and proven exact in the next lab.
This is the fix. Same inputs and output as the naive diagram, but the middle is completely different: everything expensive now happens on-chip in SRAM, and the N×N matrix is never written to HBM.
- Q,K,V tiles — instead of whole matrices, the data is loaded in small blocks (tiles), streamed through once.
- tile in SRAM — each tile's partial scores are computed in the fast on-chip scratchpad, not written out to slow HBM.
- online softmax — the running-state trick (a running max
m, denominatorl, and accumulatoracc) lets the model softmax across tiles without ever holding the full row. The next lab proves this equals the normal softmax exactly. - O (streamed out) — only the final output is written to HBM, once.
In short: Compare box-for-box with the naive diagram: the two amber "N×N in HBM" boxes are gone, replaced by on-chip work. Fewer HBM trips = the FlashAttention speedup.
The trick that makes tiling correct is online softmax. Softmax normally needs the whole row (to subtract the max and divide by the sum). Online softmax keeps a running max m, a running denominator l, and a running weighted-value accumulator — and rescales the accumulators whenever a new tile reveals a larger max. The final result is bit-for-bit the same as computing softmax over the full row at once. The next lab proves that.
5 · Lab · online softmax equals batch softmax advanced
This is the lab where you stop trusting and start verifying. A skeptic reading the last section has every right to push back: "You're telling me I can process softmax in blocks, never see the full row, and somehow get the identical answer — not close, identical? Prove it." Fair. Because if online softmax were even slightly off, tiling would silently corrupt every attention output on long sequences, and no one would trust FlashAttention with a production model. So we don't ask you to take it on faith — we compute the same thing two ways and check they match to the last decimal.
Here's the trick, in plain terms before you meet the code. The whole difficulty is that a later tile might contain a bigger score than anything seen so far — and softmax's stability step subtracts the maximum, so a new maximum means everything computed earlier was scaled against a stale, too-small max. Online softmax handles this with three running numbers: m, the largest score seen so far; l, the running sum of exponentials; and acc, the running weighted sum of values. When a new tile reveals a bigger max m_new, it multiplies the old l and acc by a correction factor exp(m_old − m_new). That factor retroactively re-bases all the earlier work as if the new max had been used from the very beginning. It's the exam-grading analogy made exact: when you discover a paper scored higher than you assumed, you rescale your running total rather than starting over.
Why does that rescaling produce an exactly equal result rather than a good approximation? Because it's pure algebra, not estimation. Softmax is invariant to subtracting any constant from every score — softmax(s) = softmax(s − c) for any c — so which max you subtract can never change the final probabilities; it only changes intermediate magnitudes to keep them from overflowing. The correction factor is precisely the constant that converts "scaled by m_old" into "scaled by m_new," so the streaming path reconstructs the same normalization the batch path computes in one shot. The lab below runs both and prints their difference; watch for a difference of exactly 0.00e+00 — that zero is your license to tile.
m,l,acc; when a tile (amber) carries a bigger max, the old accumulators are rescaled by exp(m_old − m_new) — so the final acc/l equals the one-shot batch softmax to the last decimal.
Follow the tiles left→right along the top, watch them fold into the running state in the middle, and land on the exact answer at the bottom.
- The top row is one score row chopped into tiles — the algorithm only ever holds one at a time, never the whole row.
- The arrows feed each tile into the running state box:
mthe largest score seen so far,lthe running sum of exponentials, andaccthe running weighted sum of values. - The amber tile is the danger case — it carries a bigger max than anything before it, so the earlier
landaccwere scaled against a stale max and get corrected byexp(m_old − m_new)(the amber note). - The green bottom box is the payoff:
acc / lcomes out equal to the batch softmax·V — a difference of exactly0.00e+00.
In short: Stream tiles, keep three running numbers, rescale when a new max appears — and you reconstruct the exact one-shot softmax without ever materializing the full row.
online_softmax.pyimport math
def batch_softmax_weighted_sum(scores, values):
"""The 'materialize everything' path: full softmax over all scores, then dot V."""
m = max(scores)
exps = [math.exp(s - m) for s in scores]
Z = sum(exps)
probs = [e / Z for e in exps]
return sum(p * v for p, v in zip(probs, values))
def online_softmax_weighted_sum(scores, values, tile=4):
"""FlashAttention's trick: stream tiles, keep a running max m, running
denominator l, and running output acc -- rescaling on the fly. Never stores
the full score row."""
m = -math.inf # running max
l = 0.0 # running sum of exp(scores - m)
acc = 0.0 # running weighted sum of values
for i in range(0, len(scores), tile):
s_tile = scores[i:i+tile]
v_tile = values[i:i+tile]
m_new = max(m, max(s_tile))
# rescale the old accumulators to the new max
correction = math.exp(m - m_new) if m != -math.inf else 0.0
l = l * correction
acc = acc * correction
for s, v in zip(s_tile, v_tile):
p = math.exp(s - m_new)
l += p
acc += p * v
m = m_new
return acc / l
scores = [3.0, 1.0, 2.5, 0.2, 4.1, -1.0, 2.2, 0.7, 3.3, 1.9]
values = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
batch = batch_softmax_weighted_sum(scores, values)
online = online_softmax_weighted_sum(scores, values, tile=4)
print(f"batch softmax\u00b7V = {batch:.10f}")
print(f"online softmax\u00b7V = {online:.10f}")
print(f"max abs diff = {abs(batch - online):.2e}")
print("MATCH" if abs(batch - online) < 1e-9 else "MISMATCH")
batch softmax·V = 53.6863940430
online softmax·V = 53.6863940430
max abs diff = 0.00e+00
MATCH
This is the mathematical heart of FlashAttention, small enough to run on a laptop. It computes a softmax-weighted sum of values two ways and checks they're identical — proving that processing attention tile-by-tile is safe.
batch_softmax_weighted_sumis the "materialize everything" reference: subtract the max for stability, exponentiate the whole score row, normalize, and dot with the values.online_softmax_weighted_sumprocesses the scores in tiles, carrying three running numbers: the maxm, the denominatorl, and the outputacc.- The key line is
correction = exp(m - m_new): when a new tile has a larger max, the earlier accumulators were scaled by the wrong max, so they're rescaled by this factor — re-basing them as if the new max had been used all along. - It prints both results and their absolute difference, then
MATCHif they agree to 1e-9.
What the output means: MATCH with a difference of exactly 0.00e+00 — the streaming/tiled softmax gives the same answer as the batch softmax. That equality is the license to tile, and it's the same running-state update the Triton kernel uses.
Try this: Set tile=1 then tile=len(scores) and confirm the answer never changes. Then delete the correction rescaling and re-run — the result diverges, showing exactly what the max-tracking buys you.
Exactly equal. That equality is the license to tile: because the streaming update reconstructs the same normalization, FlashAttention can process attention block-by-block in SRAM and skip the N×N HBM matrix entirely, with no loss of numerical fidelity.
6 · Kernel fusion & the roofline model expert
By now you might over-learn the lesson and reach for FlashAttention-style tricks everywhere. That's a real failure mode: an engineer internalizes "fewer HBM trips = faster," starts fusing and tiling every kernel in sight, and burns a week making a compute-bound kernel move less memory — for zero speedup, because memory was never its bottleneck. FlashAttention worked because attention is memory-bound. The skill this section builds is knowing, before you optimize, whether a given kernel is limited by bandwidth or by arithmetic — so you turn the knob that actually moves the clock.
First, generalize the FlashAttention insight into its real name: kernel fusion. Every separate kernel launch has a fixed tax — it reads its inputs from HBM and writes its outputs back. If you run five elementwise ops in a row as five kernels, you pay that read-write tax five times on the same tensor. Fuse them into one kernel and the intermediate values stay in registers/SRAM between steps; you read once and write once. FlashAttention is just the marquee example of this: fusing the score matmul, the softmax, and the value matmul into a single kernel so the N×N intermediate never round-trips. The mental takeaway: fusion is almost always a memory-traffic optimization, not a compute one — it removes trips to the fridge, it doesn't make the chef chop faster.
Now the tool that tells you when fusion (or any bandwidth trick) will help: the roofline model. Picture a rooftop profile. Along the bottom axis is a kernel's arithmetic intensity — FLOPs performed per byte moved from HBM. Low intensity means you do very little math per byte you fetch (you starve for data); high intensity means you reuse each fetched byte for lots of math. The roof has two parts: a slanted section on the left (bandwidth limits you — you're memory-bound) and a flat ceiling on the right (the ALUs limit you — you're compute-bound). Where they meet is the ridge point, and it's a pure property of the machine: peak_FLOPs ÷ bandwidth. Plot your kernel's intensity: fall left of the ridge and your fix is move fewer bytes (fusion, FlashAttention); fall right of it and your fix is do less math or use lower precision (quantization). The lab computes exactly this classification for two very different kernels.
The roofline model tells you when that matters. Plot a kernel by its arithmetic intensity (FLOPs performed per byte moved from HBM). The machine has a ridge point = peak-FLOPs ÷ bandwidth. Below the ridge you're memory-bound (bandwidth caps you; move fewer bytes); above it you're compute-bound (the ALUs cap you; do less math or use lower precision).
Read it left→right along the bottom axis (a kernel's arithmetic intensity) and note where its dot lands relative to the vertical ridge line.
- The horizontal axis is arithmetic intensity — FLOPs done per byte fetched from HBM; the vertical axis is the FLOP/s you can actually reach.
- The slanted grey roof on the left is the bandwidth limit: low-intensity kernels can't go faster than the memory can feed them. The flat grey ceiling on the right is the compute limit — the ALUs' peak.
- The dashed ridge line where they meet is
peak_FLOPs ÷ bandwidth, a pure property of the machine. The red dot (attention, AI≈64) sits far left → memory-bound; the green dot (big GEMM, AI≈1365) sits right → compute-bound.
In short: Which side of the ridge your kernel lands on decides the fix — left means cut bytes (fusion, FlashAttention), right means cut FLOPs or precision.
roofline.pydef roofline(flops, bytes_moved, peak_flops, bw_bytes_per_s):
"""Arithmetic intensity = FLOPs per byte moved. Compare to the machine's
ridge point (peak_flops / bandwidth). Below the ridge => memory-bound."""
ai = flops / bytes_moved # FLOP/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 = 990e12
BW = 3.35e12
for name, flops, byts in [
("naive attention row", 2 * 4096 * 64, 4096 * 2), # ~memory-bound
("big fused GEMM", 2 * 4096**3, 3 * 4096**2 * 2),
]:
ai, ridge, att, bound = roofline(flops, byts, PEAK, BW)
print(f"{name:22} AI={ai:8.1f} FLOP/B ridge={ridge:6.1f} -> {bound}")
print(f"{'':22} attainable={att/1e12:7.1f} TFLOP/s")
naive attention row AI= 64.0 FLOP/B ridge= 295.5 -> memory-bound
attainable= 214.4 TFLOP/s
big fused GEMM AI= 1365.3 FLOP/B ridge= 295.5 -> compute-bound
attainable= 990.0 TFLOP/s
This lab is the decision tool: given a kernel's arithmetic and its byte traffic, it tells you whether you're limited by memory bandwidth or by compute — which decides which optimization will actually help.
- Arithmetic intensity
ai = flops / bytes_movedis FLOPs performed per byte pulled from HBM. Low intensity = you do little math per byte = you starve for data. - The ridge point
ridge = peak_flops / bandwidthis the machine's break-even intensity. Here it uses an H100-class GPU: ~990 TFLOP/s and ~3.35 TB/s of HBM bandwidth. - If a kernel's intensity is below the ridge it's memory-bound (bandwidth caps you); above it, compute-bound (the ALUs cap you).
attainableis the best FLOP/s you can actually reach given that intensity. - It classifies a low-intensity attention score row against a large, high-intensity dense GEMM.
What the output means: The attention row is memory-bound (AI≈64, below the ridge of ~295) and can only reach ~214 of 990 TFLOP/s; the big GEMM is compute-bound and saturates the ALUs. Below the ridge, cut bytes moved (fusion, FlashAttention); above it, cut FLOPs or precision.
Try this: Plug in a different GPU's peak FLOP/s and bandwidth, recompute the ridge, and see how the same kernel can flip from memory- to compute-bound on different hardware.
The low-intensity attention step sits well below the ridge — it can only reach ~214 of 990 TFLOP/s because it starves for bandwidth. The big GEMM is above the ridge and saturates the ALUs. Roofline is how you decide which knob to turn: for the memory-bound case the answer is fusion + fewer HBM trips (exactly FlashAttention); for the compute-bound case it's lower precision or fewer FLOPs.
7 · Triton at a glance expert
There's a moment in every systems engineer's life where the abstraction runs out. You've profiled, you've rooflined, you've concluded the win is a fused kernel that keeps the intermediate in SRAM — and now you have to actually write it. Historically that meant CUDA C++: manual thread indexing, shared-memory bank conflicts, warp-level shuffles, memory coalescing rules. Weeks of work and a skill set most ML engineers never acquire. That barrier is exactly why for a decade "just write a custom kernel" was advice, not a plan. Triton is the tool that lowered the barrier enough that this section can exist at all.
Triton (from OpenAI) lets you write GPU kernels in Python. The key shift is the unit of thought: instead of reasoning one scalar thread at a time — the CUDA mental model — you write the computation over blocks (tiles) of data, and Triton's compiler handles the fiddly parts underneath: assigning work to SMs, allocating SRAM, coalescing memory accesses, managing the warps. You say "load this tile, do this matmul on it, keep this running state, store the result," and Triton turns that block-level description into a well-scheduled GPU kernel. That's why much of the modern fused-kernel ecosystem — including production FlashAttention variants — is now authored in Triton: it gives you Python-level control with performance close to hand-tuned CUDA.
Here's the payoff that ties this whole lesson together, and the reason the snippet below is here to read rather than run. Look closely and you'll see the kernel carries the exact same m, l, acc running-state update you proved correct on your laptop in §5 — the online-softmax rescale, now expressed in Triton's block primitives. That's the deep lesson of the offline labs: the algorithm's correctness is separable from the silicon. You verified the math where it's cheap and reproducible (pure Python, no GPU), and the GPU kernel is a faithful transcription of that verified math into hardware-aware blocks. So you don't need a datacenter to understand — or even to trust — the kernel; you need it only to run it fast. Read the shape below and recognize the running state; that recognition is the whole point.
The snippet below is real GPU code and is here to read, not to run in this course's offline environment. It sketches the shape of a Triton attention kernel: grab a tile, loop over key/value blocks, keep the online-softmax running state you just proved correct, and write the output once.
Read the top row left→right: what you write becomes what runs, with the compiler in between doing the hard part.
- The left box is your code — Python with Triton, written at the level of tiles: "load this tile, do this dot, keep the running
m,l,acc, store the result." - The middle box is the Triton compiler, which lowers that block description into a real kernel — assigning work to SMs, allocating SRAM, coalescing memory accesses, managing warps.
- The green right box is the output: a fused, tiled GPU kernel that runs fast on the SMs.
- The dashed red box below is the old path — writing all of that per scalar thread in CUDA C++ by hand — which Triton lets you skip; the unit of thought shifts from one thread to one tile.
In short: You reason in tiles and Python; Triton turns that into the scheduled, coalesced GPU kernel that CUDA C++ used to make you write thread by thread.
triton_flash.py# >>> NEEDS A GPU + Triton/PyTorch. Does NOT run in this offline lesson. <<<
# Illustrative shape of a fused, tiled attention kernel (the FlashAttention idea).
# pip install triton torch # and a CUDA GPU
import triton
import triton.language as tl
@triton.jit
def flash_attn_kernel(Q, K, V, Out, N, d, BLOCK: tl.constexpr):
pid = tl.program_id(0) # this program handles one Q tile
q = tl.load(Q + pid * BLOCK) # load a query tile into SRAM
m = tl.full((BLOCK,), -float("inf"), tl.float32) # running max
l = tl.zeros((BLOCK,), tl.float32) # running denominator
acc = tl.zeros((BLOCK, d), tl.float32) # running output
for start in range(0, N, BLOCK): # stream K/V tiles through SRAM
k = tl.load(K + start)
v = tl.load(V + start)
s = tl.dot(q, tl.trans(k)) # partial scores, on chip
m_new = tl.maximum(m, tl.max(s, axis=1))
p = tl.exp(s - m_new[:, None]) # online-softmax rescale...
corr = tl.exp(m - m_new)
l = l * corr + tl.sum(p, axis=1)
acc = acc * corr[:, None] + tl.dot(p, v)
m = m_new
tl.store(Out + pid * BLOCK, acc / l[:, None]) # write output once
m, l, acc update from the offline online-softmax lab — the offline demo is the kernel's math, minus the silicon. That's the point of the offline labs: you can verify the algorithm's correctness on a laptop, then trust the GPU version.8 · Tech-lead — reasoning about the memory wall tech-lead
Picture the meeting where this knowledge earns its keep. An engineer proposes buying a fleet of faster GPUs to cut inference latency; another wants to spend a sprint hand-optimizing a matmul; product is asking whether you can double the context window without doubling the cost. Everyone's looking at you. You don't need to have written a single Triton kernel to answer well — you need the one mental model this lesson has been building: on modern accelerators, performance is a data-movement problem first and an arithmetic problem second. That single reframe turns most of these debates into a quick calculation instead of an argument.
Watch how the model resolves each question cleanly. "Faster GPUs will fix latency" — only if the kernel is compute-bound; roofline it first, because if it's memory-bound you're paying for FLOPs you can't use. "Let's hand-optimize the matmul" — for a memory-bound step like attention's softmax, that optimizes the part that isn't the bottleneck; the real lever is fusion and fewer HBM trips. "Can we double the context?" — with FlashAttention, attention itself is O(N) in memory, so the score matrix no longer explodes; the true limit becomes the KV-cache (which grows linearly with context and batch), tying this straight into the serving math of IC3. Notice that in every case the answer flows from the same source: where do the bytes go, and how many times?
The trap to help your team avoid is treating "it's O(n²)" as the end of the analysis rather than the start. Big-O counts operations; it says nothing about which memory tier those operations touch, and on a GPU that tier is the performance. A tech lead's job here is less about writing the fast kernel and more about asking the right diagnostic question before anyone optimizes — roofline it, find the bound, then spend effort only on the side of the ridge you're actually on. Adopt a fused-attention library for long context (the HBM savings are almost always the win); reach for lower precision when you're genuinely compute-bound; and always connect the kernel-level story back up to the system-level cost. The rest of this section distills that into a single sentence worth memorizing.
Read it top→bottom as a decision tree: one question at the top, two branches, and the right optimization at the leaves.
- The top box is the diagnostic every debate should start with — roofline the kernel to find whether it's limited by bandwidth or by arithmetic.
- The left (red) branch is memory-bound — left of the ridge; its fix (blue box below) is fewer HBM trips: kernel fusion and FlashAttention.
- The right (green) branch is compute-bound — right of the ridge; its fix is fewer FLOPs: lower precision or quantization. The same source question decides both.
- The amber strip at the bottom is the system-level tie-in: once attention is O(N) in memory, doubling context makes the KV-cache the next wall — which connects straight to IC3's serving math.
In short: Diagnose before you optimize — roofline, branch on the bound, turn the matching knob, and remember long context ultimately meets the KV-cache limit.
A colleague proposes speeding up attention by using a faster matrix-multiply library. On a long sequence, why is this unlikely to help much — and what would?
Show answer
Online softmax keeps a running max m and rescales its accumulators whenever a new tile has a larger value. Why is the max-tracking necessary, and why does rescaling keep the result exact?
Show answer
exp(large) overflows). When streaming tiles, a later tile can contain a larger value than any seen so far, so the earlier exp(s - m_old) terms were scaled by the wrong max. Multiplying the running denominator l and accumulator acc by exp(m_old - m_new) retroactively re-bases them to the new max — algebraically identical to having used m_new all along. That's why the offline lab shows a zero difference from batch softmax.Exercise MS3.1 — Roofline a real kernel
Context: A roofline turns 'is this kernel slow?' into a decision: locate the machine's ridge point, then place each kernel's arithmetic intensity on either side of it.
Your task: Pick a real GPU, compute its ridge point from peak FLOP/s and HBM bandwidth, then estimate the arithmetic intensity of an attention score row and a large feed-forward GEMM and classify each.
Requirements:
- Ridge point =
peak_FLOP/s / HBM_bytes/s - Estimate intensity for (a) an attention score row and (b) a large FFN GEMM
- Classify each as memory- or compute-bound against the ridge
- State the right optimization for each: fewer HBM trips vs lower precision/FLOPs
💡 Hint: The score row lands well below the ridge (memory-bound); the big GEMM lands above it (compute-bound), and that dictates the fix.
Exercise MS3.2 — Prove tiling is safe, then break it
Context: Tiled attention only earns the right to skip the full row because the rescale step makes it exact — so the way to trust it is to prove invariance, then break it on purpose.
Your task: Confirm online softmax matches batch softmax, verify the answer is unchanged for tile sizes of 1 and of the full length, then remove the max-correction rescale and watch it diverge.
Requirements:
- Show the zero (or ~1e-9) diff between online and batch softmax
- Vary the tile size to 1 and to
len(scores)and confirm invariance - Remove
correction = exp(m − m_new)and observe divergence - Explain in one sentence what the rescale buys, referencing the running max
💡 Hint: Block size must not change the result; the rescale is what keeps the running denominator consistent when a larger max arrives.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: A GPU has fast compute but slower memory, so whether an op is limited by FLOPs or by bytes moved comes down to a single ratio: its arithmetic intensity versus the machine's FLOP-per-byte balance.
Your task: Write a helper that, given an op's FLOPs and bytes moved, classifies it as compute-bound or memory-bound by comparing arithmetic intensity to the machine ratio.
Requirements:
- Arithmetic intensity =
FLOPs / bytes_moved - Machine ratio =
peak_FLOP/s / HBM_bytes/s - Compute-bound when intensity exceeds the machine ratio, else memory-bound
- Show a large matmul is compute-bound while an elementwise op is memory-bound
💡 Hint: Softmax and masking are memory-bound elementwise ops — exactly what FlashAttention targets.
Show solution
Arithmetic intensity = FLOPs / bytes. Compare it to the machine's FLOP:byte ratio. Runnable:
def bound(flops, bytes_moved, tflops=312.0, hbm_gib_s=2000.0):
intensity = flops / bytes_moved # FLOPs per byte
machine_ratio = (tflops * 1e12) / (hbm_gib_s * 1024**3)
kind = "compute-bound" if intensity > machine_ratio else "memory-bound"
return f"intensity={intensity:6.1f} machine={machine_ratio:5.1f} -> {kind}"
print("big matmul ", bound(flops=2e11, bytes_moved=6e8))
print("elementwise add", bound(flops=1e8, bytes_moved=8e8))
# matmul is compute-bound; elementwise is memory-bound (why fusion helps)
Attention's softmax and masking are memory-bound elementwise ops — which is exactly what FlashAttention attacks.
Context: Naive attention materializes the full N×N score matrix in HBM and reads it back, so its memory traffic grows as O(N²) — the wall that makes long-context attention slow.
Your task: Reproduce the lesson's estimator for naive-attention HBM traffic (reads + writes of the N×N matrix) and show it quadruples when N doubles.
Requirements:
- Dominant traffic is the N×N score matrix, written once and read back
- Model it as a few passes over
N × N × bytes - Tabulate for growing N and show the O(N²) growth
- Conclude the score matrix never needed to touch HBM at all
💡 Hint: The quadratic term is the whole point; FlashAttention's win is keeping that matrix off HBM.
Show solution
The N×N score matrix is the dominant traffic: written once, read at least once. Runnable:
def naive_hbm_gib(N, bytes_per=2, passes=3):
# QK^T write, softmax read+write, times bytes -- ~passes over the NxN matrix
matrix_bytes = N * N * bytes_per
return passes * matrix_bytes / (1024 ** 3)
for N in (1024, 4096, 8192, 16384):
print(f"N={N:>5}: naive attention HBM traffic ~= {naive_hbm_gib(N):7.2f} GiB")
# quadruples when N doubles -- O(N^2) memory traffic is the wall
Because traffic is O(N²), long-context naive attention is memory-bound and slow — the score matrix never needed to touch HBM at all.
Context: FlashAttention is exact, not an approximation: it streams tiles with a running max and running denominator, rescaling whenever a larger max appears, and lands on the same answer as one-shot softmax.
Your task: Prove numerically that an online (streaming, tiled) softmax-weighted sum equals the batch softmax over the same scores and values.
Requirements:
- Batch version subtracts the global max, exponentiates, normalizes, weights values
- Online version keeps a running max
mand running denoml - On a new tile max, rescale the accumulators by
exp(m − m_new) - Assert the two results match to within ~1e-9 across several tile sizes
💡 Hint: The running-max rescale is what keeps the streaming result numerically identical to the one-shot answer.
Show solution
Online softmax keeps (running max m, running denom l) and rescales when a bigger max appears. Runnable, stdlib math only:
import math
def batch_softmax_dot(scores, values):
m = max(scores)
exps = [math.exp(s - m) for s in scores]
Z = sum(exps)
return sum(e / Z * v for e, v in zip(exps, values))
def online_softmax_dot(scores, values, tile=2):
m, l, acc = -math.inf, 0.0, 0.0
for i in range(0, len(scores), tile):
s_t, v_t = scores[i:i+tile], values[i:i+tile]
m_new = max(m, max(s_t))
scale = math.exp(m - m_new) if m != -math.inf else 0.0
l = l * scale + sum(math.exp(s - m_new) for s in s_t)
acc = acc * scale + sum(math.exp(s - m_new) * v for s, v in zip(s_t, v_t))
m = m_new
return acc / l
sc = [3.1, -1.0, 2.2, 0.5, 4.0, -2.0]
va = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
b = batch_softmax_dot(sc, va)
o = online_softmax_dot(sc, va)
print(f"batch softmax.V = {b:.10f}")
print(f"online softmax.V = {o:.10f}")
print("MATCH" if abs(b - o) < 1e-9 else "MISMATCH")
The running-max rescale keeps everything numerically identical — this is why FlashAttention is exact, not an approximation.
Context: The roofline model caps achievable FLOP/s at min(peak, intensity × bandwidth), and it shows precisely why fusion helps: it slides a low-intensity op up toward the compute ceiling.
Your task: Implement the roofline cap and place naive vs fused attention relative to the ridge point (where intensity = peak / bandwidth).
Requirements:
- Achievable =
min(peak_compute, intensity × bandwidth) - Ridge point is the intensity where the two terms meet
- Show a low-intensity op is stuck far below peak, a high-intensity one near it
- Explain fusion raises intensity by keeping tiles in SRAM
💡 Hint: Below the ridge you are memory-bound; fusion's job is to move the op to the right along the roofline.
Show solution
Below the ridge point (intensity = peak/bw) you are memory-bound; above it, compute-bound. Runnable:
def roofline_tflops(intensity, peak_tflops=312.0, bw_gib_s=2000.0):
bw_bytes = bw_gib_s * 1024**3
mem_bound = intensity * bw_bytes / 1e12 # TFLOP/s from bandwidth
return min(peak_tflops, mem_bound)
ridge = (312.0 * 1e12) / (2000.0 * 1024**3) # intensity at the corner
print(f"ridge point intensity = {ridge:.1f} FLOP/byte")
for name, I in [("naive attn (low I)", 5), ("fused attn (high I)", 120)]:
print(f"{name:>22}: achievable = {roofline_tflops(I):6.1f} TFLOP/s")
# low-intensity op is stuck far below peak; fusion raises intensity toward peak
Fusion (FlashAttention) raises arithmetic intensity by keeping tiles in SRAM, sliding the op up the roofline toward the compute ceiling.
Context: For memory-bound chains, fusion's speedup is essentially the traffic-reduction ratio: collapsing K elementwise ops that each round-trip through HBM into one kernel that reads and writes just once.
Your task: Model the HBM traffic saved by fusing K elementwise ops (each a read + a write) into a single kernel with one read and one write.
Requirements:
- Unfused traffic =
2 × K × tensor_bytes(read + write per op) - Fused traffic =
2 × tensor_bytes(one read + one write) - 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 for a bandwidth-bound chain.
Show solution
Unfused: each of K ops does one read + one write. Fused: one read + one write total. Runnable:
def fusion_saving_gib(tensor_elems, K, bytes_per=2):
per_pass = tensor_elems * bytes_per
unfused = 2 * K * 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=5)
print(f"unfused={u:.2f} GiB fused={f:.2f} GiB saved={s:.2f} GiB")
print(f"traffic cut: {s/u*100:.0f}%")
# fusing 5 memory-bound ops cuts HBM traffic ~80% -- pure bandwidth win
For memory-bound chains, fusion's speedup is roughly the traffic-reduction ratio — no new math, just fewer trips to HBM.
Context: Before touching Triton, a fused-softmax kernel is a tiling decision made offline: pick a BLOCK that fits in SRAM, count tiles per row, and only then encode it as GPU code.
Your task: Write an offline tile planner that chooses the largest power-of-two BLOCK fitting in SRAM and reports tiles per row, with the real Triton kernel left labeled 'needs GPU'.
Requirements:
- Max elements per tile =
SRAM_bytes / bytes_per_element - BLOCK is the largest power of two that fits within both SRAM and the row length
- tiles per row =
ceil(N / BLOCK) - Keep the Triton kernel as documented reference code labeled needs-GPU
💡 Hint: The SDK code only encodes the tiling you decide here; the planner is pure arithmetic over SRAM size.
Show solution
Offline you decide the tiling; the SDK code only encodes it. Runnable planner + labeled kernel:
def tile_plan(N, sram_kib=100, bytes_per=4):
# how big a BLOCK fits in SRAM, and tiles per row of length N
max_elems = sram_kib * 1024 // bytes_per
block = 1
while block * 2 <= min(max_elems, N):
block *= 2 # largest power-of-two block that fits
tiles = -(-N // block) # ceil division
return block, tiles
block, tiles = tile_plan(N=8192)
print(f"BLOCK={block}, tiles/row={tiles} (one row streamed in {tiles} SRAM-resident tiles)")
# needs GPU + Triton -- the SAME tiling, expressed as a kernel (documented Triton API):
# import triton, triton.language as tl
# @triton.jit
# def softmax_row(x_ptr, y_ptr, n, BLOCK: tl.constexpr):
# row = tl.program_id(0)
# offs = tl.arange(0, BLOCK)
# x = tl.load(x_ptr + row*n + offs, mask=offs < 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_ptr + row*n + offs, y, mask=offs < n)
The math and tiling are decided offline; the kernel is a faithful transcription — which is why you can reason about the memory wall without a GPU in front of you.
✓ Checkpoint — you can move on when you can…
- Sketch the GPU memory hierarchy (SMs, SRAM, HBM) and say why bandwidth is the wall.
- Explain why naive attention is memory-bound and what the N×N matrix costs in HBM traffic.
- Describe FlashAttention as tiling + online softmax → O(N) memory, fewer HBM round-trips.
- Use arithmetic intensity + a ridge point to classify a kernel as memory- or compute-bound.
- Explain kernel fusion and say what Triton is in one sentence.