veloxquant_mlx.cache.rocketkv_cache module¶
RocketKV-adapted KV cache — two-stage compression (SnapKV eviction + HSA).
Inspired by “RocketKV: Accelerating Long-Context LLM Inference via Two-Stage
KV Cache Compression” (Behnam, Fu, Zhao, Tsai, Yu, Tumanov; NVIDIA/Georgia
Tech; ICML 2025, arXiv:2502.14051). Documented as “RocketKV-adapted
(VeloxQuant-MLX implementation)” — not a faithful port. See
veloxquant_mlx/quantizers/rocketkv.py for the full list of adaptation
decisions and paper/research/surveys/NEW_METHOD_SURVEY_V23.md for the
write-up.
- Design (paper §3.2-3.6):
- Prefill (first call, S > 1):
Derive the stage-1/stage-2 split from
rocketkv_compression_ratioviasplit_compression_ratio().Run stage-1 coarse-grain PERMANENT eviction — directly reuses
snapkv_compress()(the paper adopts SnapKV verbatim for this stage) — down to a stage-1 token budget derived from that split.Build a paged max/min key summary over the surviving subset (stage 2’s index).
- Decode (subsequent calls, S == 1 per step):
Append the new key’s contribution to the paged summary incrementally (
append_paged_summary_batched()).Using the incoming key as a proxy query (same convention as SnapKV-adapted / A2ATS-adapted / every other query-aware method in this repo — the cache wrapper never sees the true query), run HSA: top-k1 head-dim channels, per-page approximate scores, top-k2 page selection.
Gather the exact K/V rows for the selected pages plus the new decode token itself (always kept), and expose that subset as the cache’s return value for this step — the eviction axis (stage 1) determines what is ever STORED; the selection axis (stage 2) determines what is FETCHED per step. Because mlx_lm’s cache protocol has no notion of “fetch a subset, store everything,” this wrapper stores only the stage-1 survivors (bounded, honest storage) and, at decode, narrows the returned K/V to the HSA selection — the base class’s buffer keeps growing with new decode tokens (never evicted, matching RocketKV’s paper: stage 1 only touches the input prompt).
- Byte accounting:
- stage1_bytes / stage2_aux_bytes — storage after eviction (fp16 kept
tokens) and HSA’s paged max/min auxiliary summary, respectively
full_fp16_bytes — hypothetical cost without any compression compression_ratio — full_fp16_bytes / (stage1_bytes + stage2_aux_bytes) tokens_kept / tokens_total — diagnostic token counters (stage 1 only —
stage 2 never drops a stored token, only narrows what a given decode step attends to)
- Limitations (stated plainly):
Key-as-query proxy at both stage-1 eviction and stage-2 HSA selection — inherited from SnapKV-adapted, not a new approximation.
No fused kernel: HSA gather/attend happens in eager MLX ops each step.
No RocketKV-MT (multi-turn) variant — see issue #239.
Performance: _process_prefill’s single-shot path originally looped
for b in range(B): for h in range(H):, calling snapkv_compress once
per head — the same unbatched-per-head-loop cost pattern that dominated
ChunkKV/CurDKV/GEAR before their own fixes (#525/#526/#527). Here it reused
_snapkv_compress_batched, the batched primitive SnapKVKVCache
already relies on in production (stage 1 is SnapKV verbatim, per this
module’s own docstring — this was reinventing its per-head loop rather
than reusing the batched call). Verified bit-identical to the old loop.
Isolated stage-1 benchmark (B=1, H=32, S=1024, D=128, budget=128): ~4.53ms
(old per-head loop) -> ~1.50ms (batched), ~3x faster and markedly more
stable run-to-run.
_process_decode had the same unbatched-per-(b,h)-loop pattern, and was
the hotter of the two (runs once per generated token, not once per prefill):
it called append_paged_summary()
once per head, itself an inner Python loop over pages. Batched via
append_paged_summary_batched(),
legal here because every (b, h) row’s paged summary provably shares the same
n_tokens/page count at every point in _process_decode’s call pattern
(every row is seeded with the same n_kept at prefill via
_snapkv_compress_batched’s one shared budget, and every decode step
appends the same S new tokens to every row — verified by grepping every
write site of the paged-summary state, not assumed). Verified bit-for-bit
identical to the old per-(b,h) loop against 1536 randomized single-step
trials plus 18 five-step decode sequences (varying page_size, prior token
count, new-token count, head_dim, batch size). Isolated decode-step
benchmark (D=128, page_size=8, n_kept=256, 50 steps): H=8: ~2.58ms ->
~0.33ms/step (~7.9x); H=32: ~11.54ms -> ~0.41ms/step (~28x) — speedup grows
with B*H, as expected for removing B*H separate Python-dispatched op graphs
(each itself looping over pages).
The chunked-prefill re-run path remains an explicit Python loop — unlike
_process_decode, per-head kept-token counts here are REDERIVED per call
via each head’s own _stage1_head (SnapKV) call, so this path was
verified (not merely assumed) to keep every row’s n_kept — and hence
page count — in sync via the shared budget/S formula, but the loop
itself was left unbatched since only chunked prompts hit it (rare, unlike
every-decode-step). See append_paged_summary_batched()’s
precondition note for why it isn’t reused there.
- class veloxquant_mlx.cache.rocketkv_cache.RocketKVKVCache(config)[source]¶
Bases:
DeferredEvictionMixin,KVCacheKV cache implementing RocketKV-adapted two-stage compression for one layer.
- Parameters:
config (
Any) –KVCacheConfig. Fields consumed:rocketkv_compression_ratio(float, default 8.0) — overalltarget compression ratio
c; drives the adaptive stage-1/ stage-2 split (paper §3.6). Must be > 1.rocketkv_page_size(Optional[int], default None) — HSA pagesize;
Nonederives it from the adaptive split instead of a fixed value.rocketkv_head_topk1(Optional[int], default None) — head-dimchannels kept per HSA step;
Nonederives it from the adaptive split (head_dim / head_dim_ratio).rocketkv_obs_window(int, default 32) — stage-1 SnapKVobservation window.
rocketkv_n_sink(int, default 4) — stage-1 SnapKV sinktokens always kept.
Notes
No
.bitsattribute — stores and returns fp16 K/V directly. Single-layer (no coordinator);for_modelpropagates allrocketkv_*fields automatically viadataclasses.replace. Stage 1 (eviction) runs once at prefill, exactly likeSnapKVKVCache. Stage 2 (HSA) runs every decode step and narrows the returned K/V subset without shrinking the stored buffer — decode tokens accumulate in storage (paper’s stage 1 only compresses the input prompt), but each attention call only touches the HSA- selected pages plus the running decode tail.- property offset: int¶
True absolute token position (NOT the retained row count).
See
SnapKVKVCache.offsetfor why the two must not be conflated — stage 1 here drops tokens exactly the same way.
- property state¶
Expose retained rows, excluding the base cache’s capacity padding.
- update_and_fetch(keys, values)[source]¶
Prefill: run stage-1 SnapKV eviction per head and build paged HSA summaries. Decode: append tokens exactly and update summaries incrementally.
- Parameters:
keys (mlx.core.array)
values (mlx.core.array)
- select_indices(query, b, h, keep_recent=0)[source]¶
HSA stage-2: approximate top-k page selection for one (b, h) query.
Exposed for callers that want to actually exercise sparse attention with this cache (the base
update_and_fetchreturn value stays dense — see the module docstring’s storage-vs-fetch distinction).- Parameters:
query (
array) –[D]proxy query vector (key-as-query convention).b (
int) – Batch and head index into the stored paged summaries.h (
int) – Batch and head index into the stored paged summaries.keep_recent (
int) – Trailing token count always included regardless of HSA score (e.g. the newest decode tokens).
- Return type:
array- Returns:
[n_selected]int32 token indices, ascending, deduplicated, covering the HSA-selected pages and the trailingkeep_recentwindow.
- is_trimmable()[source]¶
False: trim() would only roll back base-class offset bookkeeping, not the internal stage-1 kept-token accounting or stage-2 paged max/min summaries, silently desynchronizing both from the trimmed buffer on the next call.
- Return type:
- property compression_ratio: float¶
full_fp16_bytes / (stage1_bytes + stage2_aux_bytes); > 1 means savings.
- property tokens_kept: int¶
Total tokens retained after stage-1 eviction (plus all decode tokens, which are always kept).
- property keep_rate: float¶
Fraction of tokens retained after stage-1 eviction (
tokens_kept / tokens_total).