Conversation
Long-context decode reads the whole KV cache every step, but after a prompt is prefilled most middle rows contribute little to future attention. SnapKV-D keeps attention sinks + a recent window + the top observation-window-scored middle rows within a budget (SnapKV, arXiv:2404.14469) and evicts the rest, cutting the per-token KV read proportionally. Opt-in; nothing runs unless a caller compacts a cache. Retained rows are a sparse subset of the prompt, so RoPE position and physical row count must diverge: - PositionPreservingKVCache tracks the true sequence position in `offset` for future rotations while storing only retained rows, and records each row's true position so a prefix trim (prompt-cache reuse) stays exact and speculative rollback trims only the generated suffix. - snapkv_keep_indices computes the retained positions (sinks + recent + top scored) for a budget; a prompt at/under min_tokens or within budget is kept whole. - evict_prompt_cache replaces full-attention KVCache layers with compact position-preserving caches, leaving other layer types untouched. - SnapKVAttentionCapture scores a prefill by wrapping mx.fast SDPA and reducing only the observation-window query rows, so it never holds a prompt-sized attention matrix. compact_prompt_cache ties prefill + scoring + eviction into one call. Tests cover the selection policy, the position-preserving cache (offset/stored divergence, state/meta_state roundtrip, logical-prefix and speculative-suffix trims, growth), eviction, and end-to-end capture + compaction + decode on a tiny attention model. black/isort clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
snapkv_keep_indices already staged sinks + recent window into the keep set before the score-ranked fill, but capped the sink floor at max(1, budget//8), so a tight budget silently kept fewer than sink_tokens sinks. Dropping an attention sink collapses decode into repetition -- the same failure mlx-lm ml-explore#1552 fixed for DeepSeek's DSA indexer top-k selection. Add a default-on guarantee_sinks guard that clamps the sink floor only by budget/seq_len, so the first N sinks (and the recent window) can never be evicted no matter how high a middle row scores. guarantee_sinks=False restores the old capped behaviour. Existing callers use budget>=32 (cap>=4) so behaviour is unchanged there; the guard only bites when budget//8 < sink_tokens. Adds regression tests: N sinks + recency survive budget=16 with every middle row scored 1e9, and the flag toggles the floor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed The |
What
Adds SnapKV-D, an opt-in post-prefill KV-cache eviction for long-context decode. After a prompt is prefilled, most middle rows contribute little to future attention; SnapKV-D keeps attention sinks + a recent window + the top observation-window-scored middle rows within a budget (SnapKV, arXiv:2404.14469) and evicts the rest, so the per-token KV read shrinks proportionally. Nothing runs unless a caller compacts a cache — default behavior is unchanged.
Why it needs a new cache
Retained rows are a sparse subset of the prompt, so RoPE position and physical row count must diverge.
PositionPreservingKVCachetracks the true sequence position inoffset(for future rotations) while storing only the retained rows, and records each row's true position so a prefix trim (prompt-cache reuse) stays exact and speculative rollback trims only the generated suffix. The retained keys keep their original rotation and decode queries use their true offset, so attention is correct against the compact cache.API (all in
mlx_lm/models/cache.py)snapkv_keep_indices(seq_len, budget, scores, *, sink_tokens=4, recent_tokens=None, min_tokens=128)— the retained positions. A prompt at/undermin_tokensor within budget is kept whole.PositionPreservingKVCache(_BaseCache)— the compact, position-preserving cache (state/meta_state roundtrippable, trimmable, speculation-aware).evict_prompt_cache(prompt_cache, keep_indices, *, true_offset)— replaces full-attentionKVCachelayers with compact caches; other layer types untouched. Returns aSnapKVEvictionResultwith retained/original counts and compact bytes.SnapKVAttentionCapture— scores a prefill by wrappingmx.fast.scaled_dot_product_attention, reducing only the observation-window query rows to a per-key vector so it never holds a prompt-sized attention matrix. The model output still uses the original fused kernel. The patch is process-global for thewithblock.compact_prompt_cache(model, prompt, *, budget, ...)— convenience that prefills, scores, and evicts in one call.Usage:
When it pays, and the honest caveats
min_tokens, default 128); single-reference facts degrade below a budget ratio. Quality is workload-level, not per-token lossless.Because of these, it is default-off and explicit per call.
Tests
tests/test_snapkv_cache.py: the selection policy (sinks/recent/top-scored, no-op and validation paths); the position-preserving cache (offset/stored divergence, state/meta_state roundtrip, logical-prefix and speculative-suffix trims, growth, nbytes); eviction (offset preserved, non-KV layers untouched, byte reduction); and end-to-end capture + compaction + decode on a tiny attention model, including that the scoring hook actually fires and a short prompt is a no-op.black/isort --profile blackclean.This is the foundation of a small stack: a
--kv-eviction snapkvserver flag and a DuoAttention head-partitioned variant build on it.