Skip to content

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.

Intermediate14 min readUpdated Nov 1, 2026

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”

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)
Three-prompt token trace with two decode steps. Cache: 3 → 4 → 5 columns.
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 consumes g1 and produces logits for g2. No extra model pass is needed to produce the first generated token.

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

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 cache
cache_key[l][:, :, write_position, :] = new_key # shape: [B, H, 1, D]
cache_value[l][:, :, write_position, :] = new_value

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

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.

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:

  1. Cached pass: model(input_ids, past_key_values=initial_past, use_cache=True) — the model reads K/V from past_key_values and appends the new token’s K/V.
  2. 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.

Let:

  • L = number of transformer layers
  • H = number of key/value heads per layer
  • D = head dimension (the size of each key/value vector)
  • S = total sequence length, including prompt and every generated token
  • B = 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:

  1. Full attention in every layer — each token’s key and value are stored for all heads.
  2. No GQA/MQA compression — H equals the number of query heads; if GQA is used, replace H with the number of KV heads.
  3. Dense, unpadded storage — every position from 0 to S−1 occupies exactly one slot; no block/paged allocation savings are counted.
  4. One sequence per batch item — batch dimension B multiplies the per-sequence cache linearly.
  5. 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.

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 torch
import torch.nn as nn
import 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 cache
logits_prefill, past = model(prompt)
prefill_cache_len = past[0][0].shape[2]
next_id = logits_prefill[:, -1, :].argmax(-1, keepdim=True) # first generated token
logits_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 pass
full_ids = torch.cat([prompt, next_id], dim=1)
logits_full, _ = model(full_ids)
# The parity claim: last cached-decode logits == last uncached logits
diff = (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: 5
cached next-token logits: torch.Size([1, 50])
uncached next-token logits: torch.Size([1, 50])
max |cached - uncached| logit diff: 1.788e-07
parity within 1e-5: True

The 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).logits against model(..., use_cache=False).logits on the same token sequence.