Skip to content

QuantizedKVCache shows higher peak memory than fp16 KVCache (worse at longer contexts) #1587

Description

@gschaefer3

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions