6 — Why LLMs need all of this
Requirements: chapters 2 to 4, and basic matrix multiplication. Experiment 1 needs only a calculator. Experiments 2 and 3 need a GPU and vLLM.
Model inference has two phases: prefill, processing the prompt, and decode, generating tokens one at a time. One ratio explains how they differ.
The ridge point
The Roofline model compares a program with its hardware using operational intensity:
(Practitioners often say “arithmetic intensity”, but the Roofline authors use operational intensity. It’s the same formula here.)
Now take an H100 SXM5:
- Dense FP16 peak: about 989 TFLOPS (FlashAttention-3)
- HBM3 bandwidth: 3.35 TB/s
Divide them:
That’s the ridge point. Below 295, memory limits you. Above it, compute does.
The intensity of a linear layer
Take a weight matrix of size , multiplied by a batch of size , in FP16 (2 bytes per value).
- FLOPs: each of the rows needs multiply-adds, or operations.
- Bytes: reading costs . Reading costs , and writing the result costs another .
So:
Here’s how that plays out:
| D | B | I (FLOPs/byte) | Against 295 |
|---|---|---|---|
| 8192 | 1 | 1.0 | Far below |
| 8192 | 32 | 31.8 | Below |
| 8192 | 256 | 240.9 | Below |
| 4096 | 4096 | 1365 | Above |
| 8192 | 4096 | 2048 | Above |
| 16384 | 4096 | 2731 | Above |
With , you cross the ridge at about .
This is the key point. Being memory-bound depends on batch size, not on the word “decode”. With one active request, you get 1 FLOP per byte, so at most about 0.34% of peak FLOPS is reachable under this model. That’s a ceiling from arithmetic, not a measured idle percentage. Batching raises it.
A speed limit
Divide model size by memory bandwidth to get a bound on tokens per second, for one request, counting weights only:
| Model | Size | Ceiling at 3.35 TB/s |
|---|---|---|
| 70B parameters at FP16 | 140 GB | About 24 tokens/s |
| 70B parameters at FP8 | 70 GB | About 48 tokens/s |
These are model bounds, not benchmarks. Note that 140 GB doesn’t fit in one 80 GB H100, so that case needs sharding.
This is why quantization to FP8, INT4 or MXFP4 helps decode so much: fewer bytes per token. Peak FLOPS, on the other hand, mostly tells you about prefill.
Real kernels also stay below peak. FlashAttention-3 reaches 740 TFLOPs/s, or 75%, partly because softmax runs on a slower unit at 3.9 TFLOPS (FA3).
Why naive attention breaks
Self-attention, for sequence length :
A naive implementation runs this as separate kernels, writing an matrix to HBM between each. During prefill at , that matrix has elements. It exhausts VRAM, and it saturates the bus with round trips. (A KV-cached decode step has only one query, so this applies to prefill.)
Avoiding HBM with FlashAttention
FlashAttention fuses the equation into a single kernel, using ideas from chapters 2 and 4:
- Split into tiles that fit in shared memory, which runs at 31 TB/s, about 9× faster than HBM.
- Use the online softmax trick (Milakov and Gimelshein), keeping running maximums and scaling factors in registers.
- Never write the matrix to HBM.
FlashAttention-3 adds warp specialization on Hopper: one group fetches tiles with TMA while others compute. FlashAttention-4 reaches 1613 TFLOPs/s on B200, or 71%, partly emulating exponentials because that unit didn’t scale with Tensor Cores (Together AI, paper).
Keeping data high in the memory table is the entire idea.
The KV cache as virtual memory
To avoid recomputing old tokens, a server keeps their keys and values in the KV cache. Sequence lengths are unpredictable, so older systems reserved contiguous space for the maximum length. That wasted 61.8–79.6% of the KV cache, including reserved slots (PagedAttention paper).
If you’ve read the page fault chapter, you know the fix. PagedAttention (SOSP ‘23) splits the cache into fixed blocks, 16 tokens by default, allocated on demand. A block table maps logical blocks to physical ones.
What this actually gave:
- Throughput rose 2–4× (22× against FasterTransformer).
- External fragmentation goes away, while internal fragmentation stays, bounded by block size.
- Kernel latency actually rose 20–26%. The gain is at system level, through bigger batches.
The original custom kernel used a block table in global memory, with a computed index and one warp per block (v0.8.5 source). It’s now history: vLLM marks that document “historical” and defaults to FlashAttention versions (attention_backends.md). The paging idea lives on.
Past one GPU
When a model needs several GPUs, tensor parallelism splits each layer, so GPUs must combine partial results constantly.
Compare like with like: NVLink on Hopper gives 450 GB/s each way, against 64 GB/s each way on PCIe Gen5 (Hopper architecture). Blackwell’s NVLink doubles the per-GPU total to 1.8 TB/s.
With NCCL, each GPU can issue a multimem.ld_reduce to a shared address and get the reduced value back. multimem.st handles broadcasts, and SHARP engines in NVSwitch do the reduction in the network (PTX ISA, NVLink).
One token, end to end
- The serving engine forms a batch and runs fused kernels.
- The GPU spreads them across SMs. Copy engines stream tiles into shared memory, and warps issue tensor instructions.
- The interconnect combines results across GPUs without involving the CPU.
- Your tools watch it from outside, as in chapter 5.
The next chapter zooms out to how the whole machine is packaged.
Hands-on
Experiment 1: rebuild the Roofline table
Needs: a calculator or Python.
- Compute 989 / 3.35.
- Calculate for and = 1, 32, 256 and 4096.
- Solve for the where .
- Compare with the table above. Then repeat it using your own GPU’s numbers from chapter 2.
Experiment 2: observe prefill and decode
Needs: a GPU and Python 3.12.
-
Install vLLM and serve a small model (quickstart):
uv venv --python 3.12 --seed uv pip install vllm --torch-backend=auto vllm serve Qwen/Qwen2.5-1.5B-Instruct -
Save the metrics before and after each run:
curl -s http://localhost:8000/metrics | grep -E 'vllm:(request_prefill_time_seconds|request_decode_time_seconds|time_to_first_token_seconds|kv_cache_usage_perc)' -
Prefill sweep. Send one request at a time, with a fixed short
max_tokensand longer and longer prompts. Expected: prefill time grows with prompt length. -
Decode sweep. Use a short prompt with a long
max_tokens. Expected: time per output token stays roughly flat. -
Batch sweep. Raise concurrency. Expected: total throughput rises, while per-request speed doesn’t improve.
-
Expected: compute model bytes ÷ your bandwidth. Your measured time per token must be above that floor.
nvidia-smi dmon -s um -d 1 is useful alongside this, but its sm figure only says whether a kernel ran during the sample. It doesn’t show Tensor Core load, so don’t treat it as proof.
Experiment 3: watch the KV cache fill
Needs: a GPU and vLLM.
- Before changing
--block-size, check attention_backends.md. Some kernels want 128. - Raise the number of concurrent long prompts until
vllm:kv_cache_usage_percnears 1, and watchvllm:num_preemptions. - Expected: usage should grow with active tokens, not with reserved
max_tokens. That’s the paging idea made visible.
multimem can’t be a home experiment, because it needs NVSwitch.