Benchmark Results: 10 Models, 8 Compression Configs
A full 8-model benchmark of residual vector quantization for KV cache compression on Apple M4 16GB. What worked, what didn't, and the one result that surprised me.
Here is the result I did not expect: a 32-billion-parameter model running faster with the KV cache compressed than without.
Qwen2.5-32B at RVQ 2-bit: 4.2 tok/s. The same model at full fp16 precision: 3.7 tok/s. That is a 14% throughput improvement from compression — on a 16GB MacBook. Not from a faster algorithm. From freeing up memory bandwidth that the model weights were competing for.
This is the full story: what we built, how it works, and what the benchmarks across 8 models actually show.
The Memory Problem Every Mac LLM User Knows
Every token your LLM generates writes two vectors into memory — a Key and a Value — for every attention head, across every layer. This is the KV cache. It grows linearly with context length.
For Mistral 7B (32 layers, 8 KV heads, head dimension 128) at 32K tokens, the KV cache at fp16 is:
2 × 32 layers × 8 heads × 128 dims × 32,000 tokens × 2 bytes = 8.4 GB
On a 16GB Mac, that is 8.4 GB for context alone — before model weights, before the OS, before your browser tab.
The KV cache is also the performance bottleneck. Most 7-8B models on Apple Silicon are memory-bandwidth bound: the chip can do the arithmetic faster than it can load data from unified memory. Every token requires streaming the full KV cache through the memory bus once per layer. Smaller cache = faster generation.
Why 2-Bit Usually Fails
The obvious approach is to quantize each float16 value (2 bytes) down to 2 bits — an 8x reduction. The problem: with only 4 quantization levels, reconstruction error is large enough to corrupt attention scores.
Cosine similarity between the original and compressed key vector — which directly determines attention quality — drops to 0.69 at single-pass 2-bit. For short generation (< 100 tokens), this is often tolerable. For long reasoning chains, it is not. We measured this:
| Model | Single-pass 2-bit: tokens generated |
|---|---|
| Phi-4 | 0 / 200 — immediate EOS |
| Llama 3.1 8B | 32 / 200 — collapses mid-sequence |
| Falcon3 7B | 38 / 200 — degrades rapidly |
| Qwen2.5 32B | 5 / 200 — immediate near-EOS |
This is not a mild degradation. For these models, standard 2-bit quantization simply does not work.
The Fix: Two-Pass Residual Quantization
The key insight: if your first codebook leaves large error, run a second codebook on the error itself.
Stage 1 fits a Gaussian Lloyd-Max codebook on the rotated key vector. The rotation (a randomized Hadamard transform) spreads information uniformly across dimensions, so the distribution after rotation looks Gaussian and the codebook fits it well.
Stage 2 takes the residual — the difference between the original and the stage-1 reconstruction — and fits a Laplacian codebook on that. The Laplacian distribution matches the residual better than a Gaussian: it is the tail of a Gaussian, peaked at zero, with heavier tails. Using the wrong distribution family for stage 2 leaves measurable error on the table.
Result: cosine similarity from 0.69 → 0.98 at 2 bits per dimension. The two-pass approach recovers information the single-pass approach discards.
Storage per vector (d=128):
- RVQ 2-bit:
ceil(128 × 2 × 2 / 8)= 64 bytes per key vector vs 256 bytes at fp16 — 4x compression - RVQ 1-bit (sign quantizer + Laplacian residual): 32 bytes per vector — 8x compression, cosine 0.917
The 1-bit variant uses a two-level sign quantizer as stage 1 ({−0.798, +0.798} — the exact Gaussian Lloyd-Max solution at 1 bit) and a Laplacian codebook on the sign-quantization error for stage 2. It achieves 7.5x KV cache compression while keeping cosine above 0.91.
The Throughput Problem (and How We Solved It)
Compression reduces memory pressure. But if the quantization compute itself costs more than the bandwidth savings, the net result is slower generation.
Our initial implementation ran at 17.7 tok/s on Mistral 7B with RVQ 2-bit — slower than fp16's 22.1 tok/s. Four changes fixed this, each independently measurable:
1. Batch all heads into one MLX call (+22%)
The original code maintained a separate quantizer per attention head and looped over them in Python. For Mistral 7B (8 KV heads, 32 layers), that is 256 small kernel dispatches per token. The fix: reshape (B, H, S, D) → (B·H·S, D) and call one shared quantizer on the entire batch. MLX compiles the operation as a single graph node and dispatches it once.
2. Switch to Hadamard rotation
The rotation preconditioner used QR decomposition — a full d×d matrix multiply (16,384 operations at d=128). mx.hadamard_transform is a Metal-native fused kernel that achieves the same statistical effect in O(d log d) — 896 operations at d=128. The quality is mathematically identical: a Hadamard with a random ±1 diagonal is Haar-distributed, the same family as a random orthogonal matrix.
3. Boundary-sum quantization instead of broadcast-argmin
Codebook lookup was materializing a (batch, d, k) distance tensor and running argmin — three kernel launches: broadcast-subtract, absolute value, argmin. Lloyd-Max boundaries are just midpoints between sorted centroids. The nearest centroid index equals the number of boundaries the value exceeds: one comparison and one sum. Two kernels, no intermediate tensor. Verified bitwise-identical to the old path at 100.00% index match.
4. Remove redundant dtype casts
The update path was casting fp16 → fp32 → fp16 → fp32 → fp16 from accumulated unnecessary coercions. Removing them saved ~5% per call with no effect on output quality.
Net result: 17.7 → 22.3 tok/s on Mistral 7B. The compression now matches fp16 throughput.
Full 8-Model Benchmark
Hardware: Apple M4 MacBook, 16GB unified memory. Each run: 200 tokens, one fresh Python subprocess per (model, config) to avoid MLX graph compilation bugs. 6 configs: fp16, TQ 2/3/4-bit (single-pass), RVQ 2-bit, RVQ 1-bit.
| Model | fp16 | RVQ 1-bit ★ | RVQ 2-bit ★ | TQ 4-bit | TQ 2-bit |
|---|---|---|---|---|---|
| Mistral 7B | 23.3 tok/s | 22.2 (201/201) | 22.5 (201) | 21.4 (201) | 22.1 (201) |
| Falcon3 7B | 24.0 tok/s | 23.1 (200/200) | 22.7 (200) | 22.1 (200) | 17.4 (38/200) |
| Phi-4 | 11.9 tok/s | 11.8 (200/200) | 11.7 (200) | 11.4 (200) | 0.0 (0/200) |
| Qwen3 4B | 40.2 tok/s | 34.3 (187/200) | 35.0 (197) | 33.5 (199) | 31.0 (170/200) |
| Qwen3 8B | 20.5 tok/s | 21.1 (200/200) | 20.7 (200) | 19.8 (200) | 20.9 (200) |
| Llama 3.1 8B | 22.0 tok/s | 21.5 (201/201) | 20.9 (201) | 20.3 (201) | 3.4 (32/201) |
| Gemma3 4B | 32.5 tok/s | 30.5 (201/201) | 29.2 (201) | 27.7 (201) | 28.6 (198/201) |
| Qwen2.5 32B | 3.7 tok/s | 3.9 (200/200) | 4.2 (200) | 3.9 (200) | 0.3 (5/200) |
★ = RVQ configs at 7.5x compression. Bolded token counts in TQ 2-bit column = failure.
Three Findings Worth Highlighting
1. RVQ 1-bit is the reliability result
Across all 8 models at all 200 tokens, RVQ 1-bit with 7.5x compression produced complete, coherent output every time. TQ single-pass 2-bit failed catastrophically on 4 of 8 models.
This is the practical takeaway: if you want 2-bit compression and need your model to actually finish its output, residual quantization is not optional. The gap between 0.69 and 0.92 cosine is the gap between broken and working.
2. At 32B scale, compression beats fp16
Qwen2.5-32B is too large for 16GB without aggressive weight quantization. With 4-bit weights (the Qwen2.5-32B-Instruct-4bit model), it fits — but barely. The model consumes ~17.5 GB including the OS and runtime, leaving almost nothing for KV cache headroom. The chip is running at its memory bandwidth ceiling.
Compressing the KV cache frees bandwidth that the weight loads were competing for. The result is a net gain:
- fp16 KV cache: 3.7 tok/s
- RVQ 2-bit KV cache: 4.2 tok/s (+14%)
- RVQ 1-bit KV cache: 3.9 tok/s (+7%)
The effect is largest at 32B because the bandwidth contention is sharpest. At 7-8B, the same effect exists but is smaller (Qwen3-8B: +3% at RVQ 1-bit). For models that fit comfortably in unified memory, the effect disappears.
3. The Qwen3 4B exception
Qwen3 4B runs at 40.2 tok/s at fp16 — unusually fast for a model this size, because it is small and the chip can move through layers quickly. At RVQ 1-bit, it drops to 34.3 tok/s (85% of fp16).
This is the trade-off inverting. At small model sizes, fp16 is fast enough that the quantization compute overhead is a larger fraction of total runtime. At large model sizes, the weight loads dominate and the overhead is amortized. The crossover is somewhere around 8B parameters on this hardware.
If you are running a 4B model on a Mac with 32GB, use fp16. If you are running it on 16GB and memory is the constraint, RVQ 2-bit at 35.0 tok/s and 197/200 tokens is the right trade.
The VLM Extension
We also extended quantization to Qwen2-VL-7B — a vision language model that uses bfloat16 internally (wider exponent range, needed for large-norm image patch tokens). The original code always cast to float16 before normalizing, which discarded the bfloat16 exponent range at exactly the wrong moment for image tokens.
The fix is one variable: kdtype = keys.dtype. Normalize in the actual dtype, cast to float16 only for the codebook lookup. The VLM key-distribution diagnostic across all 28 layers showed that image patch tokens and text tokens have nearly identical distributions after RMSNorm — the quantizer does not need special handling for image tokens. RVQ 2-bit cosine on real Qwen2-VL keys: 0.979 for both image and text tokens across all layers.
What This Means in Practice
16GB Mac users:
For 7-8B models (Mistral, Llama, Falcon, Phi-4, Qwen3-8B), RVQ 1-bit gives you 7.5x KV cache compression at 94-99% of fp16 throughput. At 32K context, your KV cache drops from ~8 GB to ~1 GB. That is the difference between OOM and comfortable operation.
For Qwen2.5-32B on 16GB, RVQ is what makes the model faster than fp16. The compression is load-bearing.
For 4B models (Qwen3-4B, Gemma3-4B), use fp16 if you have memory. Use RVQ 2-bit if you do not.
Do not use single-pass 2-bit. It fails on Phi-4, Llama, Falcon, and Qwen2.5-32B. If you need 2-bit compression, you need residual quantization.
Using It
pip install VeloxQuant-MLX
import mlx_lm
from mlx_kv_quant import KVCacheBuilder, KVCacheConfig
model, tokenizer = mlx_lm.load("mlx-community/Mistral-7B-Instruct-v0.3-4bit")
# 7.5x compression, 95% throughput
config = KVCacheConfig(bits=1, algorithm="turboquant_rvq")
cache = KVCacheBuilder(config).for_model(model).build()
response = mlx_lm.generate(
model, tokenizer,
prompt="Explain the difference between RAM and unified memory.",
kv_cache=cache,
max_tokens=500,
)
print(response)
For RVQ 2-bit: bits=2. For single-pass 4-bit (safe on all models, less compression): algorithm="turboquant_prod", bits=4.
What the Figures Show
Each model folder in figures/2026-05-12/ contains 6 panels:
- Throughput comparison — tok/s across all 6 configs
- Quality vs compression — cosine similarity curve with RVQ ★ markers
- Memory at scale — KV cache bytes vs sequence length for each config
- Attention distortion — per-config distortion bars
- Output comparison — tokens generated per config (the failure-detection panel)
- Full report — all panels on one sheet
The output comparison panel (fig5) is the one to look at for reliability. Models where single-pass 2-bit generates 0 or 5 tokens show it plainly. RVQ configs all extend to the full 200-token bar.
Reproducibility
All runs use subprocess isolation — one fresh Python process per (model, config) — to avoid MLX's graph-reuse bug that causes 2nd+ configs to generate 0 tokens in the same process when the cache type changes. If you run these scripts yourself and see anomalously low token counts on non-fp16 configs, check that you are not running multiple configs in the same Python session.
git clone https://github.com/rajveer43/veloxquant-mlx
cd veloxquant-mlx
pip install -e .
PYTHONPATH=. python3 benchmark_scripts/run_full_reports.py --models mistral7b falcon3_7b llama31_8b
Figures save to figures/2026-05-12/<model>/. Each model takes 10–15 minutes on M4.
VeloxQuant-MLX is MIT licensed. Hardware: Apple M4, 16GB unified memory. MLX 0.24.x, mlx-lm 0.21.x.
