Skip to main content

Validation Report: KV Cache Quantization on Apple Silicon

This page explains what VeloxQuant-MLX does, how to measure it honestly, and how to reproduce numbers with scripts/validate_kv_memory.py.

No marketing softeners: every claim is a definition, a formula, or a number from a committed script. After you run the harness, cite figures/validation/<model>/results.json.

1. What the KV cache is

During autoregressive generation, each transformer layer computes key (K) and value (V) vectors for every token. The KV cache stores those tensors so each new token only computes its own K/V and attends over history.

bytes ≈ 2 × n_layers × n_kv_heads × head_dim × seq_len × 2

seq_len is tokens currently in the cache (prompt + generated).

2. What quantization does

Quantization stores fewer bits (or codebook indices) instead of fp16 so the same RAM can hold a longer seq_len.

Eviction drops or merges tokens (different axis). Cross-layer methods share state across depth.

Integration is three lines: build caches with KVCacheConfig / KVCacheBuilder.for_model, pass them to mlx_lm.generate as prompt_cache.

3. Where reduction happens

AxisWhat shrinksExamples
Keys onlyPacked key bytesRVQ, VecInfer keys
Keys + valuesFull KV packed bytesRaBitQ, KIVI
Token countseq_lenH2O, StreamingLLM
Across layersShared stateXQuant, MiniCache, xKV

Honest storage behavior (default RVQ / VecInfer)

  1. Quantize incoming tensors.
  2. Dequantize back to fp16.
  3. Store in the parent mlx_lm KVCache.
  4. Report compressed_key_bytes as packed-format accounting.

Headline ~7.5× (RVQ-1bit, d=128) and ~16× (VecInfer-1bit, sub_dim=8) are accounting ratios unless a packed/fused path is active and measured.

Measured example (M4 Pro, 48 GB)

From committed figures/validation/Llama-3.2-3B-Instruct-4bit/results.json (max_tokens=64, 120 tokens in cache):

Armtok/sPeak MBKeys fp16 (acct)Keys compressed (acct)Claim
fp1658.21954n/an/a1.00×
RVQ-1bit81.118556.562 MB0.872 MB7.53×
VecInfer-1bit49.219266.562 MB0.410 MB16.00×
claim = fp16_key_bytes / compressed_key_bytes

4. Before / after metrics

MetricMeaningJSON field
Tokens in cacheContext length heldtokens_in_cache_max
Keys fp16 sizeUncompressed key accountingfp16_key_bytes, fp16_key_mb
Keys compressed sizePacked key accountingcompressed_key_bytes, compressed_key_mb
Compression claimfp16 / compressedkey_compression
MLX peak MBWeights + activations + tempspeak_mb
Throughputtok/sthroughput_tok_s
PreviewQuality spot-checkoutput_preview

At short generation, weights dominate peak MB. Grow prefill (--prompt-repeat) before claiming cache-driven RAM wins.

5. Method defaults

GoalMethod
Everyday, zero calibrationturboquant_rvq b=1
Max key accountingvecinfer 1-bit (calibrate)
Quality at moderate ratespectral
Max context in tight RAMrabitq or eviction
Constant memorystreaming_llm / h2o

6. Reproduce

PYTHONPATH=. python scripts/validate_kv_memory.py \
--model mlx-community/Llama-3.2-3B-Instruct-4bit \
--max-tokens 128

Writes figures/validation/Llama-3.2-3B-Instruct-4bit/results.json.

Also see the fuller write-up in the repo: docs/validation-report.md.

7. Formula check

RVQ (d=128, b=1): (128×2) / (ceil(128×2×1/8)+2) ≈ 7.5×.

VecInfer (d=128, d_k=8, 8-bit indices): 256 / 16 = 16×.

Matching JSON ratios confirms counters, not resident RSS.