AI EngineeringZero to ProductionHome·About·Contact
ML Systems Internals · Part 3

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.

⏱️ ~2 hours🧪 5 labs🎯 Advanced→Tech-lead

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.
Where this sitsK5 gave you self-attention as a concept and its O(n²) cost; IC3 covered the KV-cache and serving-side reuse. This lesson goes one level down — to the silicon: how the attention math actually moves bytes across a GPU's memory hierarchy, and why the winning kernel is the one that moves the fewest.

1 · A mental model of the GPU essential

Before kernels make sense you need a picture of the machine. A GPU is not one big processor — it's a few dozen to ~130 Streaming Multiprocessors (SMs), each a small independent core cluster with its own tiny, blisteringly fast on-chip memory. Around them sits a large pool of HBM (High-Bandwidth Memory) — gigabytes, but far away and comparatively slow to reach.

LevelSize (order of)Speed (order of)Role
Registers / SMtens of KB~fastestper-thread scratch
SRAM / shared mem (on-chip)~a few hundred KB per SM~10–20 TB/sthe tile you compute on
HBM (device memory)40–192 GB~2–3.35 TB/swhere weights & activations live
Host RAM (over PCIe/NVLink)hundreds of GB~tens of GB/soff-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.

SMs (compute) do the math SRAM (on-chip) tiny · ~10-20 TB/s HBM (device) big · ~2-3 TB/s Host RAM far · tens of GB/s
🗺️ How to read this diagram

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.

The bandwidth wallPeak compute has grown far faster than memory bandwidth for two decades. The result: for many real kernels the SMs sit idle, waiting on HBM. Making a kernel faster usually means moving fewer bytes, not doing less math.

2 · Naive attention, byte by byte essential

Recall the attention math: scores S = QKᵀ (an N×N matrix for a sequence of length N), then P = softmax(S), then output O = P·V. A textbook implementation runs these as three separate kernels — and that's the problem. The N×N score matrix is materialized in HBM: written out by the matmul, read back for softmax, written again, read again for the second matmul. For N=4096 that intermediate is 4096×4096 — tens of MB — round-tripped through slow memory several times, while the actual arithmetic per byte is small.

Q,K,V read once S = QKᵀ (N×N in HBM) write+read softmax(S) (N×N in HBM) write+read O = P·V write out
🗺️ How to read this diagram

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.

Memory-bound, not compute-boundThe FLOPs in attention are modest; what kills naive attention is the HBM traffic of the N×N intermediate — written and read multiple times. Attention is memory-bandwidth-bound, so the fix is not a faster matmul, it's never writing the N×N matrix to HBM at all. This single reframe — memory, not compute — is the whole point of the lesson.

3 · Lab · an HBM-traffic estimator intermediate

Let's make the traffic concrete. This models the bytes each approach moves through HBM: naive materializes and round-trips the N×N scores and probabilities; FlashAttention (next section) streams Q/K/V once and writes the output once, never materializing N×N. Pure stdlib — it runs offline and just counts bytes.

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 · HBM bytes: naive vs flash (runs, offline)
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
▶ How this works

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.

  1. qkv is the cost both paths share: read Q, K and V once (3 * N * d elements, dtype_bytes each — here 2 bytes for fp16/bf16).
  2. The naive path adds scores_rw and probs_rw: it writes then reads the N×N score matrix, and again for the softmax probabilities. That's the 2 * (N * N * dtype_bytes) terms — and because they're N×N, they grow with the square of the sequence length.
  3. The flash line 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.
  4. 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

FlashAttention's insight: you never need the whole N×N score matrix at once. Process attention in tiles — load a block of queries and a block of keys/values into SRAM, compute that block's partial scores on chip, fold them into a running output, then move to the next block. The N×N matrix is computed but never written to HBM; only the O(N·d) inputs and output touch HBM. Memory drops from O(N²) to O(N), and HBM round-trips collapse — that's the speedup.

Q,K,V tiles stream once tile in SRAM on-chip online softmax running m,l,acc O (streamed out) write once
🗺️ How to read this diagram

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, denominator l, and accumulator acc) 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 mathematical heart of FlashAttention, in pure Python. We compute a softmax-weighted sum of values two ways — the full "materialize everything" batch softmax, and the streaming/tiled online softmax — and show they agree exactly. If this equality holds, tiling is safe.

Python · streaming softmax == batch softmax (runs, offline)
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
▶ How this works

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.

  1. batch_softmax_weighted_sum is the "materialize everything" reference: subtract the max for stability, exponentiate the whole score row, normalize, and dot with the values.
  2. online_softmax_weighted_sum processes the scores in tiles, carrying three running numbers: the max m, the denominator l, and the output acc.
  3. 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.
  4. It prints both results and their absolute difference, then MATCH if 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

FlashAttention is one instance of a general lever: kernel fusion. Every separate kernel launch reads its inputs from HBM and writes its outputs back. Fusing several operations into one kernel keeps the intermediate in SRAM/registers and pays the HBM cost only once — the same reason softmax(x)·V fused beats three separate passes. Fusion is almost always a memory-traffic optimization, not a compute one.

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).

Python · arithmetic intensity & the roofline (runs, offline)
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
▶ How this works

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.

  1. Arithmetic intensity ai = flops / bytes_moved is FLOPs performed per byte pulled from HBM. Low intensity = you do little math per byte = you starve for data.
  2. The ridge point ridge = peak_flops / bandwidth is the machine's break-even intensity. Here it uses an H100-class GPU: ~990 TFLOP/s and ~3.35 TB/s of HBM bandwidth.
  3. If a kernel's intensity is below the ridge it's memory-bound (bandwidth caps you); above it, compute-bound (the ALUs cap you). attainable is the best FLOP/s you can actually reach given that intensity.
  4. 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

Writing a fused, tiled kernel like FlashAttention traditionally meant hand-written CUDA C++. Triton (from OpenAI) lets you write GPU kernels in Python: you express the computation over blocks (tiles), and Triton's compiler handles the SM scheduling, SRAM allocation, and memory coalescing. It's how much of the modern fused-kernel ecosystem (including FlashAttention variants) is now authored — Python-level control with near-CUDA performance.

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.

Python (Triton) · attention kernel shape ▶ needs a GPU + Triton/PyTorch
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
Read it, don't run itThis is the only block in the lesson that requires real hardware. Notice it's the same running 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

A tech lead doesn't hand-write kernels day to day — but they make the decisions that hinge on this model. Is this workload memory- or compute-bound? (Roofline it before optimizing.) Should we adopt a fused-attention library or a Triton kernel? (Almost always yes for long context — the HBM savings are the win.) Does a longer context window blow up memory or just compute? (With FlashAttention it's O(N) memory, so context length is bounded by KV-cache, not the score matrix — connect to IC3.) The through-line: on modern accelerators, performance is a data-movement problem first and an arithmetic problem second.

The one sentence to keepModern GPUs can do far more math than they can feed themselves data — so the fastest kernel is usually the one that touches HBM the fewest times, not the one that does the least arithmetic. FlashAttention, kernel fusion, and roofline analysis are all just corollaries of that.
✓ Knowledge check

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
Naive attention is memory-bound: its runtime is dominated by writing and re-reading the N×N score/probability matrices in HBM, not by the matmul FLOPs. A faster matmul optimizes the part that isn't the bottleneck. The real fix is to stop materializing the N×N matrix — tile the computation and keep it in SRAM (FlashAttention) so you move O(N) bytes instead of O(N²). Roofline the kernel first: below the ridge point, reduce bytes moved, not FLOPs.
✓ Knowledge check

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
Softmax subtracts the row max before exponentiating for numerical stability (otherwise 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.

Exercise 1 · Compute vs memory: the two speedsBeginner

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.

Exercise 2 · HBM-traffic estimator for naive attentionIntermediate

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.

Exercise 3 · Online softmax equals batch softmaxAdvanced

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 m and running denom l
  • 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.

Exercise 4 · The roofline modelExpert

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.

Exercise 5 · Kernel-fusion savings modelProfessional

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.

Exercise 6 · Triton kernel: shape it offline, then portIndustry scenario

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.
© 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