Ch. 17Signature chapter
Efficient Inference
KV cache, paged attention, quantization, FlashAttention, speculative decoding, vLLM. The signature systems chapter.
The compute math behind Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → is sharper than people think. When you generate one A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → from a 70B model on an H100, your arithmetic intensity is 1: you read 140GB of weights from HBM (high-bandwidth memory, the GPU's large-but-slow main DRAM where weights live) and you do one matrix-vector product. The H100 has 989 TFLOPs of compute and 3.35 TB/s of memory bandwidth. The break-even arithmetic intensity is 295. You are under the line by a factor of 295. Your H100 is sitting idle 99.7% of the time, waiting for the next byte of weights to arrive. This is what "inference is memory-bound" means as a physical fact, not as folklore. Every technique in this chapter — KV cache, quantization, paged A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary →, speculative decoding, MQA/GQA, FlashAttention — is in some way a response to this single inequality. If you understand the inequality, you understand why the entire stack looks the way it does.
FIG 17.1 · Learning outcomes
By the end of this chapter you will be able to:
- Compute the arithmetic intensity of any layer in any transformer and predict whether it will be compute-bound or memory-bound at given batch size.
- Implement a KV cache from scratch in ≤40 lines of PyTorch and explain why it changes the inference cost from O(T³) to O(T²).
- Explain the difference between MHA, MQA, and GQA at the level of tensor shapes, and pick the right one for a deployment budget.
- Quantize a Llama-7B model to int8 with
bitsandbytesand verify perplexity stays within 1% of the FP16 baseline. - Distinguish PTQ from QAT, and GPTQ from AWQ, with a one-line summary of when to reach for each.
- Write a Triton kernel that fuses softmax with a max-stabilization pass, in 30 lines.
- Describe FlashAttention's online-softmax trick well enough to draw the I/O pattern on a whiteboard.
- Set up speculative decoding with a 1B draft model and a 70B target model, and explain why the math gives an exact sample from the target distribution.
- Configure vLLM with paged attention, prefix caching, and continuous batching, and reason about the latency-throughput tradeoff in your setup.
- Identify three new attack surfaces that efficient-inference techniques introduce: KV cache leakage between tenants, speculative-decoding timing side channels, and quantization-induced behavior drift.
FIG 17.2 · What you need first
- Ch 15 — Transformers from Scratch — you need to know what Q, K, V, attention scores, and the residual stream are. This chapter is about making the math run fast at deployment.
- Ch 11 — Training Deep Neural Networks — you need familiarity with FP16/BF16, gradient scaling, mixed precision. Quantization picks up where mixed-precision training left off.
- Ch 12 — CNNs — minor. Some kernel-fusion intuition transfers from CNN ops. Skip if you're short on time.
- Ch 16 — Multimodal Transformers — minor, mostly for the multimodal-inference framing. If you only care about text-LLM inference, skip.
- external — CUDA programming basics — you don't need to write CUDA, but the language of GPU hardware appears throughout this chapter: "thread blocks" and "warps" (a warp is the pack of 32 threads scheduled together), "SMs" (streaming multiprocessors, the GPU's compute cores), the memory hierarchy from slow off-chip HBM to fast on-chip SRAM/shared memory, and accelerators like TMA and the NVLink interconnect between GPUs. Each term is glossed where it first appears; for the full picture,
24-founder-blogs/dettmers-which-gpu-for-deep-learning §tensor-cores + §memory-bandwidth + §l2-cache-shared-memorywalks the whole hierarchy, or skim04-stanford/cs336-lecture_06.
This chapter is heavier on systems and less heavy on math than its neighbors. If you have never deployed an LLM to a GPU before, expect to spend more time on the lab.
FIG 17.3.1
The inference compute equation: arithmetic intensity and the memory wall
Every operation on a GPU has two costs: FLOPs (compute) and bytes moved between HBM and the compute units on the chip (memory transfer). The ratio is arithmetic intensity.
The H100 has 989 TFLOPs (BF16) and 3.35 TB/s HBM bandwidth. Divide: the break-even intensity is 989e12 / 3.35e12 ≈ 295. Any operation with intensity > 295 is compute-bound (good — you're using the silicon). Any operation with intensity < 295 is memory-bound (bad — the GPU is waiting for bytes).
For a dense matrix multiply times :
- FLOPs:
- Bytes: at BF16
In the limit , intensity simplifies to . The A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → dimension is what makes a matmul compute-bound.
Now apply this to LLM Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →. During generation, you process one A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → at a time per sequence (), so the MLP layers have intensity where is the number of concurrent requests. For A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → layers, the math comes out differently. The K and V matrices grow with sequence length, and they are per-sequence, so batching does not help. Attention during generation has intensity . Both regimes are far below 295.
This is the fundamental inference problem. Generation is bounded by memory bandwidth on every hardware platform built since 2017. The compute is sitting there. Every technique in this chapter buys you efficiency in one of three ways: reduce bytes-per-token (quantization, MLA, MQA), reduce reads-per-step (KV cache, FlashAttention), or amortize across more tokens (continuous batching, speculative decoding).
The prefill phase (processing the prompt) is different. Prefill has and is largely parallelizable. The MLP has intensity , attention has intensity where is prompt length. Both are compute-bound for any reasonable prompt. This is why prefill is fast and generation is slow, even though they run the same model.
FIG 17.3.2
KV cache: the most important optimization in LLM serving
In another chapter you wrote a transformer that, for each new A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary →, ran a full Running an example through the model from start to finish to get a guess, which is really just a chain of multiply-and-add steps.Full glossary → over the entire context. For generating token , that means running A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → over keys and values. The cost is per layer for attention, and you have to do this for every new token, so generating tokens of output is total. That is intolerable past sequence length ~1000.
The KV cache fixes it. Observation: when you generate token , the keys and values for positions depend only on those positions' input embeddings, which don't change as you generate more tokens. So you compute K and V for each position once (when that token is first seen, either during prefill or during generation), and store them in memory. For the next token, you compute the new K and V for position , append to the cache, and run attention against the entire cache.
Per layer, per head, you store one K vector of shape (seq_len, d_k) and one V vector of the same shape. For a typical Llama-2-13B (40 layers, 40 heads, ), at sequence length 2048, this is:
40 layers × 40 heads × 2048 tokens × 128 dims × 2 (K and V) × 2 bytes (BF16)
= 1.68 GB per sequenceMultiply by A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → size: at batanother chapter, that's 107 GB of KV cache. On an 80GB H100, you have no room left for the model weights. This is the practical A spot in a model where its width is squeezed small on purpose, forcing the information to compress before it continues.Full glossary → on Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → throughput at large context. The KV cache is the dominant memory consumer at non-trivial scale.
The crucial bit in any from-scratch implementation is the difference between prefill (run over all prompt tokens at once, fill the cache) and decode (one new token at a time, append to the cache).
model.generate(use_cache=True) vs. KV cache from scratch
DL gluemodel = AutoModelForCausalLM.from_pretrained("gpt2", torch_dtype=torch.bfloat16)
tok = AutoTokenizer.from_pretrained("gpt2")
inputs = tok("The capital of France is", return_tensors="pt")
# use_cache=True is the default; this IS the KV-cache machinery.
# do_sample=False => greedy, so the output equals decoding with no cache.
out = model.generate(**inputs, max_new_tokens=50, do_sample=False, use_cache=True)# generate_kv: prefill once over the whole prompt, then decode one token at a time
self.reset_caches()
positions = torch.arange(T, device=idx.device)
logits = self(idx, use_cache=True, positions=positions) # prefill fills the cache
next_token = logits[:, -1, :].argmax(-1, keepdim=True)
idx = torch.cat([idx, next_token], dim=1)
for _ in range(max_new_tokens - 1): # decode: T_new == 1
pos = torch.tensor([idx.shape[1] - 1], device=idx.device)
logits = self(idx[:, -1:], use_cache=True, positions=pos) # only the new token in
next_token = logits[:, -1, :].argmax(-1, keepdim=True)
idx = torch.cat([idx, next_token], dim=1)
# inside CausalSelfAttentionWithKV.forward, when use_cache=True:
cl = int(self.cache_len.item())
assert cl + T_new <= self.max_seq_len
self.k_cache[:, :, cl:cl + T_new] = k_new # in-place slice write, no realloc
self.v_cache[:, :, cl:cl + T_new] = v_new
k = self.k_cache[:, :, :cl + T_new] # view of the active prefix (all history)
v = self.v_cache[:, :, :cl + T_new]
self.cache_len += T_new
scores = q @ k.transpose(-2, -1) / math.sqrt(self.d_k) # q is the new rows, k is all of history
attn = F.softmax(scores, dim=-1)
out = attn @ vfrom scratch: lab/solution.py: GPTWithKV.generate_kv + CausalSelfAttentionWithKV.forward
- 1
model.generate(..., use_cache=True) — one call hides prefill then a per-token decode loopgenerate_kv: one prefill pass over the prompt, then the for-loop feeding idx[:, -1:] one token at a time - 2
the internal DynamicCache / past_key_values that grows each step, per layer and per headthe registered k_cache/v_cache buffers + cache_len pointer; in-place writes self.k_cache[:, :, cl:cl+T_new] = k_new - 3
Q attends to all past K,V without recomputing themscores = q @ k.transpose(-2,-1) where q is the new row(s) and k = self.k_cache[:, :, :cl+T_new] is the whole history - 4
absolute position handling for the new token (position_ids advance internally)pos = torch.tensor([idx.shape[1]-1]) passed as positions so pos_emb indexes the true absolute slot - 5
no causal mask needed during decode (only one query row)the mask branch is gated by if T_new > 1; during decode T_new==1 so no mask is built - 6
.generate() starts from a fresh cache per callself.reset_caches() at the top of generate_kv zeros every block's buffers and cache_len
What the one call hides
- The cache is per-layer and per-head: HF allocates and grows a past_key_values structure for every attention layer behind one boolean.
- The prefill-vs-decode split: the first forward processes all prompt tokens at once, every later forward processes exactly one token through the same code path at a different sequence length.
- Production caches pre-allocate a max-length buffer and write into a slice (as solution.py does with self.k_cache[:, :, cl:cl+T_new] = k_new); the naive torch.cat version reallocates the whole K/V tensor every step.
- Absolute-position bookkeeping: generate() advances position_ids / RoPE offsets so token T is embedded at slot T, not slot 0.
- That with the cache the per-token attention cost is O(T) instead of O(T^2), turning O(T^3) generation into O(T^2).
- Gotcha: do_sample must be False to reproduce the solution's argmax decode; if sampling is on (or temperature/top_p kicks in via the model's generation_config) the outputs will not match the cache-vs-no-cache equality.
- Gotcha: The cache has a fixed window (assert cl + T_new <= max_seq_len); generating past it asserts/truncates rather than sliding gracefully.
- Gotcha: Caches are stateful: reusing the module across two prompts without reset_caches() leaks the previous sequence's K/V into the new one.
- Gotcha: If you ever pass past_key_values yourself you must also pass the right cache_position/position_ids, or the new token gets the wrong absolute/RoPE position and the output silently diverges.
Prefer a well-tested library cache in production for correct batching and device handling; paged caching is a separate serving-engine feature, not guaranteed by a generic use_cache flag. The scratch cache demonstrates compute-K,V-once reuse and lets you measure the generation speedup.
On the job: What you actually hand-write at work is the serving generation loop and cache plumbing around a model — the prefill/decode split, the pre-allocated KV buffer with a write pointer, position bookkeeping, and reset between requests — not the attention math itself.
Two subtleties:
- The cache is per-layer and per-sequence. A batched implementation has shape
(B, n_heads, seq_len, d_k)for both K and V. - Concatenating with
torch.catallocates a new A chunk of numbers arranged in a grid, or many grids stacked on top of each other.Full glossary → every step. Production implementations pre-allocate the maximum-length tensor and write into a slice (cache[:, :, t, :] = k_new). This avoids the realloc overhead and matches how vLLM and TensorRT-LLM do it.
FIG 17.3.3
MHA, MQA, GQA, MLA: reducing the cache without losing quality
Once you've measured your KV cache and realized it's 50% of your Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → memory, the next question is: can you make the cache smaller without breaking the model?
The cheapest answer is to share K and V heads across query heads. Vanilla multi-head A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → (MHA) has separate K and V projections. Each head's KV is independent.
Multi-Query Attention (MQA, Shazeer 2019): use only one K and V head, shared across all query heads. The KV cache shrinks by a factor of n_heads. Llama-2-70B used MQA experimentally; PaLM-540B used MQA from the start. The share of guesses the model got right out of all its guesses.Full glossary → drops slightly but throughput improves a lot.
Grouped-Query Attention (GQA, Ainslie et al. 2023): a compromise. , and each KV head is shared by n_heads / n_kv_heads query heads. Llama-2-70B-Chat and Llama-3 use GQA with n_kv_heads = 8 and n_heads = 64, so each KV head is shared by 8 query heads, shrinking the cache by 8×. Accuracy is essentially identical to MHA.
The shape math:
| Attention | n_heads (Q) | n_kv_heads (K, V) | KV cache size |
|---|---|---|---|
| MHA | |||
| MQA | |||
| GQA |
Multi-head A compressed bundle of numbers that captures the essence of some data without being readable on its own.Full glossary → Attention (MLA, DeepSeek 2024) goes further. Instead of sharing K/V across heads, it stores a low-rank latent representation of K/V and reconstructs the full K/V on the fly via a learned up-projection. The cache holds vectors of dimension . DeepSeek-V2 uses for — a 32× reduction over MHA, and it actually outperforms MHA on the benchmarks they ran.
# Minimal GQA forward (no cache for clarity)
def gqa_forward(x, W_q, W_k, W_v, n_heads, n_kv_heads, d_k):
B, T, C = x.shape
q = (x @ W_q.T).view(B, T, n_heads, d_k).transpose(1, 2) # (B, h, T, d_k)
k = (x @ W_k.T).view(B, T, n_kv_heads, d_k).transpose(1, 2) # (B, g, T, d_k)
v = (x @ W_v.T).view(B, T, n_kv_heads, d_k).transpose(1, 2) # (B, g, T, d_k)
# Repeat KV heads to match Q heads
n_groups = n_heads // n_kv_heads
k = k.repeat_interleave(n_groups, dim=1) # (B, h, T, d_k)
v = v.repeat_interleave(n_groups, dim=1)
scores = q @ k.transpose(-2, -1) / (d_k ** 0.5)
attn = scores.softmax(dim=-1)
return (attn @ v).transpose(1, 2).contiguous().view(B, T, C)The repeat_interleave is a logical operation — in practice the kernel reads each KV head and uses it for all n_groups query heads without actually materializing the expanded A chunk of numbers arranged in a grid, or many grids stacked on top of each other.Full glossary →. FlashAttention implementations handle this natively.
FIG 17.3.4
Quantization basics: FP32 → BF16 → FP8 → INT8 → INT4
Quantization reduces the Out of all the times the model shouted 'yes,' how often it was actually right.Full glossary → of numbers in the model. Less precision means fewer bytes per One of the model's internal numbers that gets adjusted as it learns.Full glossary →, which means less memory bandwidth, which means faster Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →. The question is: how much precision can you lose before The share of guesses the model got right out of all its guesses.Full glossary → drops?
The hierarchy of numerical formats:
| Format | Bytes | Range | Use |
|---|---|---|---|
| FP32 | 4 | Training (esp. gradients, optimizer states) | |
| BF16 | 2 | , 7-bit mantissa | Default for training + inference (post-2020) |
| FP16 | 2 | , 10-bit mantissa | Older default. Smaller range than BF16. |
| FP8 (E4M3) | 1 | , 3-bit mantissa | New on H100. Inference, sometimes training. |
| INT8 | 1 | Inference. Linear quantization with scale + zero point. | |
| INT4 | 0.5 | Aggressive inference quantization. GPTQ, AWQ. |
Going from BF16 to INT8 halves your KV cache and your A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → memory. From BF16 to INT4 quarters them. On a memory-bound workload (which inference is), that translates directly into speedup, as long as you have a kernel that operates on the quantized type.
The basic linear quantization recipe for a A chunk of numbers arranged in a grid, or many grids stacked on top of each other.Full glossary →:
where (scale) and (zero-point) are chosen per tensor (or per One of several stacked grids of numbers in an image, each tracking a different kind of pattern.Full glossary →, or per group) to map the floating-point range onto the integer range. The choice of "what to quantize together" — per-tensor, per-channel, per-group — is called granularity and matters a lot for accuracy.
Post-training quantization (PTQ) quantizes a trained FP16/BF16 model in one shot, possibly using a small How well a model's stated confidence matches how often it's actually right.Full glossary → dataset to pick scales. Cheap. Works well for INT8 on most models. Struggles at INT4.
Quantization-aware training (QAT) simulates quantization during training (Running an example through the model from start to finish to get a guess, which is really just a chain of multiply-and-add steps.Full glossary → uses quantized values, backward uses the floats). More expensive but recovers more accuracy at aggressive bit-widths.
For LLMs, the workflow is almost always PTQ because the models are huge and full QAT is impractical. The question is which PTQ algorithm to use.
FIG 17.3.5
LLM.int8: outlier features and mixed-precision decomposition
Tim Dettmers' LLM.int8 (2022) is the paper that made INT8 quantization actually work on transformers at scale. The problem it solved:
Naive INT8 PTQ on a small BERT works fine. On a 175B-One of the model's internal numbers that gets adjusted as it learns.Full glossary → OPT, The share of guesses the model got right out of all its guesses.Full glossary → collapses. Dettmers identified why: at scale, transformers develop "emergent outlier features" — a small number of hidden dimensions where activation values are 100× larger than the rest. If you quantize these in INT8, the outlier dominates the scale, and the other 99.9% of dimensions get squished to ~zero. Information loss is total in those dimensions.
The fix is mixed-Out of all the times the model shouted 'yes,' how often it was actually right.Full glossary → decomposition. For each matrix multiply :
- Identify the columns of that contain outliers (any value > 6 in magnitude, typically 0.1% of columns).
- Compute the matrix multiply for those columns in FP16: .
- Quantize the other 99.9% of columns to INT8, do the multiply in INT8 with vector-wise Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary →, dequantize.
- Sum the two contributions.
The result is essentially full-precision accuracy at near-INT8 memory cost. The catch is the INT8 multiply is slower than the FP16 multiply on most hardware because the kernels are less optimized. LLM.int8 is great for memory savings (lets you fit 175B on 8×A100 instead of 16×A100) but is 15-23% slower than FP16 Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → on the same hardware. Different tradeoff than you might expect.
The bitsandbytes library implements this:
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
quant_config = BitsAndBytesConfig(
load_in_8bit=True,
llm_int8_threshold=6.0, # outlier detection threshold
llm_int8_has_fp16_weight=False,
)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-7b-hf",
quantization_config=quant_config,
device_map="auto",
)
# Now uses ~3.5GB for weights instead of 13GB. Inference works, slightly slower.The emergent features finding is, in retrospect, a mech-interp result hiding in a quantization paper. Dettmers' observation that 6 specific One piece of information about an example that the model looks at when making a guess.Full glossary → dimensions in a 6.7B model carry the outlier mass, and that these dimensions are stable across layers and inputs — is the kind of finding that the Anthropic mech-interp team picked up later. There's a connection between "outlier features" and "high-importance circuits" that I don't think is fully understood yet.
FIG 17.3.6
GPTQ and AWQ: INT4 weight quantization that works
INT8 is comfortable. INT4 is where the real memory savings (and the real engineering challenges) live. The two algorithms that dominate INT4 quantization in 2024-2025:
GPTQ (Frantar et al. 2022) treats A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → quantization as a per-layer reconstruction problem. Given How well a model's stated confidence matches how often it's actually right.Full glossary → data, find quantized weights that minimize . GPTQ does this column by column, using the Hessian of the squared error to choose which columns to quantize first (the most informative ones). The math is the closed-form update from Optimal Brain Quantization (OBQ); GPTQ is OBQ scaled up to LLM-sized matrices. With ~128 calibration samples, GPTQ quantizes OPT-175B to 3-4 bits in a few GPU-hours, with <1% A score for a language model showing how surprised it is by the test text, lower means less surprised.Full glossary → loss. (Perplexity is the standard intrinsic language-model quality metric: the exponential of the per-A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → A loss that measures how far a model's predicted chances are from the true answer.Full glossary → — lower is better, and a 1% rise means the quantized model is barely worse at predicting held-out text.)
The downside: GPTQ quantizes weights but does nothing to activations. Activations are still FP16 at runtime. The win is purely on weight memory. If your hardware supports INT4 × FP16 matmul (modern NVIDIA GPUs do via Marlin or ExLlamaV2 kernels), you also get a speedup.
AWQ (Activation-aware Weight Quantization; Lin et al. 2023) takes a different angle. Observation: not all weights are equally important. The weights in channels that get hit by large activations matter more. AWQ identifies the ~1% of weight channels that correspond to large activation magnitudes (using calibration data), keeps those in higher Out of all the times the model shouted 'yes,' how often it was actually right.Full glossary → via a per-One of several stacked grids of numbers in an image, each tracking a different kind of pattern.Full glossary → Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary → trick, and quantizes the rest to INT4 normally. The scaling is mathematically equivalent (you scale the weight up and the corresponding activation down), so no extra runtime cost.
AWQ tends to outperform GPTQ on instruction-tuned models, where the "important" channels are more concentrated. GPTQ tends to be slightly more general-purpose. Both produce 4× smaller models than FP16 with <1% perplexity hit on most workloads.
The combined recipe most people use in production:
- Quantize weights with AWQ or GPTQ.
- Use an INT4×FP16 kernel at Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → (Marlin or ExLlamaV2, both for NVIDIA Ampere/Ada) — without one, the quantized weights save memory but give no speedup.
- Keep the KV cache in FP16 or INT8 separately.
- Serve from vLLM, which supports the loading and serving glue.
# AWQ in HuggingFace
from transformers import AutoModelForCausalLM
from awq import AutoAWQForCausalLM
# Load the FP16 model, quantize with AWQ
model = AutoAWQForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf")
quant_config = {"zero_point": True, "q_group_size": 128, "w_bit": 4}
model.quantize(tokenizer, quant_config=quant_config,
calib_data="mit-han-lab/pile-val-backup")
model.save_quantized("./llama-2-7b-awq")
# Now ~3.5GB on disk, can serve from vLLM with --quantization awqFIG 17.3.7
FlashAttention: kernel-level rewrite of attention
The standard A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → implementation reads Q, K, V from HBM, computes scores (an matrix) and writes it back to HBM, reads it again for the A function that turns a list of raw scores into confidences for several options that add up to exactly 1 (100%).Full glossary →, writes the softmax result, reads it again to multiply by V. For long sequences, this materializes a giant A chunk of numbers arranged in a grid, or many grids stacked on top of each other.Full glossary → in HBM that we then immediately throw away.
FlashAttention (Dao et al. 2022) rewrites this as an IO-aware algorithm. Tile Q, K, V into blocks that fit in SRAM (the GPU's on-chip cache, ~100KB per SM). Compute the attention for each Q block by iterating over K and V blocks, accumulating a running softmax using the online softmax trick:
For each block of Q:
Initialize running max m = -inf and running denominator l = 0
For each block of K, V:
Compute scores S = Q_block @ K_block.T / sqrt(d_k)
m_new = max(m, max(S))
# Rescale previous accumulation: subtract new max
l = l * exp(m - m_new) + sum(exp(S - m_new))
O = O * exp(m - m_new) + exp(S - m_new) @ V_block
m = m_new
O = O / l
Write O to HBMThe point: you never materialize the full score matrix in HBM. Tiles exist in SRAM long enough to update the online softmax and output accumulator. Standard attention moves score-sized data to/from HBM; FlashAttention's paper analyzes HBM accesses as for head dimension and SRAM capacity over its supported regime. That is a large, hardware-dependent reduction—not generally . The wall-clock gain depends on shape, The kind of number a value is stored as, like a decimal versus a whole number.Full glossary →, hardware, and kernel implementation, so benchmark the workload rather than translating the complexity result into a fixed “week to day” promise.
FlashAttention-2 (Dao 2023) tweaked the kernel for better warp-level parallelism (a warp is the pack of 32 threads a GPU schedules together as one unit). FlashAttention-3 (Shah et al. 2024) added FP8 support and explicit use of TMA (tensor memory accelerator) on H100. FlashDecoding (Hong et al. 2023) re-derived the algorithm for the decoding case (one query, long KV) and got another 2-8× speedup. All three are now production-standard for transformer Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →.
You almost never write FlashAttention yourself. You call it:
import torch
import torch.nn.functional as F
# PyTorch 2.0+ has it built in
q = torch.randn(2, 12, 1024, 64, device="cuda", dtype=torch.bfloat16)
k = torch.randn(2, 12, 1024, 64, device="cuda", dtype=torch.bfloat16)
v = torch.randn(2, 12, 1024, 64, device="cuda", dtype=torch.bfloat16)
out = F.scaled_dot_product_attention(q, k, v, is_causal=True)
# Behind the scenes: uses FlashAttention 2 on Ampere, FA3 on Hopper.The thing to take away is the online softmax pattern. It's an algorithm that lets you compute a softmax over a sequence in one streaming pass without materializing the whole sequence. The same pattern appears in the chunked attention of Ring Attention, in tree-based attention reductions for very long context, and elsewhere. Worth understanding the math.
FIG 17.3.8
Triton kernels: writing your own when PyTorch isn't enough
Most of the time, torch.compile and F.scaled_dot_product_attention are fast enough. But sometimes you need to fuse a custom op (e.g., RMSNorm + a residual add + a Swish, all in one kernel). The two options are The system that lets code run on a graphics chip instead of the main processor.Full glossary → (hard to write, hard to debug) and Triton (Python-like syntax, JIT-compiled to PTX).
Triton is a domain-specific language by OpenAI's Philippe Tillet. It looks like Python with a few annotations and primitives (tl.load, tl.store, tl.arange). It compiles to PTX (GPU assembly). The compiler handles thread scheduling within a block; you only specify what the block does, not how the threads in it cooperate.
Here is a fused A function that turns a list of raw scores into confidences for several options that add up to exactly 1 (100%).Full glossary → kernel — the same one that ships with Triton's tutorials. It reads a 2D matrix, computes max-stabilized softmax along the rows, writes the result, all in one launch:
import triton
import triton.language as tl
import torch
@triton.jit
def softmax_kernel(
x_ptr, y_ptr,
x_row_stride, y_row_stride,
num_cols,
BLOCK_SIZE: tl.constexpr,
):
row_idx = tl.program_id(0)
col_offsets = tl.arange(0, BLOCK_SIZE)
# Load one row (with masking for the tail)
x_start = x_ptr + row_idx * x_row_stride
x_row = tl.load(x_start + col_offsets,
mask=col_offsets < num_cols,
other=float("-inf"))
# Max-subtract for stability
x_row = x_row - tl.max(x_row, axis=0)
numerator = tl.exp(x_row)
denominator = tl.sum(numerator, axis=0)
y_row = numerator / denominator
# Store
y_start = y_ptr + row_idx * y_row_stride
tl.store(y_start + col_offsets, y_row, mask=col_offsets < num_cols)
def triton_softmax(x: torch.Tensor) -> torch.Tensor:
y = torch.empty_like(x)
M, N = x.shape
BLOCK_SIZE = triton.next_power_of_2(N)
softmax_kernel[(M,)](
x, y, x.stride(0), y.stride(0), N, BLOCK_SIZE=BLOCK_SIZE,
)
return yWhat's happening: the kernel runs thread blocks (one per row). Each block loads the row into SRAM, computes softmax in-block, writes back. The whole thing is one HBM read and one HBM write — the theoretical minimum. The PyTorch softmax does 5 reads and 3 writes because it's composed of multiple An operation done to each number on its own, not by combining them together.Full glossary → operations that each launch a kernel.
The same pattern applies to fused ops in LLM Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →: RMSNorm-and-add, A way of telling a model where each token sits by twisting its number bundle a little more for each later position.Full glossary → application, KV cache write + A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary →. The big serving systems (vLLM, TensorRT-LLM, sglang) ship custom Triton kernels for these. You can read them and learn from them; you can write your own when needed.
Triton is best for kernels that are memory-bound and that PyTorch can't fuse automatically. For pure matmul, NVIDIA's CUBLAS is still faster; that's the case where the existing kernels are already at peak performance.
FIG 17.3.9
Speculative decoding: cheaper draft, exact target sampling
Generation is sequential because A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → depends on token . The classic insight that breaks this: evaluating tokens in parallel is cheaper than generating them one at a time, because evaluation is compute-bound (prefill-like) and generation is memory-bound.
Speculative decoding (Leviathan et al. 2023; Chen et al. 2023): use a small "draft model" to propose candidate tokens at once. Then use the target model to evaluate all in parallel and decide which to accept.
The acceptance rule is rejection sampling. For each draft token with draft A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → and target probability :
- Accept with probability
- If rejected, sample a new token from the residual distribution and discard the rest of the draft.
This is the modified rejection sampling that gives you an exact sample from . The proof (Leviathan §3) is one page of algebra. The intuition: if the draft model is correct often, you accept many tokens per target-model call. If the draft is wrong, the residual distribution corrects it. In expectation, the output sequence is distributed identically to 's output.
In practice: a 1B draft model with a 70B target gives 2-3× speedup on most workloads. A draft model very close to the target (e.g., a distilled version) gives 3-5×. The math is exact; the speedup is empirical.
Variants:
- Medusa (Cai et al. 2024): instead of a separate draft model, train extra "heads" on the target model that predict the next 2-4 tokens directly. No separate model to maintain.
- EAGLE (Li et al. 2024): predict not the next token but the next A number (or short list of numbers) the model keeps rewriting as it reads through a sequence, its running memory.Full glossary →, then decode with the target's head. Higher acceptance rate at lower compute.
- Self-speculative: use a few layers of the target model as the draft (skip later layers for the draft pass).
# Pseudo-code for one speculative decoding step
def speculative_step(target_model, draft_model, prefix, K):
# 1. Draft model proposes K tokens autoregressively
draft_tokens = []
draft_probs = []
ctx = prefix.clone()
for _ in range(K):
logits = draft_model(ctx)[:, -1, :]
probs = logits.softmax(dim=-1)
tok = torch.multinomial(probs, 1)
draft_tokens.append(tok)
draft_probs.append(probs.gather(-1, tok))
ctx = torch.cat([ctx, tok], dim=1)
# 2. Target model evaluates all K tokens in parallel (one forward pass)
target_logits = target_model(ctx)[:, -K-1:-1, :] # logits for each draft position
target_probs = target_logits.softmax(dim=-1)
# 3. Acceptance loop
accepted = []
for i, tok in enumerate(draft_tokens):
q_i = target_probs[0, i].gather(-1, tok)
p_i = draft_probs[i]
if torch.rand(1) < (q_i / p_i).clamp(max=1.0):
accepted.append(tok)
else:
# Sample from residual q - p, return immediately
residual = (target_probs[0, i] - draft_probs[i]).clamp(min=0)
residual = residual / residual.sum()
new_tok = torch.multinomial(residual, 1)
return prefix.new(accepted + [new_tok])
# All K accepted: bonus sample from target's logits beyond position K
bonus = torch.multinomial(target_probs[0, K], 1)
return prefix.new(accepted + [bonus])The speedup pattern: if the draft accepts out of tokens per round, you generate tokens per target Running an example through the model from start to finish to get a guess, which is really just a chain of multiply-and-add steps.Full glossary → (the bonus token at the end). If averages 3 with , you go 4× faster. The "acceptance rate" is the headline metric for any spec-decode implementation.
FIG 17.3.10
Continuous batching: serving dynamic workloads
Training works on dense batches: sequences of the same length, processed together. Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → doesn't. Requests arrive at different times, prompts have different lengths, and generation stops at different lengths (when each request hits its EOS or max-tokens).
The old way (static batching): collect a A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → of requests, pad them to the longest, run the model, return all the responses. Problems: requests wait for the batch to fill (high TTFT — time to first A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary →, how long a user waits before any output appears), short responses sit idle waiting for long responses to finish (low GPU utilization).
The new way (continuous batching, from the Orca paper, Yu et al. 2022): batch at the iteration level, not the request level. Every generation step, check what requests are in flight. If new requests arrived during the last step, add them to the batch (their prefill runs alongside the others' decode). If a request finished, drop it from the batch and free its KV cache. This is the technique that makes vLLM, TensorRT-LLM, and TGI more efficient than naive serving.
The implementation needs:
- A batch where different requests are at different positions in their generation. Some are decoding, some are still prefilling.
- Selective batching: for A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → layers, process each request's KV separately (because each has its own cache). For non-attention layers, concatenate all sequences and run as one big matmul.
- A scheduler that picks the next requests to admit based on a queue policy.
Continuous batching alone gets 2-4× higher throughput than static batching on typical workloads. TTFT improves dramatically because new requests do not wait. They are injected on the next step.
You don't implement continuous batching from scratch. You use a serving stack (vLLM is the most popular open-source one). What you do need to understand is the throughput-latency tradeoff: throughput is total tokens/second the server emits across all requests, latency is how long any one request waits (its TTFT plus per-token generation time). High batch sizes give high throughput but slow per-request generation. Small batch sizes are the opposite. Whatever serving system you pick has a "max concurrent requests" knob that controls this tradeoff. The headline efficiency number people quote is MFU (model FLOPs utilization, actual FLOP/s ÷ the hardware's promised FLOP/s); pushing batch size up is mostly about dragging this number off the floor where memory-bound generation leaves it.
FIG 17.3.11
Paged attention: KV cache as virtual memory
vLLM's signature contribution. The problem: the KV cache for each request is a When a block of data is laid out in memory in tidy, expected order with no gaps.Full glossary → block of GPU memory of size bytes. If you pre-allocate the maximum length, most of that memory is wasted for requests that finish early. If you allocate dynamically and resize, you fragment the GPU memory (just like a malloc-heavy program fragments RAM).
The vLLM solution: page the KV cache the way an OS pages process memory. Divide the cache into fixed-size blocks (e.g., 16 tokens per block). Allocate blocks as needed. Maintain a per-request "block table" that maps logical A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → positions to physical block indices.
Request 1's KV cache (logical):
[block0][block1][block2]...
|
v
Physical KV memory:
[block_42][block_7][block_113]...A request's KV cache no longer needs to be contiguous in GPU memory. New requests can grab free blocks from anywhere. When a request finishes, its blocks return to the free pool. Fragmentation: gone.
The really cool part: prefix caching. Two requests start with the same prompt (a common system prompt, say). Their first blocks of KV are identical. Paged A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → can share those blocks across both requests (copy-on-write semantics if anyone ever writes, which doesn't happen for KV). One copy of the system prompt's KV serves all requests.
This is huge for production. If you have a 2000-token system prompt and serve 1000 concurrent requests, naive caching uses 1000× the system prompt's KV memory. Paged attention with prefix sharing uses 1×. The math says you can serve 4-10× more concurrent requests on the same hardware just from prefix sharing alone.
The cost: the attention kernel has to do a small indirection on every read — look up which physical block to read from. vLLM ships custom The system that lets code run on a graphics chip instead of the main processor.Full glossary → kernels (and now FlashAttention-3 supports paged KV natively) to keep this overhead minimal. The math says ~5% slower per kernel; the practice says you get 4× more concurrent capacity, so the net is +400% throughput.
# vLLM API. PagedAttention happens behind the scenes.
from vllm import LLM, SamplingParams
llm = LLM(model="meta-llama/Llama-2-7b-hf",
gpu_memory_utilization=0.9,
enable_prefix_caching=True,
max_num_seqs=256)
prompts = [
"You are a helpful assistant. User: What is the capital of France?",
"You are a helpful assistant. User: What is the capital of Germany?",
"You are a helpful assistant. User: What is the capital of Italy?",
]
sampling_params = SamplingParams(temperature=0.7, max_tokens=100)
outputs = llm.generate(prompts, sampling_params)
# The "You are a helpful assistant. User: " prefix is shared across all three.FIG 17.3.12
MoE inference: routing, expert parallelism, the all-to-all bottleneck
Mixture-of-Experts models (Switch Transformer, GLaM, Mixtral, OLMoE, DeepSeek-V3) have many "expert" FFN layers per transformer block, but a small router network sends each A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → to only its top-1 or top-2 experts. The model has a lot of parameters but each Running an example through the model from start to finish to get a guess, which is really just a chain of multiply-and-add steps.Full glossary → uses a fraction of them. This is great for training compute (1.5T parameters at 1/5 the training cost of a dense 300B). For Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → it gets weirder.
The first issue: at inference, you can't predict which experts will be used. Different tokens go to different experts, even within one sequence. The KV cache is fine (A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → is dense), but the FFN computation hits a different subset of experts for each token. Two consequences:
- All experts must be in memory. Even though only 2 of 32 experts are active per token, you have to load all 32 from HBM, because you don't know in advance whianother chapter. The memory cost is "as if dense", not "as if active params".
- Routing is dynamic and uneven. Some experts get hit more often than others. A naive batched implementation processes all 32 experts on the same hardware and one becomes the A spot in a model where its width is squeezed small on purpose, forcing the information to compress before it continues.Full glossary → while others sit idle.
For models small enough to fit on one GPU (Mixtral-8x7B at INT4 ≈ 24GB), this is annoying but manageable. For very large MoEs (DeepSeek-V3, ~700B total params, ~37B active), you need expert parallelism: shard the experts across GPUs, route each token's compute to the GPU holding its expert.
This requires all-to-all communication every layer: each GPU sends each token to whatever GPU has its target expert(s). The amount of data shuffled is per layer, and the latency is bounded by the slowest GPU-to-GPU link. On 8× H100s with NVLink (NVIDIA's high-speed interconnect between GPUs, far faster than PCIe), this is fine. On 8× A100s without NVLink, it's a disaster. On 64× distributed nodes, it's "you need a custom kernel and a lot of engineering".
DeepSeek's "DeepEP" kernel (2025) is the open-source state of the art: it overlaps the all-to-all comm with the FFN compute, hiding most of the latency. tutel, Megablocks, and FasterMoE are similar attempts. Production MoE serving is non-trivial systems work.
The practical advice as of 2026: if you can fit a dense model in your memory budget, use a dense model. Use MoE for the cases where you specifically need the One of the model's internal numbers that gets adjusted as it learns.Full glossary → Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary → — long context with many parallel queries, or training-compute-constrained scenarios. MoE inference is rich with sharp edges.
FIG 17.3.13
RoPE extrapolation: NTK, YaRN, LongRoPE
The model was trained with context length 4096. Your application needs context length 32768. Without changes, the model produces garbage past position 4096. A way of telling a model where each token sits by twisting its number bundle a little more for each later position.Full glossary → (another chapter) creates the problem and is also the lever to fix it.
The math: RoPE rotates each Q/K dimension pair by an angle where is the position and is a frequency that depends on the dimension index. The model has learned to interpret cos/sin patterns at positions . At position 5000, the angles are extrapolated; for high-frequency dimension pairs, this means many cycles of rotation that the model has never seen during training. The A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → pattern breaks.
Position Interpolation (PI; Chen et al. 2023): rescale the positions to fit in the training range. If you want to extend from 4096 to 32768, scale all positions by 1/8 before applying RoPE. The model sees "positions" 0 through 4095, just at 1/8 spacing. Works, but loses resolution at short distances.
NTK-aware Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary → (Reddit user bloc97, 2023): instead of rescaling positions uniformly, rescale the base frequency so that low-frequency dimensions (which carry long-range information) extend more, and high-frequency dimensions (short-range) extend less. The math: change to where is the extension factor. Better than PI on most evals.
YaRN (Yet Another RoPE extensioN; Peng et al. 2023): a more careful NTK-aware approach that scales different frequency bands differently, with an attention A dial that controls how much a model gambles on unlikely words versus sticking to the most likely one.Full glossary → adjustment. Works well empirically and is what most open LLMs use for context extension.
LongRoPE (Ding et al. 2024): use evolutionary search to find a non-uniform rescaling that minimizes A score for a language model showing how surprised it is by the test text, lower means less surprised.Full glossary →. Can extend Llama-2-7B from 4096 to 2 million tokens with reasonable retention (though the model still struggles at the extremes).
The practical effect: RoPE-scaling methods can extend the usable context of a model, usually with some combination of rescaling, continued training/Taking a model that already learned a lot of general skills and training it a bit more on your own specific data.Full glossary →, and evaluation at the target length. The result is model- and method-specific: changing a configuration field does not guarantee retained retrieval or reasoning quality.
Do not implement YaRN from a six-line frequency multiplier: the method uses wavelength-dependent interpolation/extrapolation and an attention-temperature correction. In practice, use a framework implementation whose version and model configuration you have pinned, then evaluate perplexity, retrieval, and task The share of guesses the model got right out of all its guesses.Full glossary → across positions up to the advertised limit. The framework's rope_scaling schema is versioned API, not a universal drop-in dictionary.
FIG 17.3.14
Distillation for inference: TinyBERT, MiniLM, Llama-as-teacher
Training a small model to copy the behavior of a big one, learning from its nuanced answers rather than just right-or-wrong labels.Full glossary → (KD) reuses an expensive teacher model to train a cheap student. For Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →, the goal is to make the student fast enough to serve while inheriting the teacher's behavior. The KD loss in the simplest form:
Combining hard-label CE and soft-label KL with a A dial that controls how much a model gambles on unlikely words versus sticking to the most likely one.Full glossary → that smooths the teacher's distribution. The factor compensates for the A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary → that the temperature introduces. This is the formulation from Hinton et al. 2015.
For LLMs, the dominant recipe is response-level distillation: have the teacher generate responses to a corpus of prompts, then SFT-tune the student on those (prompt, response) pairs. This works because:
- You don't need access to the teacher's logits (just its outputs).
- It avoids the distribution-shift problem of fixed-corpus distillation.
- It composes with RLHF naturally. The student inherits aligned behaviors.
This is essentially what Vicuna, Alpaca, and Orca did with ChatGPT outputs in 2023. Now most "small open model" releases (Llama-3-8B-Instruct as distilled from Llama-3-70B, Mistral's smaller models, etc.) are some form of distilled-from-bigger-model.
A more aggressive recipe combines distillation with architecture pruning. The pipeline:
- Identify which layers / heads / dimensions matter least, using How well a model's stated confidence matches how often it's actually right.Full glossary → data.
- Remove them. Now you have a smaller, broken model.
- Distill the original (large, working) model into the pruned one.
NVIDIA's pruning-and-distillation paper (Muralidharan et al. 2024) shows this on Llama-3-8B compressed to ~3B parameters with the original model as teacher. Higher quality than training a 3B from scratch.
DistilBERT (Sanh et al. 2019) is the canonical case study. BERT-base distilled to half the parameters, 60% faster inference, 97% of the original The share of guesses the model got right out of all its guesses.Full glossary → on GLUE. The loss combined distillation, hard-label CE, and a cosine A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary → loss between teacher and student hidden states. The hidden-state alignment turned out to matter. Pure The raw score the model outputs before it's converted into a clean percentage with sigmoid or softmax.Full glossary → distillation gave less retention.
FIG 17.3.14
5. LoRA and PEFT: parameter-efficient fine-tuning meets inference
Distillation makes a small model that emulates a big one. A cheap way to fine-tune by training small add-on pieces while leaving the big original model frozen.Full glossary → does something different: it keeps the big model, freezes it, and learns a tiny rank-r update that nudges its behavior. At Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →, the update merges back into the base weights at zero cost.
The math is brief. A pre-trained linear layer has A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → . LoRA freezes and learns two low-rank matrices and with . The new weight is:
where is a Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary → constant (typically 8–32). For Llama-3-8B with and , the per-layer LoRA adds parameters versus the original layer's . That is 0.78% of the original size, trainable.
You apply LoRA to specific layers. The Vaswani-block convention is to add it to and in A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → (sometimes and too). Empirical: targeting attention projections gives most of the benefit; the FFN can be left untouched and the model still adapts.
Library path (HuggingFace PEFT):
from peft import LoraConfig, get_peft_model
from transformers import AutoModelForCausalLM
base = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3-8B", torch_dtype="bfloat16")
config = LoraConfig(
r=16,
lora_alpha=32,
target_modules=["q_proj", "v_proj"], # attention only
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
)
model = get_peft_model(base, config)
model.print_trainable_parameters() # "trainable params: 6.3M || all params: 8B || trainable%: 0.08%"
# Train normally. Save just the adapter (a few MB) instead of the whole model.From-scratch path (the math, in 20 lines):
import torch
import torch.nn as nn
class LoRALinear(nn.Module):
def __init__(self, base: nn.Linear, r: int = 16, alpha: float = 32.0):
super().__init__()
self.base = base
for p in self.base.parameters():
p.requires_grad = False
self.A = nn.Parameter(torch.zeros(r, base.in_features))
self.B = nn.Parameter(torch.zeros(base.out_features, r))
nn.init.kaiming_uniform_(self.A, a=5**0.5) # A non-zero
# B stays zero at init -> output identical to base at step 0
self.scaling = alpha / r
def forward(self, x):
return self.base(x) + (x @ self.A.T @ self.B.T) * self.scaling
def merge(self):
"""Fold LoRA back into base weights. Zero inference overhead after this."""
with torch.no_grad():
self.base.weight.data += (self.B @ self.A) * self.scaling
# After merge: forward() returns base(x) only; remove or zero A, B.The B-zero-at-init detail matters. It guarantees that at training step 0 the model's output is identical to the frozen base. Training only changes the model gradually as A and B move off zero.
QLoRA (Dettmers et al. 2023) is the high-leverage combination: quantize the base model to NF4 (a 4-bit format optimized for normal-distributed weights), keep A and B in BF16, train. The result: you can fine-tune a 65B model on a single 48 GB GPU. The base stays quantized during training; only the LoRA adapters update. At inference, you keep the base quantized and store the adapter separately. Multiple adapters can share the same base in memory.
LoRA at inference. Three patterns:
- Merged:
W' = W + \alpha B A. Run the merged weights. Zero inference overhead. The cost is you cannot swap adapters quickly. - Hot-swap: keep base weights frozen in memory, swap adapter weights per request. Adds one matmul per LoRA-targeted layer per Running an example through the model from start to finish to get a guess, which is really just a chain of multiply-and-add steps.Full glossary → (cheap). vLLM and TGI both support this. Lets you serve N user-specific fine-tunes off one base model.
- Multi-adapter batching (SLoRA, Punica): A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → requests across adapters by reshuffling the per-adapter matmuls. This is the cutting edge as of 2026 and is what lets a single GPU serve thousands of user-specific fine-tunes simultaneously.
The reason this section sits inside an inference chapter (and not a training chapter) is that the inference patterns above are what make LoRA economically interesting. Training a LoRA is easy. Serving fine-tunes at scale is where the engineering lives.
FIG 17.3.15
Real-world deployment: vLLM, TGI, TensorRT-LLM, Together, Fireworks
Where do all these techniques live in practice? The serving-stack landscape as of 2026:
vLLM (UC Berkeley + community, open source). The reference implementation of paged A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary →, continuous batching, prefix caching. Supports most quantization formats (AWQ, GPTQ, FP8). The model coverage is wide. Most open LLMs are first-class supported within weeks of release. The user interface is OpenAI-compatible. This is the default open-source serving stack and what most labs deploy on their own hardware.
TensorRT-LLM (NVIDIA, semi-open). The fastest single-GPU implementation, especially on H100. Uses NVIDIA-proprietary kernels (TMA, async cudaMalloc, custom matrix multiplies) that vLLM doesn't access. Harder to use, smaller model coverage. The standard for cost-optimized commercial deployment on NVIDIA hardware.
TGI (HuggingFace Text Generation Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →, open). HuggingFace's serving stack, integrated with their model hub. Easier deployment if you already use the HF ecosystem. Slightly behind vLLM on raw throughput as of 2026.
SGLang (open source). Focuses on multi-step / structured generation workloads: KV cache reuse across multiple queries, RadixAttention (a trie-based KV cache for shared prefixes), efficient JSON/regex-constrained generation. Where vLLM and TGI optimize per-request, SGLang optimizes the cross-request structure.
Together AI, Fireworks, DeepInfra (commercial). Hosted versions of vLLM and TensorRT-LLM (plus their own proprietary improvements). Their value-add is reliability, multi-region, and aggressive performance optimization. They expose pretty much the same API and charge by A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary →.
The architectural distinctions blur quickly. They all use paged attention, continuous batching, FlashAttention, speculative decoding, and the major quantization methods. What differs is the kernel-level engineering, the scheduler policies, and the integration polish.
The advice for new deployments:
- Prototyping: vLLM, FP16, batanother chapter. Get the model working.
- Cost-optimizing: AWQ or GPTQ quantization, prefix caching enabled, batanother chapter-256. Profile and tune.
- Latency-optimizing: smaller A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary →, speculative decoding with a draft model, fewer concurrent requests.
- Need exotic features (RAG with structured outputs, multi-turn with cache, etc.): SGLang or roll your own on top of vLLM.
FIG 17.4 · Safety lens · this chapter
Efficient Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → is not safety-neutral. Each technique changes the model's behavior, exposes new state to the attack surface, or creates new side channels. Three categories of failure that the rest of this chapter creates.
KV cache leakage across tenants. Prefix caching is a huge throughput win because the KV cache for a shared prompt prefix can be reused across requests. The problem: in a multi-tenant deployment (your SaaS serves users A, B, C through the same vLLM), the same prompt prefix from different users hits the same cached KV. This is usually fine for explicitly-public content (a system prompt). It is not fine if any user-specific information is in the prefix. Yan et al. 2024 (KV cache memory disclosure) and follow-up work showed that an attacker who can submit prompts and time the responses can detect cache hits: if your prompt's prefix is already cached, the first-A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → latency is dramatically lower. This is a timing side One of several stacked grids of numbers in an image, each tracking a different kind of pattern.Full glossary → that leaks information about what other users have prompted. If user A's prompt contained "my API key is sk-abc123...", a user B with a guess for that prefix can confirm or deny it from latency alone. Mitigations: per-tenant cache namespacing (vLLM supports this), or disabling prefix caching for sensitive workloads. See 26-pentest-redteam/embracethered-com-blog §timing-side-channels and 18-lilian-weng/2023-10-25-adv-attack-llm §extraction-attacks for the broader extraction-attack literature.
Speculative decoding side channels. Speculative decoding's acceptance rate depends on how well the draft model's distribution matches the target's. The acceptance pattern is visible in the response timing: if many tokens are accepted, the response is fast; if many are rejected, it's slow. An attacker can use this as an oracle to infer something about the target model's distribution on a controlled prompt. Carlini et al. 2024 (Stealing Part of a Production Language Model) showed that you can extract A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary →-projection layers and token probabilities from large API models with surprising efficiency by analyzing token-level timing patterns. Speculative decoding's per-token timing variability widens this attack surface. The mitigation is "do not expose per-token timing information to untrusted clients" — server-side buffering of responses, fixed-rate streaming, or adding noise to response times. The major commercial APIs (OpenAI, Anthropic) have implemented buffering specifically for this. Self-hosted deployments often have not.
Quantization-induced behavior drift. This is the safety failure I find most underappreciated. A model quantized to INT4 has the same A score for a language model showing how surprised it is by the test text, lower means less surprised.Full glossary → as the FP16 model on the A separate batch of examples you check the model against while you're still tinkering, to see how it's doing.Full glossary →, ±1%. It does not have the same behavior on out-of-distribution prompts, including adversarial ones. Egashira et al. 2024 (Exploiting LLM Quantization) demonstrated that you can train a model whose FP16 version passes safety evals but whose INT4 quantized version produces harmful outputs on specific adversarial prompts. The attack works because quantization concentrates floating-point Out of all the times the model shouted 'yes,' how often it was actually right.Full glossary → on the high-magnitude directions in the A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → space; an adversary who can choose the model's weights (e.g., a fine-tuner who returns weights to be deployed) can encode the harmful behavior in the low-magnitude directions that quantization will collapse together. The implication: quantize, then re-evaluate on your full safety suite. Perplexity is not enough. A model that was aligned at FP16 may be silently mis-aligned at INT4. See 26-pentest-redteam/owasp-org-www-project-top-10-for-large-language-model-applications §LLM06-sensitive-information-disclosure and the recent literature on "alignment robustness under quantization" (Hubinger et al.'s sleeper-agents framing is the closest analog in safety research).
What habits to adopt when deploying efficient inference:
- Re-run your full safety eval after every quantization or distillation step. Not just perplexity. The full adversarial prompt set you used pre-quantization. If results drift more than a few percent, dig in. This is cheap to do and catches most quantization-induced regressions.
- Disable prefix caching in multi-tenant deployments by default; whitelist specific shared prefixes (system prompts) that you've verified are public. vLLM's
--enable-prefix-caching=falseflag exists for a reason. - Add response-time buffering at your serving layer. Pick a fixed minimum latency budget; if your model finishes early, hold the response until the budget elapses. This costs a few hundred ms of latency and closes the timing side channel for both prefix caching and speculative decoding.
- Audit your serving config for KV cache cleanup on request end. A request that crashes mid-stream should have its KV blocks returned to the pool and zeroed. vLLM does this; many less-mature serving stacks do not, leaving residual KV in physical memory that a subsequent request might re-allocate and read. This is the GPU analog of memory-safety bugs.
FIG 17.5 · Under the hood
The library call, and the lines it hides
You don't have to choose between “use the library” and “build it from scratch.” Here is the one library call, the exact lines it stands in for, and when to reach for which on the job.
target.generate(assistant_model=draft) vs. greedy speculative decoding from scratch
DL gluetarget = AutoModelForCausalLM.from_pretrained("gpt2-large")
draft = AutoModelForCausalLM.from_pretrained("gpt2") # smaller, SAME tokenizer
tok = AutoTokenizer.from_pretrained("gpt2-large")
inputs = tok("The meaning of life is", return_tensors="pt")
# assistant_model = draft; greedy => output identical to target.generate alone
out = target.generate(**inputs, assistant_model=draft, do_sample=False, max_new_tokens=64)while generated < max_new_tokens:
proposal = draft.generate_no_kv(idx.clone(), K) # draft proposes K greedy tokens
proposed = proposal[:, -K:]
verify_in = torch.cat([idx, proposed], dim=1)
target_logits = target(verify_in, use_cache=False) # ONE target pass verifies all K
L = idx.shape[1]
target_preds = target_logits[:, L - 1: L + K - 1, :].argmax(-1) # (B, K)
match = (target_preds == proposed).all(dim=0)
n_accept = 0
for i in range(K):
if bool(match[i]):
n_accept += 1
else:
break
if n_accept == K: # all K agreed -> free bonus token
extra = target_logits[:, L + K - 1, :].argmax(-1, keepdim=True)
new_tokens = torch.cat([proposed[:, :n_accept], extra], dim=1)
else: # first disagreement -> target's token
correction = target_preds[:, n_accept:n_accept + 1]
new_tokens = torch.cat([proposed[:, :n_accept], correction], dim=1)
idx = torch.cat([idx, new_tokens], dim=1)
accepted_total += n_accept
proposed_total += K
generated += new_tokens.shape[1]from scratch: lab/solution.py: speculative_generate
- 1
target.generate(assistant_model=draft) — one call hides the propose/verify/accept loopthe while-loop: draft.generate_no_kv proposes K, target() verifies, the acceptance loop emits accepted prefix + correction - 2
assistant_model proposing K candidate tokens autoregressivelyproposal = draft.generate_no_kv(idx.clone(), K); proposed = proposal[:, -K:] - 3
single batched target forward that scores all K positions at oncetarget_logits = target(torch.cat([idx, proposed], 1)); target_preds = target_logits[:, L-1:L+K-1].argmax(-1) - 4
accept-longest-matching-prefix verificationmatch = (target_preds == proposed).all(0); count n_accept until the first False - 5
the 'free' bonus token when the whole draft is acceptedif n_accept == K: extra = target_logits[:, L+K-1].argmax(-1) appended after the proposal - 6
rollback / correction on the first rejected tokenelse branch: correction = target_preds[:, n_accept:n_accept+1] replaces the first mismatch and discards the rest - 7
exact-distribution guarantee (output == plain target decode)greedy form: accept where draft argmax == target argmax, else take target argmax — provably identical to target greedy decode
What the one call hides
- The position-alignment arithmetic: target logits at index L-1+i predict the i-th proposed token, so the verify slice is [L-1 : L+K-1] — an off-by-one here silently rejects everything.
- That the speedup comes entirely from running the target ONCE over K tokens (compute-bound, prefill-like) instead of K sequential memory-bound decode steps.
- The acceptance-rate accounting (accepted_total / proposed_total) that decides whether you actually got a speedup at all.
- That the draft and target must share a tokenizer/vocab; HF checks this and errors, the from-scratch loop just assumes it (and silently never accepts if it is wrong).
- In sampling mode the library uses modified rejection sampling (accept with prob min(1, q/p), then draw from the residual max(0,q-p)) to stay EXACT; the solution only covers the greedy special case where 'accept on argmax match' is trivially exact.
- Gotcha: This is the GREEDY variant only: it equals target greedy decode but says nothing about sampled (temperature>0) decoding — do not assume the same code is exact under sampling; that needs the q/p rejection rule.
- Gotcha: If the draft is not actually cheaper/faster than the target (or proposes badly), speculative decoding is SLOWER, not wrong — there is a latency penalty but no correctness one.
- Gotcha: K is a knob: too large wastes draft work on tokens that get rejected, too small under-amortizes the target pass; the library hides this behind num_assistant_tokens heuristics.
- Gotcha: speculative_generate can overshoot max_new_tokens by a token because each round emits the accepted prefix plus a bonus/correction; compare on the shared prefix when checking against a fixed-length reference.
Use the library's assistant_model (or vLLM speculative decoding) in production; write the from-scratch loop to internalize that the trick is 'verify K tokens in one cheap parallel target pass and keep the matching prefix,' and that the greedy form is provably free (zero correctness risk) while the sampling form needs the q/p rejection rule to stay exact.
On the job: On the job you write the draft/verify scheduling glue — how many tokens to propose, how to batch the verify pass, how to roll back and re-prefill the KV cache after a rejection, and the acceptance-rate telemetry that tells you if the draft is worth it.
FIG 17.6 · Chapter notebook
Build this chapter with your own hands
A single self-contained notebook. You implement the ideas, check yourself against assert cells as you go, then finish with a capstone. Hint ladders and folded solutions throughout, so it runs top-to-bottom even before you fill anything in.
What you'll build
- A tiny GPT (same architecture as Ch 15), then a KV cache bolted onto its attention, with a torch.equal correctness proof and a wall-clock timing proof that caching is faster.
- The arithmetic-intensity calculator: feed it a layer's shapes and it tells you compute-bound vs memory-bound, and where the memory-bandwidth wall sits on real hardware.
- An int8 round-trip quantizer you write from scratch, with a measured error bound, then a per-tensor-vs-per-channel comparison that shows why granularity matters.
- A deliberate bug: a "cached" decode that returns the wrong tokens because the position index is off by the cache length. You see it fail, then fix it.
~4 min on CPU · 104 cells · 9 checked exercises · runs in Colab
FIG 17.7 · Going further
27-framework-docs/vllm-docs-vllm-ai-en-latestthe production-canonical serving stack. Read
paged-attentionandcontinuous-batchingdocs after this chapter. The kernel-level details are in the source.04-stanford/cs336-lecture_06kernels and Triton. The 30-minute path from "what is a GPU" to "I wrote my own softmax kernel".
04-stanford/cs336-lecture_10the entire CS336 inference lecture. This chapter's spine. Source-of-truth for the arithmetic intensity math.
04-stanford/cs336-lecture_08parallelism (tensor, pipeline, expert). The serving counterpart to this chapter's single-GPU focus.
24-founder-blogs/dettmers-llm-int8-and-emergent-featuresDettmers on emergent outliers. Best resource for why quantization gets hard at scale, not just how.
18-lilian-weng/2023-01-10-inference-optimizationLilian Weng's overview. Encyclopedic; pair with this chapter for breadth.
01-explorables/unknown-karpathy-llama2.cKarpathy's clean re-implementation of Llama-2 inference in C, ~700 lines. The reference for understanding what the inner loop of an inference engine looks like.
26-pentest-redteam/embracethered-com-blogfor the security-pattern complement. Wunderwuzzi catalogs actual exploits, including some that touch on prefix caching and timing oracles.
29-practice-engineering/lucidrains-flash-attentionthe Jax-based educational reproduction of FlashAttention. Smaller than the official CUDA version, easier to read.
FIG 17.8 · What this enables
Chapters you can now read, with the connecting idea written out.
text-to-image inference also benefits from many of these techniques (KV cache for the UNet text conditioning, batch packing of diffusion steps, quantization). The patterns transfer.
PPO and DPO require sample generation at every training step. The cost of one PPO iteration is dominated by inference. Knowing how to make inference fast makes RLHF feasible.
agents call the model many times (one per tool call, one per reasoning step). Per-call latency matters more than per-call throughput. The chapter on agents will pull from this one heavily.
the safety lens in this chapter foreshadows the deeper coverage there. Prefix-cache timing attacks, quantization-induced drift, and speculative-decoding side channels are all covered with red-team-grade depth.
production deployment is the practical complement. This chapter is the "what". MLOps is the "how do you run it reliably for 6 months".
FIG 17.9 · 29 sources
- 01-explorables/unknown-karpathy-llama2.c
- 04-stanford/cs336-lecture_02
- 04-stanford/cs336-lecture_06
- 04-stanford/cs336-lecture_06_mlp
- 04-stanford/cs336-lecture_06_utils
- 04-stanford/cs336-lecture_08
- 04-stanford/cs336-lecture_10
- 12-karpathy-code/nanoGPT-master-model
- 12-karpathy-code/nanoGPT-master-train
- 14-arena-notebooks/chapter0-part3-optimization
- 18-lilian-weng/2021-09-25-train-large
- 18-lilian-weng/2023-01-10-inference-optimization
- 18-lilian-weng/2023-01-27-the-transformer-family-v2
- 18-lilian-weng/2023-10-25-adv-attack-llm
- 22-anthropic-recent/2024-scaling-monosemanticity-index
- 24-founder-blogs/dettmers-llm-int8-and-emergent-features
- 24-founder-blogs/dettmers-which-gpu-for-deep-learning
- 24-founder-blogs/dettmers-deep-learning-hardware-guide
- 24-founder-blogs/raschka-understanding-large-language-models
- 26-pentest-redteam/embracethered-com-blog
- 26-pentest-redteam/owasp-org-www-project-top-10-for-large-language-model-applications
- 26-pentest-redteam/www-anthropic-com-news-many-shot-jailbreaking
- 27-framework-docs/vllm-docs-vllm-ai-en-latest
- 27-framework-docs/vllm-docs-vllm-ai-en-latest-serving-openai-compatible-server-html
- 27-framework-docs/triton-triton-lang-org-main
- 27-framework-docs/triton-triton-lang-org-main-getting-started-tutorials-01-vector-add-html
- 27-framework-docs/pytorch-pytorch-org-tutorials-recipes-recipes-saving-and-loading-models-for-inference-ht
- 29-practice-engineering/lucidrains-flash-attention
- 06-practice/lilianweng-posts-2023-01-10-inference-optimization