Skip to content

Commit 2bba367

Browse files
committed
Add TurboQuant KV cache compression for prefix cache
Adds --turbo-kv-bits flag (1-4) to compress stored prefix cache entries using TurboQuant (arXiv 2504.19874). 3-bit gives 4.6x compression vs FP16, compared to ~2x from the existing 8-bit quantization. Integration points: - memory_cache.py: _turbo_quantize_cache/_dequantize_cache, memory estimation, trim support, needs_dequantize property, config validation - scheduler.py: turbo_kv_bits in SchedulerConfig, propagation to MemoryCacheConfig - cli.py: --turbo-kv-bits for serve and bench commands Requires mlx-lm with TurboQuant support (ml-explore/mlx-lm#1067).
1 parent d235c37 commit 2bba367

3 files changed

Lines changed: 124 additions & 13 deletions

File tree

vllm_mlx/cli.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ def serve_command(args):
150150
kv_cache_quantization_bits=args.kv_cache_quantization_bits,
151151
kv_cache_quantization_group_size=args.kv_cache_quantization_group_size,
152152
kv_cache_min_quantize_tokens=args.kv_cache_min_quantize_tokens,
153+
# TurboQuant
154+
turbo_kv_bits=args.turbo_kv_bits,
153155
)
154156

155157
print("Mode: Continuous batching (for multiple concurrent users)")
@@ -169,7 +171,9 @@ def serve_command(args):
169171
else f"{args.cache_memory_percent*100:.0f}% of RAM"
170172
)
171173
print(f"Memory-aware cache: {cache_info}")
172-
if args.kv_cache_quantization:
174+
if args.turbo_kv_bits:
175+
print(f"TurboQuant: {args.turbo_kv_bits}-bit prefix cache compression")
176+
elif args.kv_cache_quantization:
173177
print(
174178
f"KV cache quantization: {args.kv_cache_quantization_bits}-bit, "
175179
f"group_size={args.kv_cache_quantization_group_size}"
@@ -248,6 +252,8 @@ async def run_benchmark():
248252
kv_cache_quantization_bits=args.kv_cache_quantization_bits,
249253
kv_cache_quantization_group_size=args.kv_cache_quantization_group_size,
250254
kv_cache_min_quantize_tokens=args.kv_cache_min_quantize_tokens,
255+
# TurboQuant
256+
turbo_kv_bits=args.turbo_kv_bits,
251257
)
252258
engine_config = EngineConfig(
253259
model_name=args.model,
@@ -687,6 +693,16 @@ def main():
687693
default=256,
688694
help="Minimum tokens for quantization to apply (default: 256)",
689695
)
696+
# TurboQuant KV cache compression (arXiv 2504.19874)
697+
serve_parser.add_argument(
698+
"--turbo-kv-bits",
699+
type=int,
700+
default=None,
701+
choices=[1, 2, 3, 4],
702+
help="TurboQuant KV cache compression bits for prefix cache. "
703+
"3-bit gives 4.6x compression vs FP16 (default: disabled). "
704+
"Replaces --kv-cache-quantization when set.",
705+
)
690706
serve_parser.add_argument(
691707
"--stream-interval",
692708
type=int,
@@ -966,6 +982,15 @@ def main():
966982
default=256,
967983
help="Minimum tokens for quantization to apply (default: 256)",
968984
)
985+
# TurboQuant KV cache compression
986+
bench_parser.add_argument(
987+
"--turbo-kv-bits",
988+
type=int,
989+
default=None,
990+
choices=[1, 2, 3, 4],
991+
help="TurboQuant KV cache compression bits for prefix cache. "
992+
"3-bit gives 4.6x compression vs FP16 (default: disabled).",
993+
)
969994
# Paged cache options (experimental)
970995
bench_parser.add_argument(
971996
"--use-paged-cache",

vllm_mlx/memory_cache.py

Lines changed: 94 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,18 @@ def estimate_kv_cache_memory(cache: list[Any]) -> int:
103103

104104
total_bytes = 0
105105

106+
try:
107+
from mlx_lm.models.turboquant_cache import TurboQuantKVCache as _TQ
108+
except ImportError:
109+
_TQ = None
110+
106111
for layer_cache in cache:
112+
# Handle TurboQuantKVCache — estimate from shape+dtype (avoid lazy eval)
113+
if _TQ is not None and isinstance(layer_cache, _TQ):
114+
if layer_cache.k_packed is not None:
115+
for arr in layer_cache.state:
116+
total_bytes += _array_memory(arr)
117+
continue
107118
# Handle different cache object types
108119
# Check dict first since dicts have .keys() method that would match below
109120
if isinstance(layer_cache, dict) and "state" in layer_cache:
@@ -165,6 +176,7 @@ class MemoryCacheConfig:
165176
kv_bits: int = 8
166177
kv_group_size: int = 64
167178
kv_min_quantize_tokens: int = 256
179+
turbo_kv_bits: int | None = None # TurboQuant: 1-4 bit, 4.6x compression at 3-bit
168180

169181
def __post_init__(self) -> None:
170182
if not 0.0 < self.max_memory_percent <= 1.0:
@@ -177,6 +189,15 @@ def __post_init__(self) -> None:
177189
raise ValueError(
178190
f"kv_min_quantize_tokens must be >= 0, got {self.kv_min_quantize_tokens}"
179191
)
192+
if self.turbo_kv_bits is not None and self.turbo_kv_bits not in (1, 2, 3, 4):
193+
raise ValueError(
194+
f"turbo_kv_bits must be 1-4, got {self.turbo_kv_bits}"
195+
)
196+
197+
@property
198+
def needs_dequantize(self) -> bool:
199+
"""Whether stored caches need dequantization on fetch."""
200+
return self.kv_quantize or self.turbo_kv_bits is not None
180201

181202
def compute_memory_limit(self) -> int:
182203
"""
@@ -255,14 +276,14 @@ def create(cls, tokens: list[int], cache: list[Any]) -> _CacheEntry:
255276

256277

257278
def _trim_cache_offset(cache: list[Any], trim_by: int) -> list[Any]:
258-
"""Create shallow copies of KVCache/QuantizedKVCache layers with offset reduced.
279+
"""Create shallow copies of KVCache/QuantizedKVCache/TurboQuantKVCache layers
280+
with offset reduced.
259281
260282
This is used when returning a cached KV state to the scheduler so that
261283
the last N positions are "freed" and the model will recompute them on the
262284
next forward pass (preventing duplicate KV entries).
263285
264-
Supports both KVCache (keys/values are arrays) and QuantizedKVCache
265-
(keys/values are 3-tuples of arrays).
286+
Supports KVCache, QuantizedKVCache, and TurboQuantKVCache.
266287
"""
267288
from mlx_lm.models.cache import KVCache
268289

@@ -271,6 +292,11 @@ def _trim_cache_offset(cache: list[Any], trim_by: int) -> list[Any]:
271292
except ImportError:
272293
QuantizedKVCache = None # noqa: N806
273294

295+
try:
296+
from mlx_lm.models.turboquant_cache import TurboQuantKVCache
297+
except ImportError:
298+
TurboQuantKVCache = None # noqa: N806
299+
274300
trimmed: list[Any] = []
275301
for layer_cache in cache:
276302
if QuantizedKVCache is not None and isinstance(layer_cache, QuantizedKVCache):
@@ -281,6 +307,18 @@ def _trim_cache_offset(cache: list[Any], trim_by: int) -> list[Any]:
281307
tc.group_size = layer_cache.group_size
282308
tc.bits = layer_cache.bits
283309
trimmed.append(tc)
310+
elif TurboQuantKVCache is not None and isinstance(
311+
layer_cache, TurboQuantKVCache
312+
):
313+
# Shallow copy with adjusted offset (do NOT mutate original)
314+
tc = TurboQuantKVCache.__new__(TurboQuantKVCache)
315+
tc.__dict__.update(layer_cache.__dict__)
316+
tc.offset = max(layer_cache.offset - trim_by, 0)
317+
tc._k_deq_buf = None # invalidate decode buffer
318+
tc._v_deq_buf = None
319+
tc._deq_offset = 0
320+
tc._deq_alloc = 0
321+
trimmed.append(tc)
284322
elif (
285323
hasattr(layer_cache, "offset")
286324
and hasattr(layer_cache, "keys")
@@ -366,11 +404,33 @@ def _quantize_cache(cache: list[Any], bits: int = 8, group_size: int = 64) -> li
366404
return quantized
367405

368406

407+
def _turbo_quantize_cache(cache: list[Any], bits: int = 3) -> list[Any]:
408+
"""Compress KVCache layers with TurboQuant (4.6x at 3-bit).
409+
410+
Uses PolarQuant: randomized Hadamard rotation + Lloyd-Max codebook
411+
quantization with fused Metal kernels. See arXiv 2504.19874.
412+
"""
413+
from mlx_lm.models.cache import KVCache
414+
415+
compressed = []
416+
for layer in cache:
417+
if isinstance(layer, KVCache) and layer.keys is not None:
418+
compressed.append(layer.to_turbo_quantized(bits=bits))
419+
else:
420+
compressed.append(layer)
421+
return compressed
422+
423+
369424
def _dequantize_cache(cache: list[Any]) -> list[Any]:
370-
"""Dequantize QuantizedKVCache layers back to regular KVCache."""
425+
"""Dequantize QuantizedKVCache or TurboQuantKVCache layers back to KVCache."""
371426
import mlx.core as mx
372427
from mlx_lm.models.cache import KVCache, QuantizedKVCache
373428

429+
try:
430+
from mlx_lm.models.turboquant_cache import TurboQuantKVCache
431+
except ImportError:
432+
TurboQuantKVCache = None # noqa: N806
433+
374434
result = []
375435
for layer in cache:
376436
if isinstance(layer, QuantizedKVCache) and layer.keys is not None:
@@ -383,6 +443,23 @@ def _dequantize_cache(cache: list[Any]) -> list[Any]:
383443
)
384444
kv.offset = layer.offset
385445
result.append(kv)
446+
elif TurboQuantKVCache is not None and isinstance(layer, TurboQuantKVCache) and not layer.empty():
447+
# Ensure quantizer is initialized (needed after from_state)
448+
if layer._k_q is None:
449+
layer._ensure_quantizer(layer._k_dim, layer._v_dim)
450+
B, H = layer.k_packed.shape[:2]
451+
dtype = layer._dtype if layer._dtype is not None else mx.float16
452+
k_all = layer._full_dequant(
453+
layer.k_packed, layer.k_norms, layer._k_q,
454+
layer._k_dim, B, H, layer.offset, dtype,
455+
)
456+
v_all = layer._full_dequant(
457+
layer.v_packed, layer.v_norms, layer._v_q,
458+
layer._v_dim, B, H, layer.offset, dtype,
459+
)
460+
kv = KVCache()
461+
kv.update_and_fetch(k_all, v_all)
462+
result.append(kv)
386463
else:
387464
result.append(layer)
388465
return result
@@ -481,7 +558,7 @@ def fetch(self, tokens: list[int]) -> tuple[list[Any] | None, list[int]]:
481558
self._last_match_type = "exact"
482559
cache_out = (
483560
_dequantize_cache(entry.cache)
484-
if self._config.kv_quantize
561+
if self._config.needs_dequantize
485562
else entry.cache
486563
)
487564
return cache_out, []
@@ -539,7 +616,7 @@ def fetch(self, tokens: list[int]) -> tuple[list[Any] | None, list[int]]:
539616
excess = n_cached - n_requested
540617

541618
has_non_trimmable = any(
542-
not (hasattr(lc, "offset") and hasattr(lc, "keys"))
619+
not (hasattr(lc, "is_trimmable") and lc.is_trimmable())
543620
for lc in best_super.cache
544621
)
545622

@@ -556,7 +633,7 @@ def fetch(self, tokens: list[int]) -> tuple[list[Any] | None, list[int]]:
556633
self._last_match_type = "supersequence"
557634
trimmed_cache = (
558635
_dequantize_cache(trimmed_cache)
559-
if self._config.kv_quantize
636+
if self._config.needs_dequantize
560637
else trimmed_cache
561638
)
562639
return trimmed_cache, []
@@ -567,7 +644,7 @@ def fetch(self, tokens: list[int]) -> tuple[list[Any] | None, list[int]]:
567644
self._last_match_type = "supersequence"
568645
cache_out = (
569646
_dequantize_cache(best_super.cache)
570-
if self._config.kv_quantize
647+
if self._config.needs_dequantize
571648
else best_super.cache
572649
)
573650
return cache_out, []
@@ -581,7 +658,7 @@ def fetch(self, tokens: list[int]) -> tuple[list[Any] | None, list[int]]:
581658
self._last_match_type = "prefix"
582659
cache_out = (
583660
_dequantize_cache(best_match.cache)
584-
if self._config.kv_quantize
661+
if self._config.needs_dequantize
585662
else best_match.cache
586663
)
587664
return cache_out, remaining
@@ -624,7 +701,7 @@ def fetch(self, tokens: list[int]) -> tuple[list[Any] | None, list[int]]:
624701
excess = len(best_lcp_entry.tokens) - best_lcp_length
625702

626703
has_non_trimmable = any(
627-
not (hasattr(lc, "offset") and hasattr(lc, "keys"))
704+
not (hasattr(lc, "is_trimmable") and lc.is_trimmable())
628705
for lc in best_lcp_entry.cache
629706
)
630707
logger.debug(
@@ -648,7 +725,7 @@ def fetch(self, tokens: list[int]) -> tuple[list[Any] | None, list[int]]:
648725
self._last_match_type = "lcp"
649726
trimmed_cache = (
650727
_dequantize_cache(trimmed_cache)
651-
if self._config.kv_quantize
728+
if self._config.needs_dequantize
652729
else trimmed_cache
653730
)
654731
return trimmed_cache, remaining
@@ -693,8 +770,13 @@ def store(
693770
# Trim oversized KV arrays to actual used size
694771
cache = _trim_to_offset(cache)
695772

696-
# Quantize if enabled and sequence is long enough
773+
# Compress KV cache for storage: TurboQuant (4.6x) or standard quantization (2x)
697774
if (
775+
self._config.turbo_kv_bits is not None
776+
and len(tokens) >= self._config.kv_min_quantize_tokens
777+
):
778+
cache = _turbo_quantize_cache(cache, self._config.turbo_kv_bits)
779+
elif (
698780
self._config.kv_quantize
699781
and len(tokens) >= self._config.kv_min_quantize_tokens
700782
):

vllm_mlx/scheduler.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ class SchedulerConfig:
7777
kv_cache_quantization_group_size: int = 64
7878
kv_cache_min_quantize_tokens: int = 256
7979

80+
# TurboQuant KV cache compression (4.6x at 3-bit, replaces standard quantization)
81+
turbo_kv_bits: Optional[int] = None # 1-4 bit; None = disabled
82+
8083
# Paged cache settings (experimental - for memory efficiency)
8184
use_paged_cache: bool = (
8285
False # Use BlockAwarePrefixCache instead of PrefixCacheManager
@@ -1020,6 +1023,7 @@ def __init__(
10201023
kv_bits=self.config.kv_cache_quantization_bits,
10211024
kv_group_size=self.config.kv_cache_quantization_group_size,
10221025
kv_min_quantize_tokens=self.config.kv_cache_min_quantize_tokens,
1026+
turbo_kv_bits=self.config.turbo_kv_bits,
10231027
)
10241028
self.memory_aware_cache = MemoryAwarePrefixCache(
10251029
model=model,

0 commit comments

Comments
 (0)