KV Cache › Verifying Cache Correctness
Language AI HandbookAll chapters KV Cache › Verifying Cache CorrectnessOne section · Sections in this chapter9 / 14 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
A necessary property of KV caching is that it must produce identical outputs to non-cached attention. This guarantee makes caching a valid optimization rather than an approximation. If caching changed the output even slightly, every generated token would be slightly different, and those small errors would compound over the course of a generation, creating wildly different sequences. Let's verify this mathematically and empirically.
The algebraic argument is straightforward. In standard full-sequence attention, the output for position in a sequence of length is:
where denotes the submatrix of containing only the first rows. In cached attention, we have:
where at step contains exactly the first rows of . Since is computed identically whether we process the full sequence at once or token-by-token, the two expressions are algebraically identical. The numerical values in are bitwise identical to the corresponding rows of , so the outputs are numerically equivalent up to floating-point precision.
def verify_cache_equivalence():
"""
Verify that cached and non-cached attention produce identical outputs.
"""
torch.manual_seed(42)
d_model = 64
num_heads = 4
batch_size = 2
attention = CausalSelfAttentionWithCache(d_model, num_heads)
# Create a sequence
prompt = torch.randn(batch_size, 5, d_model)
next_tokens = torch.randn(batch_size, 3, d_model)
full_sequence = torch.cat([prompt, next_tokens], dim=1)
# Method 1: Process entire sequence without cache
output_no_cache, _ = attention(full_sequence, use_cache=False)
# Method 2: Process with cache (prefill + decode)
cache = KVCache(batch_size, num_heads, d_model // num_heads, max_seq_len=32)
# Prefill with prompt
output_prefill, cache = attention(prompt, cache, use_cache=True)
# Decode remaining tokens one at a time
cached_outputs = [output_prefill]
for i in range(next_tokens.shape[1]):
token = next_tokens[:, i : i + 1, :]
output_step, cache = attention(token, cache, use_cache=True)
cached_outputs.append(output_step)
output_with_cache = torch.cat(cached_outputs, dim=1)
return output_no_cache, output_with_cache
output_no_cache, output_with_cache = verify_cache_equivalence()
# Compare outputs
max_diff = (output_no_cache - output_with_cache).abs().max().item()
are_equal = torch.allclose(output_no_cache, output_with_cache, atol=1e-5)Maximum difference between cached and non-cached: 1.19e-07 Outputs are equivalent: True
The numerical equivalence confirms that caching is purely an optimization: it changes how we compute the result, not what we compute. The tiny residual difference (typically around ) shows floating-point rounding from the different order of operations, not a systematic error.