veloxquant_mlx.cache.kvquant_cache module¶
KVQuant-NUQ KV cache wrapper — non-uniform quantization + outlier isolation.
Inspired by “KVQuant: Towards 10 Million Context Length LLM Inference with KV
Cache Quantization” (arXiv:2401.18079, NeurIPS 2024). Documented as
“KVQuant-adapted (VeloxQuant-MLX implementation)” — implements the four
cache-observable pillars (per-channel keys / per-token values, NUQ datatype,
per-vector dense-and-sparse outlier isolation, and Attention Sink-Aware
quantization) and documents pre-RoPE key quantization as out of scope. See
veloxquant_mlx.quantizers.kvquant for the numerics.
- Quantization axes (matching KVQuant’s asymmetry, the same axes KIVI uses):
- Keys — per-channel: each head-dim channel gets its own non-uniform levels
(sample axis = tokens). Channels have stable, distinct distributions.
Values — per-token: each token gets its own levels (sample axis = channels).
- Level lifecycle:
Prefill (S > 1) fits the NUQ levels from the incoming batch and freezes them (
kvquant_refit_interval == 0, the default — mirrors SVDq’s frozen-V). Decode tokens quantize against the frozen levels. With a positive refit interval, levels are re-fit every N decode steps from the most recent token. Value levels are per-token (one signpost table per token, sample axis = channels) and are, by design, always fit fresh every call — never frozen, regardless ofkvquant_refit_interval(that field only gates the key path). This is inherent to the per-token scheme, not a bug.
Performance (VeloxQuant-MLX#504): the per-head application was originally a
Python for b in range(B): for h in range(H): loop, and the Lloyd-Max fit
inside it forced a host sync (mx.eval) every iteration — together
measured a real 59x mlx_lm.generate() decode slowdown (94.6 -> 2.8 tok/s,
Llama-3.2-1B, Apple M4). Both are fixed: application is now batched over the
flattened B*H axis (split_dense_sparse_batched(),
fit_nuq_levels_batched(), etc.),
and the Lloyd-Max loop no longer evaluates per iteration — measured 9.6x
real end-to-end recovery (2.8 -> 27 tok/s on the same benchmark). This
does not close the full gap to fp16 speed. Unlike AdaKV’s equivalent fix
(VeloxQuant-MLX#512), the remaining cost here is not dispatch overhead —
it’s the Lloyd-Max fit’s own O(n_iters x N x L x D) arithmetic, paid
fresh for values on every decode step by the algorithm’s own per-token-
levels design. kvquant_lloyd_iters (default 8) is the existing,
already-correctly-wired lever for trading fit quality for speed — lower
values measured a further 27 -> 41 tok/s at lloyd_iters=1 on the same
benchmark, still well short of fp16 parity. There is no further “fix” to
apply here without changing the algorithm’s per-token-value-levels design.
- Byte accounting:
- compressed_* — NUQ codes + per-(channel/token) level table + fp16 outlier
side-channel (value + position index).
fp16_* — uncompressed cost for the ratio.
- Attention Sink-Aware quantization (paper §3.5):
The first
kvquant_n_sinktokens of the sequence are restored to exact fp16 and excluded from both the level fit and the outlier thresholds — the model is disproportionately sensitive to quantization error at the sink positions it dumps excess attention onto.- What is NOT implemented (documented):
Pre-RoPE key quantization (needs a model-forward hook — outside the cache contract; we see post-RoPE keys only).
Offline calibration-set level fitting (we fit online; zero calibration).
Fisher-information sensitivity weighting of the Lloyd-Max objective (Eq. 1 weights each squared error by F_ii; needs gradients from a calibration pass, so our fit is the unweighted k-means special case).
- class veloxquant_mlx.cache.kvquant_cache.KVQuantKVCache(config)[source]¶
Bases:
KVCacheKV cache implementing KVQuant-NUQ non-uniform quantization.
- Parameters:
config (
Any) –KVCacheConfig. Fields consumed:kvquant_bits(int, default 3),kvquant_outlier_fraction(float, default 0.01),kvquant_group_size(int, default 32; reserved for grouped fits),kvquant_lloyd_iters(int, default 8),kvquant_refit_interval(int, default 0 = freeze prefill levels).
No
.bitsattribute — mlx_lm’s SDPA checkshasattr(cache, "bits")to route to its quantized-matmul kernel path, which expects a different cache layout (mx.quantize’s native tuple format) and a.group_sizeattribute this cache doesn’t have. We expose.nuq_bitsinstead.- update_and_fetch(keys, values)[source]¶
Fit (or reuse frozen) per-channel key / per-token value NUQ levels, isolate outliers, and preserve sink tokens in fp16; return dequantized K/V.
- Parameters:
keys (mlx.core.array)
values (mlx.core.array)
- trim(n)[source]¶
Drop the last
ntokens and keep this cache’s own state in step.The inherited
trimonly movesoffset. Left alone,_n_tokensstays at its old value, so after trimming to empty the next prefill would not get its sink tokens (n_sinkapplies only at_n_tokens == 0) and would reuse the previous sequence’s frozen key levels and outlier thresholds.Trimming to empty resets everything (a new sequence starts from scratch). A partial trim keeps the levels fit on the surviving prefix and scales the byte accounting down with the token count (an estimate: per-call table and outlier bytes are not tracked per token).
- property merge¶
- property outlier_fraction: float¶
Configured nominal fraction of elements treated as dense-and-sparse outliers.
- property outlier_count: int¶
Realized count of elements isolated as outliers (K + V), across all calls so far.
- property key_levels¶
Frozen per-channel non-uniform quantization levels fit at prefill.
- property value_levels¶
Frozen per-token non-uniform quantization levels fit at prefill.
- property compressed_key_bytes: int¶
Realized stored bytes for the compressed key cache (NUQ codes + per-channel level table + fp16 outlier/sink side-channel, all heads/batches).