veloxquant_mlx.cache.skvq_cache module¶
SKVQ KV cache wrapper for mlx_lm integration.
Implements “SKVQ: Sliding-window Key and Value Cache Quantization for Large
Language Models” (Duanmu, Yuan, Li, Duan, Zhang, Lin — COLM 2024;
arXiv:2405.06219) on top of the standard mlx_lm update_and_fetch
protocol. Documented as “SKVQ-adapted (VeloxQuant-MLX implementation)” —
not a faithful port (no offline calibration, no weight-fused permutation,
no 1.5-bit values, no FP8 metadata; see the module docstring of
veloxquant_mlx/quantizers/skvq.py and paper/NEW_METHOD_SURVEY_V13.md).
Three mechanisms compose:
Sliding fp16 window — the NSNQuant chunk-flush idiom: tokens accumulate at fp16; every time
skvq_windowtokens age past the quantized frontier, that chunk is round-tripped through reorder → clipped group quant → dequant → inverse reorder once and frozen. The frontier only advances in whole chunks, so prefill and token-by-token decode produce identical quantized state by construction (path independence, pinned by test).Channel reordering — per-head permutations for K and V computed from the first flushed chunk (the paper computes them offline from a calibration corpus and fuses them into projection weights — our documented deviation), then frozen for the cache’s lifetime.
skvq_reorder=Falseis the identity-permutation ablation.Clipped dynamic quantization — per-token, per-group asymmetric min/max quant whose window is shrunk by a per-group grid-searched clip factor α (
skvq_clip_search=True) or a fixedskvq_clip_alpha. Quant groups run along the channel axis for both K and V; reordering is what makes that viable for keys.
The paper’s attention-sink filter is kept: the first skvq_n_sink tokens
are restored to fp16 after chunk 0’s flush (and accounted as fp16, not
compressed).
Like every method in this repo, the quantize→dequantize round-trip happens
inside update_and_fetch so the downstream SDPA call sees standard fp16
tensors. The paper’s throughput gains come from fused CUDA kernels that do
not port to Metal — on Apple Silicon the win is memory, measured honestly
by the byte accounting below. Fully deterministic: no RNG anywhere.
Per-token storage at bits b, head_dim D, group size g (per tensor):
D * b / 8 bytes of codes + ceil(D/g) * 2 * 2 bytes fp16 (lo, scale)
per token. The searched α adds nothing (folded into lo/scale); the frozen
permutations add 2 * H * D * 4 bytes per layer total, counted once in
perm_bytes.
- class veloxquant_mlx.cache.skvq_cache.SKVQKVCache(config)[source]¶
Bases:
KVCacheKV cache implementing SKVQ sliding-window quantization for one layer.
- Parameters:
config (
Any) –KVCacheConfig. Fields consumed:head_dim,skvq_bits_key,skvq_bits_value,skvq_group_size,skvq_window(fp16 sliding window == flush chunk size),skvq_n_sink(leading tokens restored to fp16, the paper’s filter),skvq_reorder,skvq_clip_search,skvq_clip_alpha(used when search is off),skvq_max_ctx(optional hard cap on context length;None, the default, is unlimited).
Notes
Never exposes
.bits— mlx_lm’s SDPA checkshasattr(cache, "bits")to route to a quantized kernel path. We expose.assigned_avg_bitsinstead.- update_and_fetch(keys, values)[source]¶
Append the incoming block, then flush every completed chunk.
The flush overwrites the aged-out tokens’ fp16 storage in place with their dequantized round-trip (quantize once, frozen), so decode-time tokens are quantized when they age past the window — the paper’s sliding-window semantics, unlike KIVI’s incoming-block-only simplification. Chunk 0 additionally restores the first
skvq_n_sinkrows to their exact fp16 values (the sink filter) and computes the frozen channel permutations.
- property compressed_key_bytes: int¶
Realized stored bytes for the quantized (behind-the-flush-frontier) key region, excluding the fp16 sliding-window tail and sinks.
- property compressed_value_bytes: int¶
Realized stored bytes for the quantized (behind-the-flush-frontier) value region, excluding the fp16 sliding-window tail and sinks.
- property residual_fp16_bytes: int¶
the un-flushed sliding-window tail plus the sink rows that flushing skipped — a snapshot, not a cumulative counter.
- Type:
Bytes currently held at fp16 (keys + values)
- property quantized_tokens: int¶
Tokens behind the flush frontier (multiple of the window), including the fp16-restored sink rows.
- property key_perms: mx.array | None¶
Frozen per-head key channel permutations
[H, D](None before the first flush or whenskvq_reorder=False).
- property value_perms: mx.array | None¶
Frozen per-head value channel permutations
[H, D](None before the first flush or whenskvq_reorder=False).
- property assigned_avg_bits: float¶
Effective bits/element over the quantized key region, including the fp16 (lo, scale) metadata (excludes the fp16 window and sinks; for an end-to-end ratio use
(compressed_*_bytes + residual_fp16_bytes) / fp16_*_bytes).
- is_trimmable()[source]¶
False: trim() would only roll back base-class offset bookkeeping, not the internal flush frontier (
_q_end) or the frozen per-head channel permutations (_perm_k/_perm_v, computed once from chunk 0 and never revisited) that actually determine what gets quantized and how. Unlike a pure-eviction cache, the information loss here is irreversible: once a chunk is flushed, its rows are overwritten in place with a lossy quantize/dequantize round-trip, so there is no exact fp16 value to restore even if_q_endwere rolled back too. Reproduced directly: trimming past_q_endleavesoffset < _q_end, and the nextupdate_and_fetchtreats already-flushed rows sitting in the buffer at those same indices as already-quantized instead of overwriting them with the new tokens actually meant to land there — silently corrupting future calls rather than crashing (see VeloxQuant-Studio issue #26).- Return type:
- property merge¶