Summary
QuantizedKVCache shows higher peak MLX memory than plain fp16 KVCache in stream_generate, at every bit width and context length tested, with the gap widening as context grows — the opposite of the expected result. Filing this as data in case it's useful alongside the compute-stays-float design gap already noted on #941.
Environment
- mlx-lm 0.31.3, mlx 0.31.2
- Apple M4 Max, 128GB unified memory, macOS 27.0
- Model:
mlx-community/Llama-3.2-3B-Instruct-4bit
Reproduction
import mlx.core as mx
from mlx_lm import load, stream_generate
from mlx_lm.models.cache import make_prompt_cache, QuantizedKVCache
model, tokenizer = load("mlx-community/Llama-3.2-3B-Instruct-4bit")
filler = ("The quick brown fox jumps over the lazy dog. " * 2000)
ids = tokenizer.encode(filler)[:32000]
prompt = tokenizer.decode(ids) + "\n\nSummarize the above in one sentence."
# fp16 case (separate process from quantized case, to rule out allocator state bias)
cache = make_prompt_cache(model)
# quantized case
base = make_prompt_cache(model)
cache = [QuantizedKVCache(group_size=64, bits=8) if type(c).__name__ == "KVCache" else c for c in base]
mx.reset_peak_memory()
for _ in stream_generate(model, tokenizer, prompt=prompt, max_tokens=30, prompt_cache=cache):
pass
print(mx.get_peak_memory() / 1e9, "GB")
Each case run in its own fresh process (not just mx.reset_peak_memory() between cases in the same process), to rule out allocator-state bias between runs. Results were identical either way.
Results
| context |
case |
peak MLX memory |
decode speed |
| 8,000 tok |
fp16 |
3.46 GB |
3.2 tok/s |
| 8,000 tok |
QuantizedKVCache 8-bit |
4.87 GB (+1.41 GB) |
2.6 tok/s |
| 32,000 tok |
fp16 |
4.72 GB |
1.0 tok/s |
| 32,000 tok |
QuantizedKVCache 8-bit |
7.10 GB (+2.38 GB) |
0.7 tok/s |
| 32,000 tok |
QuantizedKVCache 4-bit |
6.53 GB (+1.81 GB) |
0.6 tok/s |
Quantized cache used more peak memory in every case, at every bit width, and the gap widened with context length rather than shrinking. Decode throughput was also consistently lower under quantization.
Possible explanation
Per @angeloskath's comment on #941, QuantizedKVCache computes attention in float and only stores quantized — so the quantize/dequantize scratch buffers needed during prefill attention appear to cost more, in peak-memory terms, than the storage format saves, at least at these model/context sizes. mx.get_peak_memory() captures the max across the whole run (prefill included), so this may not reflect steady-state resident memory after prefill — that wasn't isolated separately here, and might tell a different story for very large contexts or larger models where cache dominates total memory more. Flagging as-is since the peak-memory number is what most callers will actually observe.
Happy to run additional configurations (different models, isolating post-prefill steady-state memory rather than run-peak, batched generation) if that would help narrow this down — let me know what would be most useful.
Summary
QuantizedKVCacheshows higher peak MLX memory than plain fp16KVCacheinstream_generate, at every bit width and context length tested, with the gap widening as context grows — the opposite of the expected result. Filing this as data in case it's useful alongside the compute-stays-float design gap already noted on #941.Environment
mlx-community/Llama-3.2-3B-Instruct-4bitReproduction
Each case run in its own fresh process (not just
mx.reset_peak_memory()between cases in the same process), to rule out allocator-state bias between runs. Results were identical either way.Results
QuantizedKVCache8-bitQuantizedKVCache8-bitQuantizedKVCache4-bitQuantized cache used more peak memory in every case, at every bit width, and the gap widened with context length rather than shrinking. Decode throughput was also consistently lower under quantization.
Possible explanation
Per @angeloskath's comment on #941,
QuantizedKVCachecomputes attention in float and only stores quantized — so the quantize/dequantize scratch buffers needed during prefill attention appear to cost more, in peak-memory terms, than the storage format saves, at least at these model/context sizes.mx.get_peak_memory()captures the max across the whole run (prefill included), so this may not reflect steady-state resident memory after prefill — that wasn't isolated separately here, and might tell a different story for very large contexts or larger models where cache dominates total memory more. Flagging as-is since the peak-memory number is what most callers will actually observe.Happy to run additional configurations (different models, isolating post-prefill steady-state memory rather than run-peak, batched generation) if that would help narrow this down — let me know what would be most useful.