Skip to main content

TurboQuant data-oblivious rotation quantization for MLX-LM, with custom Metal kernels.

Project description

mlx-turboquant

A standalone, pip-installable TurboQuant adapter for mlx-lm on Apple silicon, with a custom Metal kernel for non-uniform quantization.

TurboQuant (Zandieh, Daliri, Hadian, Mirrokni, 2025) is a data-oblivious (calibration-free) vector quantizer. Its core trick is a random rotation (a Randomized Hadamard Transform): rotating a vector spreads outliers across coordinates and turns the marginal into a concentrated, near-Gaussian distribution that low-bit scalar quantizers handle gracefully. Crucially, an orthogonal rotation preserves inner products, so attention scores computed on rotated queries and keys are unchanged — which is what makes it so effective for KV-cache quantization.

This adapter implements both regimes:

  • Weights (MSE regime). Rotate each weight matrix, then quantize. Rotation is the robust, always-on win; you can quantize with MLX's fast affine quantized_matmul (default) or with a custom non-uniform Lloyd–Max LUT Metal kernel (--mode lut).
  • KV cache (inner-product regime). A drop-in TurboQuantKVCache that stores keys in the rotated frame and rotates the query to match. This is where TurboQuant shines (see numbers below).

Install

pip install mlx-turboquant        # from PyPI
# or, from source:
pip install -e .

Requires mlx>=0.31.2 and mlx-lm on macOS/Apple silicon.

Quantize a model (weights)

turboquant convert --model mlx-community/Qwen3-0.6B-bf16 --out ./qwen3-tq4 --bits 4

Produces a standard mlx-lm model directory (safetensors + config.json with a quantization_config of quant_method: turboquant).

Run

import mlx_turboquant as tq
tq.register()                       # teach stock mlx-lm to load turboquant dirs
from mlx_lm import load, generate
model, tok = load("./qwen3-tq4")
print(generate(model, tok, prompt="Why is the sky blue?", max_tokens=128, verbose=True))

or the CLI:

turboquant generate --model ./qwen3-tq4 --prompt "Why is the sky blue?"

Serve (OpenAI-compatible HTTP API)

turboquant serve wraps mlx_lm.server: it installs the TurboQuant hooks (so a TurboQuant-quantized model dir loads through the stock server) and, optionally, swaps in the rotated TurboQuant KV cache — then forwards every other flag to mlx_lm.server.

# Serve a TurboQuant weight-quantized model (all mlx_lm.server flags pass through)
turboquant serve --model ./qwen3-tq4 --port 8080

# ...plus the rotated 4-bit KV cache, with the unbiased 1-bit QJL residual
turboquant serve --model ./qwen3-tq4 --port 8080 --kv-bits 4 --qjl

Then call it like any OpenAI endpoint:

curl http://127.0.0.1:8080/v1/chat/completions -H "Content-Type: application/json" \
  -d '{"model": "./qwen3-tq4", "messages": [{"role":"user","content":"Hello!"}]}'

--kv-bits / --kv-group-size / --qjl are the only TurboQuant-specific flags; everything else (--host, --port, --adapter-path, --max-tokens, …) is handled by mlx_lm.server. Requires mlx-lm>=0.31.3 (older versions use a single global generation stream that breaks under the server's worker threads).

Quantize the KV cache (long-context inference)

The KV cache is applied at generation time to any (even unquantized) model:

import mlx_turboquant as tq
from mlx_lm import load, generate
model, tok = load("mlx-community/Qwen3-1.7B-bf16")
cache = tq.make_prompt_cache(model, kv_bits=4, kv_group_size=64)   # rotated KV
# or, for the unbiased 1-bit QJL residual estimator (+1 bit/channel):
cache = tq.make_prompt_cache(model, kv_bits=3, kv_group_size=64, qjl=True)
generate(model, tok, prompt=..., max_tokens=256, prompt_cache=cache, verbose=True)

The 1-bit QJL residual (unbiased inner-product estimator)

The rotated-MSE key gives a biased attention score: <Rq, k̂> = <Rq, Rk> − <Rq, r> (it under-counts by the residual inner product <Rq, r>), and the bias is largest for the high-similarity keys softmax weights most. TurboQuant fixes this with a 1-bit QJL sketch of the residual r = Rk − k̂: store sign(RHT₂(r)) (1 bit/channel) and ‖r‖, then estimate <Rq, r> ≈ √(π/2d)·‖r‖·⟨RHT₂(Rq), sign(RHT₂(r))⟩. Adding it back yields an unbiased score. Verified numerically (tests/test_qjl.py): for correlated (query, key) pairs the MSE-only bias of ≈ −0.05 is removed to ≈ 0. Enable with qjl=True.

Measured results (benchmarks/kv_quality.py, 509-token context)

Teacher-forced perplexity with a quantized KV cache (lower is better):

Qwen3-1.7B (fp16 reference = 2.93):

KV bits plain affine KV TurboQuant KV TurboQuant + QJL (+1 b/ch)
8 2.91 2.93 2.91
4 31.39 💥 3.16 3.03
3 4625 77.5 4.82
2 2.1e6 28704 6937

Two effects, both the paper's claims, reproduced:

  1. Rotation — at 4-bit, plain affine KV collapses (ppl ~31) while TurboQuant's rotated KV stays near-neutral (3.16). Rotation preserves inner products and removes the outliers that wreck low-bit affine KV.
  2. QJL residual — the +1-bit unbiased correction closes the last gap: 4-bit KV becomes fp16-neutral (2.93), and it rescues 3-bit KV from unusable (77.5) to usable (5.55). This matches the paper's "quality-neutral at ~3.5 bits/channel".

(2-bit weight-only and ≤3-bit KV without QJL break these small models; the larger the model, the lower the bits you can push.)

Throughput, latency & memory (benchmarks/throughput.py, Qwen3-1.7B)

Median over 50 ShareGPT prompts of varied length (13–2631 tokens, median 224), 64 decode tokens each, on Apple silicon. prepare_sharegpt.py samples the prompts; throughput.py runs the grid.

config decode tok/s TPOT (ms) TTFT (ms) weights KV @ 2k ctx
MLX LM bf16 (baseline) 26.6 37.6 327 3.44 GB 0.23 GB
TurboQuant 4-bit + KV4 56.6 17.7 330 1.42 GB 0.066 GB
TurboQuant 4-bit + KV4 + QJL 27.6 36.3 543 1.42 GB 0.074 GB

Recommended config — TurboQuant 4-bit weights + rotated 4-bit KV:

  • ~2.1× faster decode than bf16 (56.6 vs 26.6 tok/s) and ~2.1× lower time-per-token (17.7 vs 37.6 ms) — memory-bound decode loves 4-bit weights, and the rotated KV cache adds essentially no overhead.
  • 2.4× smaller weights (3.44 → 1.42 GB) and ~3.5× smaller KV cache (0.23 → 0.066 GB at 2k context) — so you fit far longer contexts in the same memory, the real constraint for on-device long-context inference.
  • All while staying near fp16 quality at 4-bit KV where plain affine KV collapses (see above).

QJL mode (qjl=True) is the maximum-quality / maximum-compression option: it makes 3-bit KV usable and 4-bit KV fp16-neutral for only +1 bit/channel (0.066 → 0.074 GB). It trades decode speed for that quality (the unbiased correction adds an extra sketch dot-product per step), so reach for it when you are memory-bound at very low KV bits and want fp16-grade scores.

Memory footprint over long context

The KV cache — not the weights — is what grows with context and dominates memory for long sequences. Since MLX's affine KV is unusable at 4-bit (ppl ~31), the honest comparison is iso-quality: to stay near fp16, affine needs 8-bit KV while TurboQuant is neutral at 4-bit, so TurboQuant's KV cache is ~1.9× smaller at matched quality (and 3.5× smaller than fp16).

KV cache memory vs context length

Measured on Qwen3-1.7B (benchmarks/kv_memory.pyplot_kv_memory.py); the ShareGPT prompt range is shaded, extrapolated to long context. KB/token is measured directly from the cache arrays (verified constant across 2k/4k/8k, and matching the throughput benchmark's independent 0.066 GB @2k), so the GB columns are exact linear scaling — not estimates. ppl is the quality proxy from kv_quality.py at 509-token context (not re-measured at 32k/128k):

KV config quality (ppl) KB/token KV @ 32k KV @ 128k
fp16 2.93 112.0 3.76 GB 15.0 GB
MLX affine 8-bit 2.91 59.5 2.00 GB 8.0 GB
MLX affine 4-bit 31.4 ✗ 31.5 1.06 GB 4.2 GB
TurboQuant 4-bit 3.16 31.5 1.06 GB 4.2 GB
TurboQuant 3-bit + QJL 4.82 28.4 0.95 GB 3.8 GB

TurboQuant 4-bit uses the same bytes as affine 4-bit but is actually usable (3.16 vs 31.4). At 128k context that's a 4.2 GB KV cache instead of 8 GB (affine, matched quality) or 15 GB (fp16) — often the difference between fitting long context on-device or not.

The Metal kernels

TurboQuant needs two operations MLX can't express with built-ins, so the adapter ships two hand-written mx.fast.metal_kernels:

  1. Non-uniform LUT dequant + matmul (kernels/qmm.py). mx.quantized_matmul only supports uniform/affine codebooks; TurboQuant's MSE-optimal quantizer uses a non-uniform Lloyd–Max codebook. The kernel does LUT dequant + matmul directly on packed indices (one SIMD group reduces over K per output), bits ∈ {2,4,8}, and cuts weight-reconstruction MSE ~15% vs affine at 2-bit. Enable with --mode lut.
  2. Packed-sign QJL inner product (kernels/qjl_dot.py). The unbiased KV correction computes Σ_d qproj[d]·sign_bit(d) against the 1-bit residual sketch. The kernel reads the packed uint32 sign words directly and accumulates ±qproj per bit in fp32 — no dense unpack, no extra full matmul — powering the QJL KV path.

Rotations run on Metal via mx.hadamard_transform.

How it plugs in

  • Weights: nn.Linear → TurboQuantLinear module swap (same pattern as mlx-lm's bitnet_quantize). register() wraps mlx_lm.utils.load_model so turboquant dirs load through the stock mlx_lm.load / generate / server.
  • KV cache: TurboQuantKVCache subclasses mlx-lm's QuantizedKVCache (inherits all mask/state logic), rotates keys, and (with qjl=True) stores the 1-bit residual sketch; register() patches scaled_dot_product_attention to rotate the query and add the QJL correction.

Roadmap

  • A fused RHT Metal kernel (currently we reuse MLX's already-optimized mx.hadamard_transform).
  • 3-bit LUT packing (32 not divisible by 3).

Development

pip install -e ".[test]"
pytest -q                 # rotation invariance, codebook MSE, kernel numerics, KV + QJL correctness
python benchmarks/kv_quality.py  --model mlx-community/Qwen3-0.6B-bf16   # KV perplexity
python benchmarks/throughput.py  --model mlx-community/Qwen3-1.7B-bf16   # TPOT/TTFT over ShareGPT
python benchmarks/kv_memory.py && python benchmarks/plot_kv_memory.py    # memory chart

MIT licensed. Not affiliated with the TurboQuant authors or Apple.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mlx_turboquant-0.0.2.tar.gz (37.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mlx_turboquant-0.0.2-py3-none-any.whl (32.3 kB view details)

Uploaded Python 3

File details

Details for the file mlx_turboquant-0.0.2.tar.gz.

File metadata

  • Download URL: mlx_turboquant-0.0.2.tar.gz
  • Upload date:
  • Size: 37.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.5

File hashes

Hashes for mlx_turboquant-0.0.2.tar.gz
Algorithm Hash digest
SHA256 6c946786d0379e189e25722a147bc7ea5b93d18c00ba7db7abebb1df478bebac
MD5 75bdefb3d7627fca0bf2b6bd218dea61
BLAKE2b-256 b7b8d9dfd9ec5385abccfd3096d9ca0e80f17ad77354a888f1b6bf4e35da1d98

See more details on using hashes here.

File details

Details for the file mlx_turboquant-0.0.2-py3-none-any.whl.

File metadata

  • Download URL: mlx_turboquant-0.0.2-py3-none-any.whl
  • Upload date:
  • Size: 32.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.5

File hashes

Hashes for mlx_turboquant-0.0.2-py3-none-any.whl
Algorithm Hash digest
SHA256 3bff584d92dfeba8c43f17a9af4bc01cf167f4a063c7d4de1514471c948b342b
MD5 83b07966086146bf25baa54c172ea86c
BLAKE2b-256 f2046a8cb0543e761ca8af3be41c49ed5eaea1951e49d3859819fc956402e23e

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page