Skip to content

Add DuoAttention head-partitioned KV cache - #1520

Closed
pierre427 wants to merge 3 commits into
ml-explore:mainfrom
pierre427:pr/snapkv-d-duoattention
Closed

pierre427 wants to merge 3 commits into
ml-explore:mainfrom
pierre427:pr/snapkv-d-duoattention

Conversation

@pierre427

Copy link
Copy Markdown
Contributor

What

Adds a DuoAttention (arXiv:2410.10819) head-partitioned KV cache, a second eviction stage on top of SnapKV-D: different KV heads retain different token subsets — "retrieval" heads keep the long context, "streaming" heads keep only sinks + a recent window — so per-head sparsity cuts the KV read further than a single shared keep set.

Stacked on the SnapKV-D cache + server PRs (#1518, #1519). Until they merge the diff here also shows their commits; the DuoAttention change is the last commit (cache.py + one test).

How (all in mlx_lm/models/cache.py)

  • HeadPartitionedKVCache(_BaseCache) — stores the union of retained positions with a per-head keep mask, tracks the true offset for RoPE like PositionPreservingKVCache, and masks attention so each head only attends to its own retained rows. State/meta_state roundtrippable, trimmable, speculation-aware.
  • evict_prompt_cache_by_head(prompt_cache, head_keep_indices, *, true_offset, query_heads=None) — replaces full-attention KVCache layers with HeadPartitionedKVCache given caller-supplied per-head keep indices; other layers untouched. Returns a HeadPartitionedEvictionResult with union/per-head counts and compact bytes.

Scope

This PR provides the cache class and the eviction op that applies a caller-supplied head_keep_indices. The head classification (deciding which heads are retrieval vs streaming — typically a one-time offline pass per model) is deliberately out of scope; this is the runtime substrate it plugs into.

A note on the storage model

Storage is a shared union of positions plus a per-head mask, so the memory saving comes from streaming heads narrowing the union, not from any single head. If one head retains the full context, the stored union is the full prompt regardless of what other heads drop — the win requires the retained heads themselves to be bounded.

Test

tests/test_snapkv_duoattention.py: HeadPartitionedKVCache construction from per-head keep, size/offset/positions/head_positions, state/meta_state roundtrip, is_trimmable, trim, nbytes, per-head make_mask semantics, and decode append; evict_prompt_cache_by_head with asymmetric per-head keep (offset preserved, correct union count, non-KV layers untouched, real byte reduction); and an end-to-end prefill → evict-by-head → decode on a tiny attention model. black / isort --profile black clean.

Pierre Lamy and others added 3 commits July 10, 2026 10:36
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>
Wire SnapKV-D post-prefill eviction into the server as an opt-in policy.

- New flags: --kv-eviction {none,snapkv} (default none), --kv-budget,
  --kv-window, --kv-sink-tokens, --kv-min-tokens.
- On a fresh full prefill longer than the floor, the single-request path
  prefills the prompt under a SnapKVAttentionCapture, compacts every
  full-attention layer to the budget via evict_prompt_cache, and decodes
  from the last prompt token. Partial prefix-cache hits and short prompts
  are left uncompacted so retained positions stay exact.
- SnapKV scores a prefill with a process-global attention hook that cannot
  separate per-request scores in a shared batch, so enabling it routes the
  model to the single-request path.

Tested with a stubbed generator over a tiny attention model: off is a
passthrough, a fresh long prompt is compacted to the budget with the true
offset preserved and decodes, and short / partial-hit prompts are skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second SnapKV-D stage: HeadPartitionedKVCache stores the union of retained
prompt positions once and exposes a per-head prefix mask so retrieval heads
attend the full long context while streaming heads attend only their own
sink+recent rows. offset still tracks the true sequence position for RoPE, and
positions/head_positions make prefix trim (prompt-cache reuse) and speculative
rollback exact. evict_prompt_cache_by_head applies a caller-supplied
head_keep_indices, replacing plain KVCache layers with the partitioned cache
and leaving every other layer type untouched.

The head-classification policy (deciding which heads are retrieval vs
streaming) is intentionally out of scope and stays application-side; this
provides only the cache representation and the eviction op. Default-off: no
model wires it up automatically.

Provenance: DuoAttention (arXiv:2410.10819).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants