Expose existing KV-cache quantization behind an env-var opt-in - #2242
Open
alytaphoenix wants to merge 1 commit into
Open
Expose existing KV-cache quantization behind an env-var opt-in#2242alytaphoenix wants to merge 1 commit into
alytaphoenix wants to merge 1 commit into
Conversation
Investigated adding Google's TurboQuant (rotation + Lloyd-Max KV-cache compression) per a follow-up request. It's not merged into mlx or mlx_lm upstream (open feature requests: ml-explore/mlx-lm#1060, ml-explore/mlx#3404) and only exists as a handful of small third-party MLX reimplementations -- not something to depend on directly. Along the way, found that exo already has a working (but unreachable) KV-cache quantization path: mlx_lm's QuantizedKVCache is wired into every generation code path here -- make_kv_cache's direct construction and mlx_lm's maybe_quantize_kv_cache during stream_generate/pipeline prefill -- but gated behind hardcoded constants (KV_CACHE_BITS/KV_BITS = None) with no way to enable without editing code. This PR exposes that existing capability via env vars instead of building anything new: - EXO_KV_CACHE_BITS: bits to quantize the KV cache to (e.g. 4 or 8). Unset (default) preserves current full-precision behavior. - EXO_KV_CACHE_GROUP_SIZE: quantization group size, default 64. Also consolidates two previously-separate, inconsistent group-size constants (KV_GROUP_SIZE=32 for the generate/stream_generate path, CACHE_GROUP_SIZE=64 for make_kv_cache's direct construction) into one KV_CACHE_GROUP_SIZE. This is a behavior change beyond pure exposure: once EXO_KV_CACHE_BITS is set, the generate path now quantizes at group size 64 instead of 32 (finer-grained scale/bias overhead is smaller relative to compression at 64 -- if anything an improvement, but worth knowing). Verified the existing prefix-cache reuse logic (KVPrefixCache) already handles QuantizedKVCache correctly: _entry_length explicitly lists it in its type union, and its rollback goes through the shared _BaseCache.trim()/.offset interface -- the same generic path used for plain KVCache -- rather than the RotatingKVCache/ArraysCache-specific snapshot machinery. What's genuinely unverified is real-hardware behavior; this environment has no mlx runtime to test against. Known open question, not resolved here: PR exo-explore#2240 added max_context_length, which makes make_kv_cache build a RotatingKVCache instead of a plain KVCache. Whether mlx_lm's maybe_quantize_kv_cache (invoked via kv_bits on every stream_generate/pipeline_parallel_prefill call) behaves correctly when handed a RotatingKVCache is untested -- combining a context cap with quantization is an interaction this PR doesn't attempt to validate. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011rjSfwDBTkmySmfU6NgHKF
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This started as an investigation into adding Google's TurboQuant (rotation + Lloyd-Max KV-cache compression, Google Research blog, arXiv) as a memory-reduction option. Findings on that:
Building real TurboQuant support would mean writing correctness-critical rotation+quantization math ourselves with no canonical reference — out of scope for now.
What this PR actually does instead: exposes a KV-cache quantization capability that already exists in this codebase but is unreachable.
make_kv_cache()(cache.py) already constructsQuantizedKVCachewhenKV_CACHE_BITSis set, and every generation call site (prefill(),mlx_generate(),pipeline_parallel_prefill()ingenerate.py) already passeskv_bits/kv_group_sizeinto mlx_lm's ownstream_generate/maybe_quantize_kv_cache. Both were hardcoded toNoneinconstants.pywith no config surface — pure dead code. This PR turns them into env vars:EXO_KV_CACHE_BITS: bits to quantize the KV cache to (e.g.4or8). Unset (default) preserves current full-precision behavior — opt-in, no behavior change by default.EXO_KV_CACHE_GROUP_SIZE: quantization group size, default64.Notable side-effect: group-size consolidation
There were two separate, inconsistent group-size constants:
KV_GROUP_SIZE=32(used by thestream_generate/pipeline-prefill path) andCACHE_GROUP_SIZE=64(used bymake_kv_cache's direct construction). This PR unifies them into oneKV_CACHE_GROUP_SIZE(default 64). This is harmless while quantization is off (group size is ignored whenkv_bits=None), but it means once someone setsEXO_KV_CACHE_BITS, thestream_generatepath now quantizes at group 64 instead of the old 32 — coarser grouping, smaller relative scale/bias overhead, likely a minor improvement, but flagging it as a behavior change beyond pure exposure.Investigated but not resolved: prefix-cache + quantization interaction
Before shipping this, I checked whether exo's prefix-cache reuse (
KVPrefixCacheincache.py) actually handlesQuantizedKVCachecorrectly, since that seemed like the obvious risk. It does:_entry_lengthexplicitly listsQuantizedKVCachein its type union with a comment, and its rollback goes through the shared_BaseCache.trim()/.offsetinterface — the same generic path used for plainKVCache— not theRotatingKVCache/ArraysCache-specific snapshot machinery. This looks like it was deliberately built to supportQuantizedKVCache, just never wired to a config knob.What's still unverified: actual behavior on real Apple Silicon hardware — this development environment has no mlx runtime, so nothing here has been exercised end-to-end.
One open interaction, not addressed here: #2240 (context-window support, not yet merged) makes
make_kv_cachebuild aRotatingKVCachewhen a context cap (max_context_length) is set. Whether mlx_lm'smaybe_quantize_kv_cache(invoked viakv_bitson everystream_generate/pipeline_parallel_prefillcall, unconditionally of cache type) behaves correctly when handed aRotatingKVCacheinstead of a plainKVCacheis untested. Combining a context cap with KV quantization is a combination this PR doesn't validate — flagging for whoever tests this on hardware.Test plan
uv run basedpyright(full repo, not just touched files) — 319 errors / 110 warnings, identical tomainbaseline (all pre-existing, due to mlx not being installed in this environment)uv run ruff check(full repo) — all checks passeduv run ruff format --check— all touched files already formatteduv run pytest src/exo(excluding mlx-dependent test dirs, which can't collect without mlx installed) — 321 passed, 3 skipped, no regressionsEXO_KV_CACHE_BITS=4 EXO_KV_CACHE_GROUP_SIZE=32correctly sets the constants; unset correctly defaults toNone/64(current behavior preserved)nix fmt/nix flake checknot run in this environment (ruff substituted)🤖 Generated with Claude Code
https://claude.ai/code/session_011rjSfwDBTkmySmfU6NgHKF