KV compression - StreamingLLM, H2O, SnapKV#
Track 05 - Serving · Notebook 03 · Runtime: ≈30 s on CPU
Prerequisites:
05_serving/02(KV variants),01_inference/01(KV cache).Papers:
Xiao et al. 2023, Efficient Streaming Language Models with Attention Sinks (StreamingLLM, 2309.17453).
Zhang et al. 2023, H2O: Heavy-Hitter Oracle (2306.14048).
Li et al. 2024, SnapKV: LLM Knows What You are Looking for Before Generation (2404.14469).
What#
Long contexts inflate the KV cache linearly. Three techniques reduce cached tokens (not bytes per token) during decoding:
StreamingLLM. Keep a small number of attention sinks at the very start of the context plus a sliding window at the end. Throw away the middle.
H2O. Score every token by its cumulative attention weight so far; evict the lowest-scoring tokens (“heavy hitters” stay).
SnapKV. At prompt-encoding time, look at the attention pattern of the final observation window; keep tokens that window attends to, drop the rest. Fixed cost, big savings.
We implement all three against a synthetic attention-score matrix and verify: (a) each policy keeps ≤ budget tokens; (b) H2O retains more ground-truth-important tokens than StreamingLLM; (c) SnapKV works in one pass at encoding time.
from llm_systems_cookbook.nb import bootstrap
import numpy as np
s = bootstrap("05_serving_03_kv_compression_streamingllm_h2o_snapkv")
Ground-truth attention matrix#
Simulate a T = 512-token sequence. The attention score matrix
A[q, k] = the attention weight from query q to key k, rows
normalised. We plant 32 “important” tokens scattered throughout that
every query attends to heavily. A good compression keeps most of
them.
set_seed(0)
rng = np.random.default_rng(0)
T = 512
IMPORTANT = sorted(rng.choice(T, size=32, replace=False).tolist())
# Base attention: uniform over earlier positions; boost "important" positions.
A = np.zeros((T, T), dtype=np.float32)
for q in range(T):
for k in range(q + 1):
A[q, k] = 1.0
for k in IMPORTANT:
if k <= q:
A[q, k] += 8.0 # heavy-hitters
A += rng.exponential(0.1, size=A.shape) * np.tri(T, T)
# Row-normalise.
A = A / (A.sum(axis=1, keepdims=True) + 1e-12)
print(f"attention matrix {A.shape}, important tokens = {len(IMPORTANT)}")
Three compression policies#
Each returns a set of kept key indices under a budget B.
def streaming_llm(T: int, budget: int, n_sinks: int = 4) -> set[int]:
'''n_sinks at the start + (budget - n_sinks) at the end.'''
sinks = set(range(n_sinks))
window = set(range(T - (budget - n_sinks), T))
return sinks | window
def h2o(A: np.ndarray, budget: int) -> set[int]:
'''Score each key by its cumulative attention across queries; keep top-budget.'''
scores = A.sum(axis=0)
top = np.argsort(-scores)[:budget]
return set(top.tolist())
def snapkv(A: np.ndarray, budget: int, window: int = 32) -> set[int]:
'''At encoding time, score keys by attention from the last ``window`` queries.'''
T = A.shape[1]
scores = A[-window:].sum(axis=0)
top = np.argsort(-scores)[:budget]
return set(top.tolist())
BUDGET = 64
kept_stream = streaming_llm(T, budget=BUDGET)
kept_h2o = h2o(A, budget=BUDGET)
kept_snap = snapkv(A, budget=BUDGET)
for name, kept in [("StreamingLLM", kept_stream), ("H2O", kept_h2o), ("SnapKV", kept_snap)]:
recall = len(kept & set(IMPORTANT)) / len(IMPORTANT)
print(f" {name:<14} kept={len(kept)} important-token recall = {recall:.2%}")
recall_stream = len(kept_stream & set(IMPORTANT)) / len(IMPORTANT)
recall_h2o = len(kept_h2o & set(IMPORTANT)) / len(IMPORTANT)
recall_snap = len(kept_snap & set(IMPORTANT)) / len(IMPORTANT)
s.check("streaming_respects_budget", lambda: len(kept_stream) <= BUDGET, msg=f"{len(kept_stream)}")
s.check("h2o_respects_budget", lambda: len(kept_h2o) <= BUDGET, msg=f"{len(kept_h2o)}")
s.check("snapkv_respects_budget", lambda: len(kept_snap) <= BUDGET, msg=f"{len(kept_snap)}")
s.check(
"h2o_best_recall_of_important_tokens",
lambda: recall_h2o >= recall_stream,
msg=f"h2o={recall_h2o:.1%} streaming={recall_stream:.1%}",
)
s.check(
"snapkv_comparable_to_h2o",
lambda: recall_snap >= recall_h2o - 0.15,
msg=f"snapkv={recall_snap:.1%} h2o={recall_h2o:.1%}",
)
s.check(
"all_policies_nontrivially_better_than_random",
lambda: min(recall_h2o, recall_snap) > BUDGET / T + 0.1,
msg=f"random baseline ~{BUDGET / T:.1%}",
)
Compression vs quality on one plot#
x-axis: how much of the cache was thrown away (higher = more aggressive). y-axis: recall of the planted “important” tokens (higher = better quality proxy). The three methods sit at the same compression level here but separate cleanly on quality - H2O and SnapKV find the heavy hitters, StreamingLLM keeps what’s nearby.
import matplotlib.pyplot as plt
methods = [
("StreamingLLM", kept_stream, recall_stream, "tab:blue"),
("H2O", kept_h2o, recall_h2o, "tab:orange"),
("SnapKV", kept_snap, recall_snap, "tab:green"),
]
random_recall = BUDGET / T
fig, ax = plt.subplots(figsize=(6.8, 4.2))
for name, kept, recall, color in methods:
compression = 1.0 - len(kept) / T
ax.scatter(compression, recall, s=220, color=color, edgecolor="black",
lw=0.8, zorder=5, label=name)
ax.annotate(name, (compression, recall), xytext=(8, 6),
textcoords="offset points", fontsize=10)
ax.axhline(random_recall, color="tab:gray", linestyle="--",
label=f"random baseline ({random_recall:.1%})")
ax.axhline(1.0, color="black", linestyle=":", alpha=0.4, label="oracle")
ax.set_xlabel("compression ratio (fraction of KV evicted)")
ax.set_ylabel("important-token recall")
ax.set_title(f"KV compression Pareto @ budget={BUDGET}/{T} tokens")
ax.set_xlim(0, 1)
ax.set_ylim(0, 1.05)
ax.grid(True, alpha=0.3)
ax.legend(loc="lower left", fontsize=9)
plt.tight_layout()
plt.show()
Exercises#
Sweep budget. Plot important-token recall vs budget for all three policies. StreamingLLM has a step function (fixed sinks + fixed window); H2O/SnapKV have smooth curves.
Combine. Union a StreamingLLM window with H2O heavy hitters. Typical real systems do this - the window covers recent interactions, H2O catches distant must-keep tokens.
Real integration. Plug a compression policy into
01_inference/01’s KV loop - on each decode step, evict tokens not inkept.
References#
StreamingLLM, H2O, SnapKV papers for the three algorithms.
KIVI (notebook 04) attacks the problem from a different angle: keep all tokens, but quantise K/V to 2 bits.
s.summary()
s.save()