veloxquant_mlx.cache.snapkv_cache module¶
SnapKV-adapted KV cache — prefill observation-window token eviction.
Inspired by “SnapKV: LLM Knows What You are Looking for Before Generation” (Yuan et al., ICLR 2025, arXiv:2404.14469). Documented as “SnapKV-adapted (VeloxQuant-MLX implementation)” — not a faithful port.
Token eviction: during prefill (S > 1), the last snap_obs_window key rows
act as proxy queries; their softmax attention over all prefix tokens produces
a per-token importance score. Only the top-snap_budget tokens (plus the
first snap_n_sink sink positions) are retained as fp16. All subsequent
decode tokens (S == 1) are always appended — never evicted.
mlx_lm’s chunked prefill calls update_and_fetch once per
prefill_step_size chunk for prompts longer than one chunk. Every S > 1
call re-enforces the budget against the accumulated kept set (prior kept
tokens concatenated with the new chunk), not just that chunk in isolation —
otherwise the retained count would grow by up to snap_budget per chunk
instead of staying capped (see #84).
This is the repo’s first eviction method. Every other method compresses all tokens to fewer bits; SnapKV-adapted stores fewer tokens at full fp16 precision. The two axes compose: wrap a quantizer cache around the selected subset for combined eviction + compression.
- Adaptation limitations (stated plainly):
Key-as-query proxy: obs-window uses key vectors, not true prompt query vectors (not visible at
update_and_fetch).No max-pool smoothing (paper’s
kernel_size > 1not implemented).Uniform
snap_budgetacross all heads.
- Byte accounting:
evicted_key_bytes / evicted_value_bytes — fp16 bytes for the kept subset full_key_bytes / full_value_bytes — hypothetical cost without eviction eviction_ratio — full_fp16 / kept_fp16 (> 1 = savings) tokens_kept / tokens_total — diagnostic token counters
- class veloxquant_mlx.cache.snapkv_cache.SnapKVKVCache(config)[source]¶
Bases:
KVCacheKV cache implementing SnapKV-adapted prefill eviction for one layer.
- Parameters:
config (
Any) –KVCacheConfig. Fields consumed:snap_budget(int, default 512) — max tokens retained after prefill,snap_obs_window(int, default 32) — trailing keys used as proxy queries,snap_n_sink(int, default 4) — initial positions always kept.
Notes
No
.bitsattribute — stores and returns fp16 K/V directly. Single-layer (no coordinator);for_modelpropagates allsnap_*fields automatically viadataclasses.replace. Eviction happens at every prefill chunk (S > 1) — mlx_lm’s chunked prefill (prefill_step_size) callsupdate_and_fetchonce per chunk for prompts longer than one chunk, so budget enforcement must re-run over the accumulated kept set, not just each chunk in isolation (see #84). Decode tokens (S == 1) are always kept. Accumulated decode tokens grow the cache beyond the initial budget in the decode phase — consistent with the paper’s design (the budget constrains prefill history, not the decode stream).- property offset: int¶
True absolute token position (NOT the retained row count).
While the base class’s
update_and_fetchis on the stack this yields the retained row count instead, so its cursor arithmetic and return slice stay correct.
- update_and_fetch(keys, values)[source]¶
Prefill: score and evict down to the retention budget (SnapKV window-attention proxy), deferred to after this step’s own attention. Decode: pass through unevicted.
- Parameters:
keys (mlx.core.array)
values (mlx.core.array)
- make_mask(N, return_array=False, window_size=None, **_)[source]¶
Explicit position-based causal mask — see VeloxQuant-MLX#370.
The inherited
KVCache.make_maskreturns the string"causal"wheneveroffset == 0(the very first prefill call, before any eviction has run) or otherwise builds a mask keyed off row index rather than true position — both wrong once eviction has made the kept keys a non-contiguous subset. Before the first prefill call (_kept_positionsis stillNone, nothing evicted yet) this falls back to the base class’s behavior exactly, since a plain trailing-window mask is correct there.Called before this step’s
update_and_fetch, whose attention runs over the stored rows plus this step’sNnew ones (eviction is deferred past the return), so the key axis islen(kept) + N.
- property tokens_total: int¶
Total token positions seen (before eviction) across all heads and steps.
- is_trimmable()[source]¶
False: trim() reads a corrupted
self.offsetand both writes and returns the wrong thing, worse than a mere bookkeeping rollback.The base
KVCache.trim(n)doesn = min(self.offset, n); self.offset -= n. Outsideupdate_and_fetch(_in_baseis False), this class’soffsetproperty returns_true_offset— the absolute token position used for RoPE — not the retained row count (_row_offset) the base class’s buffer actually holds. Sonis clamped against the wrong (larger) number, and the setter then writes_row_offset -= n, which can leave_row_offsetlarger than the number of rows ever written whenever eviction has dropped any tokens (the normal case once prefill has run). The nextupdate_and_fetchthen returns a slice reaching past the real data into the pre-allocated buffer’s stale/uninitialized rows, silently feeding garbage into attention rather than crashing. Reproduced directly: budget=4, 20 real prefill tokens (so_row_offset== 4,_true_offset== 20);trim(3)leaves_row_offset== 17 though only 4 rows are real, and the following decode step returns an 18-row slice (see VeloxQuant-Studio issue #27).- Return type:
- property merge¶