Module 04 · Chapter 13
KV Cache Implementation and Memory Accounting
Update the cache correctly across positions, verify cached and uncached logits match, and calculate memory at a stated precision.
Learning objectives
- Implement a correct per-layer KV cache update and track position across prefill and decode steps
- Verify that cached and uncached next-token logits agree within numerical tolerance
- Calculate raw KV cache bytes from stated tensor assumptions and validate with a numerical example
Before you start
- Read Understanding the KV cache (cache growth and memory estimation)
- Read Prefill and Decode: Measuring the Two Phases
- Comfort reading Python and basic PyTorch
Lab: Run the companion CPU exercise at
/labs/track1/03-cache-and-gqa/(per-layer cache update, logit parity, memory accounting).
KV Cache Implementation and Memory Accounting
Section titled “KV Cache Implementation and Memory Accounting”Why per-layer cache updates matter
Section titled “Why per-layer cache updates matter”In a decoder-only transformer, every attention layer maintains its own key and value cache. During prefill, the entire prompt is processed in one parallel forward pass, and the K/V tensors for every prompt token are stored. During decode, one new token is consumed per step: its key and value are computed, appended to the cache, and the next-token logits are produced.
The critical detail is that each layer writes its own K/V column independently. If a layer’s cache write is off by one position, or if the position index is misaligned with the attention mask, the next token can attend to the wrong key/value pair, producing incorrect logits. This lesson walks through the per-layer update, verifies logit parity with an uncached reference, and quantifies the memory cost.
Token trace: prefill through two decode inputs
Section titled “Token trace: prefill through two decode inputs”Consider a causal decoder with a 3-token prompt t1, t2, t3 and greedy generation of two output tokens. The trace below tracks cache length, K/V shape, and the token whose logits are produced at each step.
KV cache trace: prefill → g1 → g2
| Step | Prompt / Input | Cache length | K/V shape (per layer) | What happens | Logits predict |
|---|---|---|---|---|---|
| 0 | Prompt tokens t1, t2, t3 available |
— | — | Three tokens ready for processing | — |
| 1 | Prefill: t1, t2, t3 processed together in one forward pass. Final prompt-position logits predict the first generated token. K/V states for all 3 tokens are stored in the cache. |
P = 3 | [B, H, 3, D] |
All three K/V pairs written to their respective cache slots. | g1 (first generated token) |
| 2 | Decode step 1: consume token g1. With the KV cache, the new token attends to cached K/V for t1, t2, t3 plus its own K/V. Cache grows from 3 to 4. Logits predict the next token. |
P+1 = 4 | [B, H, 4, D] |
g1’s key and value are appended as column index 3. The attention mask now includes the new token position. |
g2 |
| 3 | Decode step 2: consume token g2. Attend to cached K/V for t1, t2, t3, g1, plus g2’s own K/V. Cache grows from 4 to 5. |
P+2 = 5 | [B, H, 5, D] |
g2’s key and value are appended as column index 4. |
— (trace end) |
Read this diagram as text
Prefill fills the cache with 3 columns (one per prompt token). Decode step 1 appends g1's K/V, extending the cache to 4 columns; its logits predict g2. Decode step 2 appends g2's K/V, extending the cache to 5 columns; its logits would predict g3.
Key point: Each decode step processes exactly one new token. The cache extends by one column per step. The prefill pass already produces the logits for
g1; decode step 1 consumesg1and produces logits forg2. No extra model pass is needed to produce the first generated token.
Shape annotations for the trace
Section titled “Shape annotations for the trace”- Prefill K/V:
[B, H, P, D]— one key/value pair per token, per KV head, per head dimension, for all prompt tokens together. With B=1, H=8, P=3, D=64 this is[1, 8, 3, 64]. - New K/V per decode step:
[B, H, 1, D]— the key and value for the single new token, computed anew and written into the cache at the next free slot. - Usable K/V after g1:
[B, H, P+1, D]— the cache now contains the original P prompt columns plus 1 generated column. Any future attention pass can read all P+1 columns. - Usable K/V after g2:
[B, H, P+2, D]— the cache contains P prompt columns plus 2 generated columns.
These shapes assume a conventional decoder-only architecture where every attention layer stores K and V for every token in the context, across all KV heads. Grouped-query attention (GQA) would change the H dimension (to the number of KV heads) but the overall structure is the same.
Per-layer cache write: mechanics
Section titled “Per-layer cache write: mechanics”Each transformer layer l maintains a key cache and a value cache. The write operation for a single decode step can be expressed as:
# Pseudocode: write new K/V into layer l's cachecache_key[l][:, :, write_position, :] = new_key # shape: [B, H, 1, D]cache_value[l][:, :, write_position, :] = new_valuewhere write_position is the current cache length (number of tokens already stored). After prefill with P prompt tokens, write_position = P. After each decode step, write_position increments by 1.
The read operation for attention in the same layer uses a causal mask that allows the query at position s to attend to all key/value positions 0 … s (including the newly written column). In practice, the framework’s past_key_values argument handles this: when past_key_values is passed, the attention mechanism reads from the cached K/V and combines it with the new token’s Q, K, V so that the causal mask and cached storage are consistent.
Common mistake: Writing the new K/V at slot write_position + 1 instead of write_position. This shifts every subsequent read by one, causing the attending token to see a zero column (or wrap-around) and producing silently wrong logits. The trace above guards against this by always incrementing the cache length after the write is confirmed.
Tensor axes: query heads versus KV heads
Section titled “Tensor axes: query heads versus KV heads”In a model with n_query_heads query heads and n_kv_heads key/value heads:
| Dimension | Meaning | Typical model example |
|---|---|---|
n_query_heads |
Number of Q projection heads, one per attention head | Llama-2-7B: 4 |
n_kv_heads |
Number of K/V projection heads, shared across multiple Q heads (GQA/MQA) | Llama-2-7B: 1 (MQA) / Llama-2-70B: 8 |
Head dimension d |
Size of each head’s key/value/query vector | Llama-2-7B: 64 |
The cache stores only the K/V dimensions, i.e. n_kv_heads × d per layer per token. The Q projection still operates on all n_query_heads, but the K/V it attends to come from the smaller KV cache.
GQA effect on cache size: If n_query_heads = 32 and n_kv_heads = 8, the cache stores 8 KV heads instead of 32, reducing KV bytes by 4×. Query computation is unchanged; only the K/V read path is smaller.
For the numerical example in this lesson, I’ll assume a plain-full-attention model where n_query_heads = n_kv_heads = H, so the cache dimension H equals the number of attention heads. This keeps the byte formula straightforward, and the GQA reduction can be discussed as a lever in the “What you can trade” section.
Cached versus uncached logit parity
Section titled “Cached versus uncached logit parity”Claim: If the same causal attention computation is performed (1) with the KV cache enabled and (2) without the cache (full sequence K/V passed explicitly), the next-token logits should agree to within floating-point tolerance.
Verification setup: Run a tiny decoder-only model through two forward passes:
- Cached pass:
model(input_ids, past_key_values=initial_past, use_cache=True)— the model reads K/V frompast_key_valuesand appends the new token’s K/V. - Uncached pass:
model(input_ids_full, use_cache=False)— the model receives the entire sequence including the new token, so attention computes Q/K attention over all tokens without a cache.
The logits from the last position should satisfy:
‖logits_cached − logits_uncached‖_∞ < εwhere ε depends on precision:
- FP32:
ε ≈ 1e-5(machine epsilon for float32) - FP16:
ε ≈ 1e-3(reduced precision introduces rounding in softmax and matmul)
If the difference exceeds ε, check: (a) the cache write position is correct, (b) the causal mask aligns with the extended cache length, (c) the query/key/value projections have the same shape in both paths.
Source: This parity check is a standard regression test in Transformer implementations (e.g., Hugging Face transformers cache test suite) and is mentioned in the vLLM prefix caching design.
Raw cache bytes: assumptions and a checked numerical example
Section titled “Raw cache bytes: assumptions and a checked numerical example”The raw KV cache size depends on several stated assumptions. Below is the formula and a worked example that you can reproduce with a calculator or a small Python script.
Formula
Section titled “Formula”Let:
L= number of transformer layersH= number of key/value heads per layerD= head dimension (the size of each key/value vector)S= total sequence length, including prompt and every generated tokenB= batch size (concurrent sequences)b= bytes per stored value (2 for FP16, 4 for FP32)
In a decoder where every layer uses full attention, each token needs a key and a value for every layer and every key/value head. The raw cache size is:
KV bytes = 2 × L × H × D × S × B × b
The leading 2 accounts for the key and value pair. This formula assumes:
- Full attention in every layer — each token’s key and value are stored for all heads.
- No GQA/MQA compression —
Hequals the number of query heads; if GQA is used, replaceHwith the number of KV heads. - Dense, unpadded storage — every position from 0 to S−1 occupies exactly one slot; no block/paged allocation savings are counted.
- One sequence per batch item — batch dimension
Bmultiplies the per-sequence cache linearly. - No allocator metadata, fragmentation, or workspace overhead — the formula gives the tensor bytes, not the GPU memory bill inclusive of the allocator.
Worked example: 32-layer model, 8 heads, D=128, 1K context, 1 sequence, FP16
Section titled “Worked example: 32-layer model, 8 heads, D=128, 1K context, 1 sequence, FP16”Take a model with L = 32 layers, H = 8 KV heads, head dimension D = 128, a context of S = 1,024 tokens (prompt + generated), B = 1 sequence, and FP16 storage (b = 2 bytes).
| Step | Calculation | Result |
|---|---|---|
| Per-token, per-layer cache size | 2 × H × D × b = 2 × 8 × 128 × 2 |
4,096 bytes ≈ 4 KiB |
| Per-token, all layers | 4 KiB × L = 4 KiB × 32 |
128 KiB |
| One full 1K sequence | 128 KiB × S = 128 KiB × 1,024 |
128 MiB |
| Eight concurrent sequences | 128 MiB × B = 128 MiB × 8 |
1 GiB |
Check: 2 × 32 × 8 × 128 × 1,024 × 1 × 2 = 2 × 32 × 8 × 128 × 2,048 = 2 × 32 × 8 × 262,144 = 134,217,728 bytes = 128 MiB. ✓
The formula scales linearly: doubling the context length S doubles the cache; doubling the batch B doubles it; moving from FP16 to FP32 (b = 4) doubles it.
What changes with GQA?
Section titled “What changes with GQA?”If the same 32-layer model uses GQA with 32 query heads but only 4 KV heads (H = 4 in the formula), the cache halves relative to the H = 8 example above, because the formula scales linearly in H:
- FP16, S = 1K, B = 1: 128 MiB to 64 MiB
- FP16, S = 8K, B = 8: 8 GiB to 4 GiB
For reference, a model with no GQA at all (H = 32, one KV head per query head) would need 512 MiB in the 1K/FP16/B=1 case: 2 x 32 x 32 x 128 x 1,024 x 1 x 2 = 536,870,912 bytes.
The quality impact of reducing KV heads should be evaluated on the target task, as fewer heads may reduce the model’s capacity to store diverse key patterns.
Reproducible: per-layer KV cache update and logit parity check
Section titled “Reproducible: per-layer KV cache update and logit parity check”The following self-contained PyTorch program builds a tiny two-layer decoder with an explicit per-layer KV cache, runs a cached and an uncached forward pass over the same tokens, and asserts that the next-token logits agree. It was executed for this lesson with torch 2.15.0, Python 3.12, CPU; the output below is the real run, not an illustration.
import torchimport torch.nn as nnimport torch.nn.functional as F
torch.manual_seed(0)torch.set_grad_enabled(False)
D, H, D_HEAD, L, V = 32, 2, 16, 2, 50
class Block(nn.Module): def __init__(self): super().__init__() self.q = nn.Linear(D, H * D_HEAD) self.k = nn.Linear(D, H * D_HEAD) self.v = nn.Linear(D, H * D_HEAD) self.o = nn.Linear(H * D_HEAD, D) self.ln1 = nn.LayerNorm(D) self.ln2 = nn.LayerNorm(D) self.fc1 = nn.Linear(D, 4 * D) self.fc2 = nn.Linear(4 * D, D)
def forward(self, x, past=None): B, S, _ = x.shape q = self.q(x).view(B, S, H, D_HEAD).transpose(1, 2) # [B,H,S,Dh] k = self.k(x).view(B, S, H, D_HEAD).transpose(1, 2) v = self.v(x).view(B, S, H, D_HEAD).transpose(1, 2) if past is not None: k = torch.cat([past[0], k], dim=2) v = torch.cat([past[1], v], dim=2) S_all = k.shape[2] scores = (q @ k.transpose(-2, -1)) / (D_HEAD ** 0.5) q_pos = torch.arange(S_all - S, S_all) k_pos = torch.arange(S_all) blocked = k_pos[None, :] > q_pos[:, None] # [S, S_all] scores = scores.masked_fill(blocked[None, None], float("-inf")) attn = scores.softmax(-1) out = (attn @ v).transpose(1, 2).reshape(B, S, H * D_HEAD) x = x + self.o(out) x = x + self.fc2(F.gelu(self.fc1(self.ln2(x)))) return x, (k, v)
class Tiny(nn.Module): def __init__(self): super().__init__() self.emb = nn.Embedding(V, D) self.blocks = nn.ModuleList([Block() for _ in range(L)]) self.ln_f = nn.LayerNorm(D) self.head = nn.Linear(D, V, bias=False)
def forward(self, idx, past=None): x = self.emb(idx) new_past = [] for i, blk in enumerate(self.blocks): x, layer_past = blk(x, None if past is None else past[i]) new_past.append(layer_past) return self.head(self.ln_f(x)), new_past
model = Tiny()prompt = torch.tensor([[1, 2, 3, 4]]) # [B=1, P=4]
# Cached path: prefill the 4 prompt tokens, then feed one token against the cachelogits_prefill, past = model(prompt)prefill_cache_len = past[0][0].shape[2]next_id = logits_prefill[:, -1, :].argmax(-1, keepdim=True) # first generated tokenlogits_decode, past = model(next_id, past=past)decode_cache_len = past[0][0].shape[2]
# Uncached reference: run the S=5 sequence (prompt + the same generated token) in one passfull_ids = torch.cat([prompt, next_id], dim=1)logits_full, _ = model(full_ids)
# The parity claim: last cached-decode logits == last uncached logitsdiff = (logits_decode[:, -1, :] - logits_full[:, -1, :]).abs().max().item()print(f"cache length after prefill: {prefill_cache_len}, after one decode step: {decode_cache_len}")print(f"cached next-token logits: {logits_decode[:, -1, :].shape}")print(f"uncached next-token logits: {logits_full[:, -1, :].shape}")print(f"max |cached - uncached| logit diff: {diff:.3e}")print(f"parity within 1e-5: {diff < 1e-5}")Actual output:
cache length after prefill: 4, after one decode step: 5cached next-token logits: torch.Size([1, 50])uncached next-token logits: torch.Size([1, 50])max |cached - uncached| logit diff: 1.788e-07parity within 1e-5: TrueThe cache grows by exactly one position per decode step (4 to 5), and the cached path matches
the uncached reference to within 1.8e-07, far inside the FP32 tolerance discussed below.
Numerical tolerance note: The cached and uncached logit difference should be < 1e-3 in FP16 and < 1e-5 in FP32. If you see larger differences, verify that (a) the cache write position increments by exactly 1 per decode step, (b) the causal mask covers the full extended cache length, and (c) no head-dimension transpose error has occurred. The above script is a minimal reproduction; a full check would use a pretrained model (e.g., GPT-2) and compare
model(..., use_cache=True).logitsagainstmodel(..., use_cache=False).logitson the same token sequence.
Sign in to unlock this chapter
“KV Cache Implementation and Memory Accounting” is part of the int8ai curriculum. Primers are freely accessible to everyone without login. Free reader accounts unlock all chapters, code labs, and progress syncing.