KV-Cache Incremental Decode Equivalence
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.
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.
Try it first.
tests.py · 4 passing
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))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).