Training a language model is one big batched forward-and-backward pass. Generating from it is a loop that produces one token per iteration, and almost everything that makes inference engineering its own discipline follows from that one fact. This post walks through the loop, does the KV-cache memory arithmetic for a 7B-class model with explicit assumptions, verifies the key property of rotary position embeddings numerically, and implements the sampling knobs that sit at the end of every step.
The decode loop
A decoder-only transformer defines
- Prefill. Run the prompt tokens
through the model in one batched pass. For every layer, keep the key and value vectors of every position; this is the KV cache. Take the logits at the last position. - Sample a token from those logits (temperature, top-k, top-p, or greedy).
- Decode step. Feed only the new token. In each layer, compute its query, key and value; append the key and value to the cache; attend the single query over all cached keys and values; continue to the next layer.
- Repeat from step 2 until an end-of-sequence token or a length limit.
Without the cache, step 3 would have to recompute keys and values for all previous tokens, making the cost of generating
The two phases have opposite performance characters. Prefill multiplies a
TIP
A quick lower bound on single-sequence decode latency is bytes that must be read per step divided by memory bandwidth. A 7B model in 16-bit weights is about 13.5 GB; on an accelerator with 2 TB/s of memory bandwidth that is at least 6.7 ms per token, or at most about 150 tokens per second at batch size 1, before the cache is even counted. Batching more sequences reuses each weight read and is how serving systems recover throughput.
KV-cache memory arithmetic
Per token, the cache stores one key and one value vector per layer per key/value head:
where the leading 2 counts keys and values. Total cache memory is that number times context length times batch size. The table uses these assumptions: 32 layers, head dimension 128, and either 32 KV heads (multi-head attention, as in Llama-2-7B) or 8 KV heads (grouped-query attention, as in Mistral-7B and Llama-3-8B), with 16-bit or 8-bit cache elements.
| Configuration (assumed) | n_kv | Bytes / token | 4,096 tokens | 32,768 tokens | Batch 16 × 4,096 |
|---|---|---|---|---|---|
| L = 32, d_head = 128, MHA, fp16 | 32 | 524,288 B = 0.5 MiB | 2.0 GiB | 16 GiB | 32 GiB |
| L = 32, d_head = 128, MHA, int8 cache | 32 | 262,144 B = 0.25 MiB | 1.0 GiB | 8 GiB | 16 GiB |
| L = 32, d_head = 128, GQA (8), fp16 | 8 | 131,072 B = 128 KiB | 0.5 GiB | 4 GiB | 8 GiB |
| L = 32, d_head = 128, GQA (8), int8 | 8 | 65,536 B = 64 KiB | 0.25 GiB | 2 GiB | 4 GiB |
The worked number for the first row:
Memory is only half of the problem: the cache is allocated per sequence and grows unpredictably, which fragments accelerator memory. Paged attention, which stores the cache in fixed-size blocks indexed through a page table, is the systems answer and is the core idea in vLLM.
Rotary position embeddings
Attention has no notion of order, so position must be injected. Rotary position embeddings (RoPE) do it inside the attention scores. Split the
The property that makes it work: rotating the query by
because a product of two rotations is a rotation by the difference of the angles. Relative position therefore enters every attention score, absolute position does not, and nothing is added to the residual stream.
def rope(x, pos, base=10000.0):
"""Rotate the pairs (x[i], x[i + d/2]) of each row by pos * theta_i."""
d = x.shape[-1]
half = d // 2
theta = base ** (-np.arange(half) * 2.0 / d) # theta_i = base^(-2i/d)
ang = np.asarray(pos)[:, None] * theta[None, :] # (T, d/2) angles
c, s = np.cos(ang), np.sin(ang)
x1, x2 = x[..., :half], x[..., half:]
return np.concatenate([x1 * c - x2 * s, x1 * s + x2 * c], axis=-1)
q, k = rng.normal(size=(1, 64)), rng.normal(size=(1, 64))
score = lambda m, n: float(rope(q, [m])[0] @ rope(k, [n])[0])
print(max(abs(score(10 + s, 6 + s) - score(10, 6)) for s in range(0, 512, 8)))
The printed maximum deviation over shifts of up to 512 positions is
Why the dot product depends only on m − n
Each pair
At decode time this matters for the cache: the cached keys are stored already rotated by their own positions, so a new query only needs its own rotation. Extending context beyond the training length is then a question of what to do with angles larger than any seen in training, which is what position interpolation and frequency-scaling schemes address.
Sampling
The last step of each iteration turns logits
-
Divides the logits by
before the softmax, . sharpens (the top token gained from 0.27 to 0.63 of the mass at in the example below), flattens, is greedy. It changes every probability but never removes a token.def sample_next(logits, rng, temperature=1.0): z = logits / max(temperature, 1e-8) p = np.exp(z - z.max()) p /= p.sum() return int(rng.choice(len(p), p=p)) -
Keeps the
highest logits and renormalizes. Simple, but is a poor fit for both peaked distributions (where includes junk) and flat ones (where it cuts off plausible tokens).def sample_next(logits, rng, top_k=50): z = np.where(logits >= np.sort(logits)[-top_k], logits, -np.inf) p = np.exp(z - z.max()) p /= p.sum() return int(rng.choice(len(p), p=p)) -
Keeps the smallest set of tokens whose cumulative probability reaches
. The set adapts to the shape of the distribution: in the example, kept 13 of 20 tokens.def sample_next(logits, rng, top_p=0.9): p = np.exp(logits - logits.max()) p /= p.sum() order = np.argsort(-p) cum = np.cumsum(p[order]) keep = order[: int(np.searchsorted(cum, top_p)) + 1] # smallest prefix reaching top_p mask = np.zeros_like(p, dtype=bool) mask[keep] = True p = np.where(mask, p, 0.0) p /= p.sum() return int(rng.choice(len(p), p=p))
The knobs compose: temperature is applied first, then truncation, then the draw. Repetition penalties and the various "min-p" schemes are further edits to the same vector. All of them are cheap compared with the forward pass, so the choice is about output quality, not speed.
Where the time goes, in one sentence
Per generated token, a 7B-class model reads all of its weights and the whole KV cache once; everything else about inference engineering (batching, quantized weights and caches, grouped-query attention, paged memory, speculative decoding) is a way to read fewer bytes per useful token.
Further reading
- Su et al. (2021), RoFormer: enhanced transformer with rotary position embedding: https://arxiv.org/abs/2104.09864
- Holtzman et al. (2019), The curious case of neural text degeneration, which introduced nucleus sampling: https://arxiv.org/abs/1904.09751
- Ainslie et al. (2023), GQA: training generalized multi-query transformer models from multi-head checkpoints: https://arxiv.org/abs/2305.13245
- Shazeer (2019), Fast transformer decoding: one write-head is all you need, the multi-query original: https://arxiv.org/abs/1911.02150
- Kwon et al. (2023), Efficient memory management for large language model serving with PagedAttention: https://arxiv.org/abs/2309.06180
- Leviathan, Kalman and Matias (2022), Fast inference from transformers via speculative decoding: https://arxiv.org/abs/2211.17192
- Model configurations used for the assumptions above: Llama 2 (https://arxiv.org/abs/2307.09288) and Mistral 7B (https://arxiv.org/abs/2310.06825)