AAI Agentic Handbook
Problem 108 · Attention & Inference

KV-Cache Incremental Decode Equivalence

HardAI engineering
01Problem

Build a one-layer, one-head toy decoder: deterministic token embeddings (seeded), projections Wq, Wk, Wv, causal attention, and an unembedding to logits. Implement `full_forward(tokens)` that recomputes every position from scratch and a `KVCache` class whose `step(token)` projects only the new token, appends its K and V, and attends over the cache. Prove they produce the same logits at every position and the same greedy continuation, and count projection calls to show the saving.

02Key insight

Causal attention means position t's output depends only on positions <= t, and their K and V never change once computed; caching them turns each decode step from O(t) projections into O(1). The equivalence test is the contract every serving optimisation (paged attention, prefix caching, speculative decoding) must keep: if cached and uncached logits drift beyond float tolerance, you have a position or masking bug. The call counter makes the asymptotics concrete: n(n+1)/2 projections versus n.

03Solution

Try it first.

TimeCached: O(t * d + d^2) per step; uncached: O(t * d^2 + t^2 * d) per step
SpaceO(T * d) cache per layer per head
tests.py · 4 passing
tests.pypython15 lines
lm = ToyLM(vocab=11, d=6, seed=3)
seq = [1, 4, 2, 9, 0, 7]
full = full_forward(lm, seq)
cache = KVCache(lm)
for t, tok in enumerate(seq):
    inc = cache.step(tok)
    assert all(abs(a - b) < 1e-9 for a, b in zip(inc, full[t]))
lm.projections = 0
g_full = generate_full(lm, [1, 2], 10)
full_calls = lm.projections
lm.projections = 0
g_cached = generate_cached(lm, [1, 2], 10)
assert g_full == g_cached and len(g_full) == 12
assert lm.projections == 12
assert full_calls == sum(range(2, 12))
04Staff-level follow-up

Production caches are paged: fixed-size blocks with a block table per sequence (vLLM's PagedAttention), so fragmentation stays low and prefixes can be shared copy-on-write across requests. Then cover what invalidates a cache: changing the system prompt, a different LoRA adapter, a different RoPE scaling, or quantising K and V (fp8 KV halves memory but needs its own equivalence tolerance).