Files
elizaos--eliza/packages/training/scripts/quantization/README.md
T
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

735 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Post-Training Quantization
This directory holds the post-training quantization passes used to
shrink the fine-tuned Eliza checkpoints before they leave the
training rig. Each method is a self-contained CLI; they are independent
and can be combined or compared on the same fine-tuned checkpoint.
> **Gemma 4 cutover note.** The eliza-1 base is now Gemma 4 (dense:
> alternating SWA/global, shared-KV, MQA, dual head dims 512/256, stock q8_0
> KV). Gemma geometry is the active release target. The shipping Gemma weight
> quant is stock llama.cpp `Q4_K_M` from `gguf-q4_k_m_apply.py`; TurboQuant
> and QJL are runtime KV-cache experiments, and PolarQuant is a separate
> weight-quant experiment. Those optional paths must be revalidated per tier
> before their sidecars can be cited as release provenance.
## PolarQuant
Reference: Caio Vicentino, *PolarQuant: Optimal Gaussian Weight
Quantization via Hadamard Rotation for LLM Compression*, arXiv:2603.29078
(March 2026). The arXiv PDF was withdrawn for errata fixes; the
implementation we run against lives in
[`caiovicentino/eoq-quantization`](https://github.com/caiovicentino/eoq-quantization)
@ commit `15a12160245d7d3015290c6c5b6dbb7f22094d5e`. The two source
files we depend on are vendored under `polarquant/` (see
`polarquant/LICENSE.md` for the upstream license situation — currently
no LICENSE file in the upstream repo; vendored under a documented
research-citation arrangement that must be revisited if the upstream
project publishes a restrictive license).
### Algorithm in one paragraph
For each linear's weight tensor, group the values into power-of-two
blocks (default 128). Per block: extract the L2 norm and store it as
fp16; normalize the block to the unit hypersphere; multiply by a
WalshHadamard matrix so that each coordinate is an i.i.d. draw from
roughly N(0, 1/√d); quantize each coordinate against the **LloydMax
optimal centroids for N(0, 1)** (precomputed via the iterative
conditional-expectation algorithm in `polarquant/polar_quant.py`),
storing the centroid index as int8; optionally append a 1-bit QJL
residual sign per block as a cheap correction. At inference, undo
each step: lookup centroid → divide by √d → inverse Hadamard → rescale
by per-block norm. The Hadamard rotation alone accounts for ~98% of
the perplexity recovery vs absmax (paper, Ablation §4.2). PolarQuant
is **data-free** — no calibration set is required — because the
post-rotation distribution is analytically Gaussian.
### Tradeoffs
- **Pros.** Data-free; near-lossless at Q5 (paper claims very small PPL
deltas on decoder-only checkpoints vs FP16). int8 codes + fp16 per-block norms gives the
storage payload that downstream INT4 inference kernels (torchao,
llama.cpp, MLX) consume directly. Architecture-agnostic at the
``nn.Linear`` level.
- **Cons.** Reconstruction-back-into-fp16 (the path we use today) does
**not** save inference VRAM by itself — the model still loads as a
fp16 ``nn.Linear`` matrix; the win shows up when the codes-only
payload is loaded by an INT4 inference kernel. The sidecar
``polarquant_artifacts.safetensors`` we emit is the artifact for
that downstream path. The vendored compute kernel is a pure-Python
PyTorch loop over linears (no Triton, no fused kernel), so the
*quantization step itself* is slow on big models — it costs O(N)
Hadamard products at quantization time, then is free at inference.
### Supported architectures
The vendored kernel runs on any model that exposes its weights as
``nn.Linear`` modules. We have explicitly verified the active path on:
- Gemma (`google/gemma-4-E2B`)
- Llama, Mistral, Phi-3 style decoder stacks by structural inspection
#### Gemma compatibility notes
PolarQuant operates on `nn.Linear` weights, so it quantizes the Q/K/V/O
and MLP projections that Gemma exposes through the HF model graph. Keep
these constraints in mind before adding a new tier:
1. Non-linear recurrent/state buffers, if present on a future hybrid
tier, are **not** `nn.Linear` and must stay outside PolarQuant.
2. Vision-language variants must expose the text decoder before calling
`quantize_checkpoint`; use the text config/model tower, not the vision
encoder.
3. Future MoE router weights are tiny, fall under the `--min-numel`
cutoff, and must be deliberately skipped when that line is reintroduced.
### CLI
Quantize a fine-tuned 2B checkpoint:
```bash
uv run python scripts/quantization/polarquant_apply.py \
--model checkpoints/gemma4-e2b-eliza/final \
--calibration data/final/val.jsonl \
--calibration-samples 128 \
--output checkpoints/gemma4-e2b-eliza/final-polarquant
```
The `--calibration*` flags are accepted for parity with the rest of
the quantization pipeline but are **unused** — PolarQuant is data-free
per the paper. Passing a non-existent calibration path still errors so
that misconfigured callers fail loudly.
Useful knobs:
- `--bits {2,3,4,5,6}` (default 4). Q5 is the paper's near-lossless
point; Q4 is the practical default for INT4 inference kernels.
- `--block-size N` (default 128, must be a power of 2).
- `--no-qjl` to drop the 1-bit residual correction (slightly worse
PPL, slightly smaller sidecar).
- `--no-artifacts` to skip writing the sidecar codes; useful when you
only want the reconstructed fp16 model.
- `--include-lm-head` / `--include-embedding` to override the default
skip behavior. Quantizing the LM head on weight-tied models is
redundant; quantizing embedding tables hurts logits because they're
looked up rather than multiplied.
### Validation
`scripts/quantization/test_polarquant.py` runs the round-trip on
`google/gemma-4-E2B`, using 5 native JSON-shaped samples
from `data/final/val.jsonl`. It asserts (a) the codes-only payload is
at least 30% smaller than the fp16 baseline checkpoint and (b) the
quantized model produces non-degenerate text on every sample.
```bash
uv run python scripts/quantization/test_polarquant.py
```
## TurboQuant
Reference: Amir Zandieh, Majid Daliri, Majid Hadian, Vahab Mirrokni
(Google Research / Google DeepMind / NYU), *TurboQuant: Online Vector
Quantization with Near-Optimal Distortion Rate*, **arXiv:2504.19874**,
ICLR 2026. Implementation:
[`vivekvar-dl/turboquant`](https://github.com/vivekvar-dl/turboquant)
shipped to PyPI as `turbokv` v0.1.0 (import name: `turboquant`). Pinned
in `pyproject.toml` under `[project.optional-dependencies] train`.
### What this is — and what it is NOT
TurboQuant is an **online, data-oblivious KV-cache quantizer**. It runs
at inference time and replaces the standard fp16 ``DynamicCache`` with
a ``TurboQuantCache`` that stores keys and values in 2- or 4-bit
packed form per coordinate.
It is **not** a weight quantizer. The ``model.safetensors`` file does
not change. ``turboquant_apply.py`` therefore writes the merged base
model unchanged and adds a ``turboquant.json`` sidecar that records the
quantizer config (`nbits`, `base_seed`, calibrated `skip_layers`,
`residual_length`) so a downstream loader can reconstruct the cache
deterministically.
### Algorithm in one paragraph
For each cached vector ``x`` of length ``head_dim`` (per attention
head, per token): (1) extract and store ``||x||`` in bf16; (2) rotate
the unit vector by a fixed-per-layer random orthogonal matrix Π drawn
from SO(d) via QR-decomposition with sign-fix — after rotation each
coordinate is approximately Beta-distributed (≈ N(0, 1/d) for large
d); (3) bucketize each coordinate against the precomputed Lloyd-Max
optimal centroids for the Beta distribution at b bits; (4) bit-pack
the indices into uint8 (2 indices/byte at 4-bit, 4 indices/byte at
2-bit). At attention time, unpack → centroid lookup → inverse rotation
``x ≈ ||x|| · ΠᵀC[i]``. The recent ``residual_length`` tokens stay in
fp16 to keep the freshly-generated context lossless.
### Tradeoffs
- **Pros.** Data-free / online — calibration is a single forward pass
used only to detect outlier-norm layers (typically only layer 0) that
should stay fp16. Drops naturally into ``model.generate`` via
``past_key_values=cache``. Works across Gemma/Llama/Phi-style decoder
architectures without per-model code paths. Information-theoretic
near-optimal: paper proves the rate is within ~2.7× the per-channel
Shannon-Bennett lower bound.
- **Cons.** The reference implementation is pure PyTorch — the
per-step quantize/dequantize is a Python-level operation per layer
per step, which costs throughput. On a gemma-4-E2B model on a 5080 we
observed **~5× slowdown** vs the bf16 ``DynamicCache``
(66.8 → 12.2 tok/s). The TurboQuant paper claims faster runtime than
the bf16 baseline because it ships **Triton kernels**; those are not
in the `turbokv` 0.1.0 PyPI release we depend on. Until upstream
ships Triton, this method is a *memory* win, not a *speed* win.
- The savings are concentrated in the long-context regime. At 4096-
token prefill on gemma-4-E2B we measured **3.52× per-token KV
reduction** (114,688 → 32,608 bytes/token) which produced a real
274 MB peak-VRAM drop on a tiny model — the absolute savings scale
with `num_hidden_layers × num_kv_heads × head_dim × context_length`.
### Supported architectures
`TurboQuantCache` materializes a `TurboQuantLayer` per full-attention
layer reported by the model config. Verified locally against:
- Gemma (`google/gemma-4-E2B`) using the active validation harness.
Should work, by structural inspection, on:
- Llama and Phi style full-attention decoders with GQA, the same shape
`TurboQuantLayer` already handles.
#### Gemma hybrid-cache notes
Gemma tiers can declare per-layer `layer_types`. TurboQuant is only
meaningful for layers with a standard (B, H, T, D) KV cache; recurrent or
state-space layers have no KV tensor to quantize. Concretely on
gemma-4-E2B, 6 of 24 layers are full attention, so the analytic ceiling
on KV reduction is capped by those layers. The
`kv_bytes_per_token_analytic` helper in `test_turboquant.py` honors
`layer_types` so the reported reduction factor is correct for hybrid
models.
For vision-language Gemma variants, `TurboQuantCache(model.config, ...)`
must receive the **text decoder config** —
`model.config.get_text_config(decoder=True)` when the config provides it.
The `cache.py` in `turbokv` 0.1.0 already calls `get_text_config` when
available.
For future dense/MoE variants, TurboQuant is orthogonal to expert routing
when the KV cache shape is unchanged. Revalidate that separately before
adding those tiers back to the active release line.
### CLI
Apply to a fine-tuned 2B checkpoint (auto-merges if `--model` points
to a LoRA adapter):
```bash
uv run python scripts/quantization/turboquant_apply.py \
--model checkpoints/gemma4-e2b-eliza/final \
--calibration data/final/val.jsonl \
--calibration-samples 128 \
--output checkpoints/gemma4-e2b-eliza/final-turboquant
```
Useful knobs:
- `--nbits {2,4}` (default 4). 4-bit hits ~3.5× KV reduction with
~zero quality loss; 2-bit hits ~6.4× with measurable but small
degradation per the paper.
- `--residual-length N` (default 128). Most-recent N tokens stay in
fp16 to keep freshly-generated context lossless.
- `--base-seed N` (default 42). Layer i uses `seed = base_seed + i`.
Pin this to the value recorded in `turboquant.json` at inference
time; otherwise the rotation matrices will not match and dequant
gives garbage.
- `--norm-threshold f` (default 5.0). Calibration skips layers whose
per-token key norms exceed `f × median`.
### Inference-time use
```python
import json
from turboquant import TurboQuantCache
from transformers import AutoModelForCausalLM, AutoTokenizer
side = json.load(open("checkpoints/gemma4-e2b-eliza/final-turboquant/turboquant.json"))
model = AutoModelForCausalLM.from_pretrained(
"checkpoints/gemma4-e2b-eliza/final-turboquant",
torch_dtype="bfloat16", device_map="cuda",
)
tok = AutoTokenizer.from_pretrained("checkpoints/gemma4-e2b-eliza/final-turboquant")
cache = TurboQuantCache(
model.config,
nbits=side["nbits"],
residual_length=side["residual_length"],
base_seed=side["base_seed"],
skip_layers=set(side["skip_layers"]),
)
out = model.generate(**tok("...", return_tensors="pt").to("cuda"),
past_key_values=cache, max_new_tokens=256)
```
### Validation
`scripts/quantization/test_turboquant.py` runs the round-trip on
`google/gemma-4-E2B`, with 5 native JSON-shaped prompts from
`data/final/val.jsonl` and a 4096-token long-context probe. It asserts
(a) the per-token KV-cache size shrinks by at least 30% and (b) every
quantized output is non-empty and not degenerate.
```bash
uv run python scripts/quantization/test_turboquant.py
```
The full numeric report is written to
`scripts/quantization/turboquant_report.json`. Last measured run on
gemma-4-E2B / 5080 (4-bit, skip={0}, 4096-token long context):
| metric | baseline (bf16 DynamicCache) | TurboQuant 4-bit | delta |
|---|---|---|---|
| KV bytes/token | 114,688 | 32,608 | **3.52× / -71.6%** |
| Peak VRAM (4096-tok prefill) | 1.783 GB | 1.509 GB | -274 MB |
| Tok/s (5 short prompts × 128 new) | 66.8 | 12.2 | -82% (no Triton) |
## Fused TurboQuant (Triton)
Reference: same paper as TurboQuant above (arXiv:2504.19874). Implementation:
[`fused-turboquant`](https://pypi.org/project/fused-turboquant/) v0.1.0 — a
re-implementation of the TurboQuant scheme in **Triton kernels** (encode,
decode, fused Q@K^T scoring directly from packed indices). The math is
identical to ``turbokv`` 0.1.0 above; the win is throughput.
Pinned in `pyproject.toml` under `[project.optional-dependencies] train`
alongside `turbokv`. The two are kept side-by-side because (a) `turbokv` is
the architecture-portable fallback when ``patch_model`` rejects a model
(non-power-of-2 head_dim, fused QKV, sliding window), and (b) `fused-turboquant`
needs a working Triton + system-dev-headers stack that ``turbokv`` does not.
### CLI
Apply to a fine-tuned 27B checkpoint (auto-merges if `--model` points to a
LoRA adapter):
```bash
uv run python scripts/quantization/fused_turboquant_apply.py \
--model checkpoints/gemma4-e4b-eliza/final \
--output checkpoints/gemma4-e4b-eliza/final-fused-turboquant \
--bits 4
```
The script runs ``check_model_compatibility(model)`` first and refuses to
proceed if the architecture isn't supported (logged with a structured
report). After patching it discards the cache, unpatches the model, and
saves the **unmodified** base weights — fused-TurboQuant is a runtime
concern, the safetensors files are byte-identical to the input.
`--calibration` / `--calibration-samples` are accepted for parity with the
other quantizer CLIs but are unused: the Lloyd-Max codebooks and RHT seeds
are data-oblivious.
### Inference-time use
```python
from quantization.fused_turboquant_vendored.hf import patch_model
from transformers import AutoModelForCausalLM, AutoTokenizer
model = AutoModelForCausalLM.from_pretrained(
"checkpoints/gemma4-e4b-eliza/final-fused-turboquant",
torch_dtype="bfloat16", device_map="cuda",
)
tok = AutoTokenizer.from_pretrained("checkpoints/gemma4-e4b-eliza/final-fused-turboquant")
cache = patch_model(model, bits=4, compress_v=True) # patches model.forward in-place
out = model.generate(**tok("...", return_tensors="pt").to("cuda"),
past_key_values=cache, use_cache=True, max_new_tokens=256)
```
### Compatibility constraints (read this before deploying)
The Triton-kernel path is more constrained than the pure-PyTorch
``turbokv`` 0.1.0 path. The script's first step is
``check_model_compatibility(model)`` — these are the failure modes:
- **`head_dim` must be a power of 2** ∈ {64, 128, 256}. The Randomized
Hadamard Transform in `fused_turboquant.kernels.triton_rht` is built
around butterfly operations and has no implementation for arbitrary
dims. Verified on Gemma text decoders with supported head_dim values.
- **Separate Q/K/V projections required.** Fused-QKV models (`qkv_proj`,
`c_attn`) are rejected — `make_fused_attention_forward` raises with a
clear error rather than producing garbage.
- **No sliding-window attention.** Models with `sliding_window` set on
the config or attention module are rejected (e.g., Mistral-Sliding,
Gemma-2). The kernel is causal-full-attention only.
- **No attention logit softcapping** (Gemma-2-style).
- **RoPE expected.** ALiBi / learned positional embeddings produce
incorrect results; `check_model_compatibility` warns when RoPE isn't
detected in config.
- **Hybrid decoder models**: only the full-attention layers are patched;
recurrent/state-space layers keep their native state. The compatibility
checker reports `compatible=True` when the Triton path can run, but the
savings scale only with the full-attention layer count. **Note**: the bonus
gemma-4-E2B run in our local test failed at the *baseline* generate
step (HF `DynamicCache` is not the right cache for a hybrid model —
it raises `has_previous_state can only be called on LinearAttention
layers`); fused-TurboQuant is orthogonal to that issue. Hybrid models
need a `HybridCache` wrapped around `CompressedKVCache`, which is
upstream work. We document the gap and skip the bonus run.
### Blackwell / RTX 5080 Laptop notes
The 5080 Laptop is **sm_120** (Blackwell), CUDA 13.0, torch 2.11. Triton
3.6.0 supports this architecture. The build does, however, require Python
development headers to compile its `cuda_utils.so` shim on first use:
```bash
sudo apt install python3.12-dev
```
If the headers are missing the kernel raises a confusing error nested
inside the smoke test — see the "Last measured run" section below for the
exact failure mode. Without the headers the JIT cannot build and
`patch_model(..., verify=True)` fails on the very first forward pass.
### Validation
`scripts/quantization/test_fused_turboquant.py` runs three paths back-to-
back on `google/gemma-4-E2B` with 5 prompts × 128 new tokens at a 4096-token
prompt. It writes the full report to
`scripts/quantization/fused_turboquant_report.json` and asserts:
- fused peak VRAM ≤ pure-PyTorch turbokv peak VRAM, and
- fused tok/s ≥ 1.5x pure-PyTorch turbokv tok/s.
```bash
uv run python scripts/quantization/test_fused_turboquant.py
```
#### Last measured run (gemma-4-E2B, 5080 Laptop, 4-bit, 4096-token prompt + 128 new tokens)
| path | peak VRAM | tokens/sec | notes |
|---|---|---|---|
| baseline (bf16 DynamicCache) | 1.782 GB | 33.34 tok/s | HF default |
| pure-PyTorch turbokv 0.1.0 | 1.496 GB | 9.59 tok/s | -286 MB vs baseline; **3.48x slower** (Python per-step quant/dequant) |
| fused-turboquant 0.1.0 | **BLOCKED** | **BLOCKED** | Triton JIT could not compile its `cuda_utils.so` helper |
The fused path failed at `patch_model(..., verify=True)` during the first
single-token forward pass. The exact error from
`/usr/bin/gcc` was:
```
fatal error: Python.h: No such file or directory
7 | #include <Python.h>
| ^~~~~~~~~~
```
**Fix:** install the matching Python development headers system-wide so
Triton's CUDA utility helper can compile:
```bash
sudo apt install python3.12-dev
```
After installing, re-run the test — the same script will exercise the
Triton path without modification. The pure-PyTorch numbers above are real
and confirm that `turbokv` 0.1.0 still delivers the documented memory win
(-19.2% peak VRAM vs baseline at 4096-token prompt) but at a 3.48x
throughput cost; recovering that throughput is exactly what the Triton
kernels in `fused-turboquant` exist to do.
## QJL
Reference: Amir Zandieh, Majid Daliri, Insu Han, *QJL: 1-Bit Quantized JL
Transform for KV Cache Quantization with Zero Overhead*,
**arXiv:2406.03482**, AAAI 2025
([acm dl](https://dl.acm.org/doi/10.1609/aaai.v39i24.34773)).
Implementation: vendored from
[`amirzandieh/QJL`](https://github.com/amirzandieh/QJL) @
`648b3641f96b6e95e091217220b94e4739fd4d82` under
`scripts/quantization/qjl/` (Apache 2.0 — see
`scripts/quantization/qjl/LICENSE` and `qjl/NOTICE.md`). There is **no
`pyqjl` package on PyPI** — the QJL kernel is shipped as a CUDA C++
extension that the user has to compile against their local nvcc.
`pyproject.toml` adds `pybind11>=2.12.0` to the `train` extras so the
template-binding step has its dep; `nvcc` and the matching
`pythonX.Y-dev` headers come from the system package manager.
### What this is — and what it is NOT
QJL is the **K (keys) side** companion to TurboQuant's V (values) side.
Both compressors were authored by Amir Zandieh; they are explicitly
designed to compose. The combination delivers ~10x KV-cache compression
at long context: 1-bit per JL-projected key coordinate + 4-bit per value
coordinate, plus a small per-token bf16 norm on each side and an
amortized outlier sketch shared across `group_size` consecutive tokens.
QJL is **not** a weight quantizer. ``model.safetensors`` is unchanged
after applying. ``qjl_apply.py`` writes the merged base model unchanged
plus a ``qjl_config.json`` sidecar that records the projection geometry
(`projection_dim_per_head`, `projection_seed`, `outlier_count_*`,
`initial_layers_count`, `group_size`, `buffer_size`, paired
`value_bits`) so the inference loader can deterministically reconstruct
the JL projection matrix Π and the value-side codebook.
### Algorithm in one paragraph
For each cached key vector ``k`` of length ``head_dim`` (per attention
head, per token): (1) extract ``||k||`` and store it in bf16; (2)
multiply by a fixed-per-layer JohnsonLindenstrauss matrix Π ∈
R^{head_dim × s} drawn from N(0, 1) and orthogonalized via a chunked
QR decomposition (``QJLSketch.init_rot_dir``), giving a sketch ``s = Πᵀ
k`` of length ``s = projection_dim_per_head``; (3) take the **sign** of
each sketch coordinate, packing 8 signs into one uint8. To recover an
unbiased estimator of inner products at attention time, the query is
projected through the *same* Π and the score is reconstructed in a
custom CUDA kernel (``qjl_kernel/csrc/qjl_score_kernel.cu``). The paper
proves the resulting cosine-similarity estimator has minimal relative
distortion at 1 bit. To handle outlier coordinates (a few head_dim
indices with disproportionately large norms — common on layer 0 in
Llama/Gemma-style models), the kernel additionally stores a top-k outlier
sketch per group of ``group_size`` consecutive tokens, with its own
larger JL projection of dimension ``dim_outlier`` (256 for general
layers, 128 for the first ``initial_layers_count`` layers). The recent
``buffer_size`` tokens stay in fp16 to preserve freshly-generated
context losslessly.
### Tradeoffs
- **Pros.** Provably minimal relative distortion at 1-bit (paper
Theorem 3). Storage is purely the packed sign sketch plus a per-token
bf16 norm — no quantization scale/zero-point per coordinate, which
is what gives the "zero overhead" tagline. Hand-written CUDA kernels
for both quantize and score paths recover throughput at long context
(the paper reports 2.02.5x speedup on prefill and decode for
Llama-2/3 at 32k context). Composes cleanly with TurboQuant on the V
side for end-to-end ~4x KV reduction whole-model.
- **Cons.** The compression unit is the JL sketch dimension, not the
number of bits per coord — at the canonical
``projection_dim_per_head=256`` the K-side ratio
``head_dim*2 / (projection_dim/8 + 2)`` works out to **7.53x for
head_dim=128** (Llama-3-style dense attention), not the
marketing-headline 16x (which would assume zero norm overhead).
Pushing to ``projection_dim_per_head=128`` recovers ~14.2x at the
cost of attention-score quality. The kernel hard-codes
``EMB_DIM 128`` in ``csrc/qjl_quant_kernel.cu`` (line 7), so other
head_dim values need a kernel rebuild with a different define.
The CUDA kernel was written for Ampere/Hopper; Blackwell (sm_120)
requires the PTX-fallback build flag (see "Build" below).
### Supported architectures
The vendored ``LlamaAttention_QJL`` and ``LlamaDecoderLayer_QJL``
modules under ``scripts/quantization/qjl/`` (mirrored from upstream
``models/llama3_qjl.py``) target the Llama-3 attention shape directly:
``q_proj`` / ``k_proj`` / ``v_proj`` / ``o_proj`` linear projections
plus ``LlamaRotaryEmbedding`` and grouped-query attention (the GQA
score kernel ``cuda_qjl_gqa_score`` handles
``num_attention_heads != num_key_value_heads``). Verified upstream on:
- Llama-2 7B and Llama-3 8B (the upstream ``run_longbench.py``
evaluation set)
Gemma tiers require per-tier validation before release use. The current
kernel is authored around a 128-dim Llama-style attention path, while the
active Gemma targets can expose different text-decoder head dimensions.
#### Gemma caveat (read this)
QJL only applies to ``full_attention`` layers — there is nothing to
compress in recurrent/state-space layers. The ``qjl_apply.py``
calibration step honors `layer_types` and silently skips non-full-attention
layers. The on-disk config records ``n_full_attention_layers`` so the
inference loader knows which layers to wrap.
Vision-language Gemma variants need the text decoder extracted before
patching the attention modules.
### Build
The QJL kernel is **not** pip-installable. Build it once, in place,
inside the vendored directory:
```bash
cd scripts/quantization/qjl
# Default Ampere/Hopper build:
python setup.py build_ext --inplace
# Blackwell (RTX 50-series, sm_120) — the kernel was not authored
# against this arch; force PTX fallback so it compiles for compute_120
# via the JIT path:
TORCH_CUDA_ARCH_LIST="12.0+PTX" python setup.py build_ext --inplace
```
Prerequisites (verified missing on the local 5080 dev box at the time
this was vendored):
```bash
sudo apt install nvidia-cuda-toolkit python3.12-dev
```
The first installs `nvcc` (PyTorch wheels ship `ptxas` and `nvrtc` but
**not** the full nvcc driver — the cu13 wheel directory only contains
`include/`, `lib/`, no `bin/nvcc`). The second installs `Python.h` so
the C++ extension's pybind11 bindings can compile.
### CLI
Apply to a fine-tuned 2B checkpoint (auto-merges if `--model` points to
a LoRA adapter):
```bash
uv run python scripts/quantization/qjl_apply.py \
--model checkpoints/gemma4-e2b-eliza/final \
--calibration data/final/val.jsonl \
--calibration-samples 128 \
--output checkpoints/gemma4-e2b-eliza/final-qjl
```
Apply to a fine-tuned 27B checkpoint (same shape; calibration is
single-pass forward and fits in 16 GB only with offload):
```bash
uv run python scripts/quantization/qjl_apply.py \
--model checkpoints/gemma4-e4b-eliza/final \
--calibration data/final/val.jsonl \
--calibration-samples 128 \
--projection-dim-per-head 256 \
--projection-dim-per-head-initial 512 \
--initial-layers-count 15 \
--outlier-count-general 8 \
--value-bits 4 \
--output checkpoints/gemma4-e4b-eliza/final-qjl
```
Useful knobs:
- `--key-bits 1` (only). QJL is a 1-bit-per-projected-coord scheme by
construction; the flag exists for forward-compat.
- `--projection-dim-per-head N` (default 256). The JL sketch dimension
per attention head. Smaller = more compression, lower attention
score fidelity. Must be byte-aligned (`% 8 == 0`).
- `--projection-dim-per-head-initial N` (default 512). The first
`--initial-layers-count` layers carry more attention mass; the paper
recommends a larger sketch budget there. Leaving this at 2× the
general dim matches upstream defaults exactly.
- `--initial-layers-count N` (default 15, paper's choice for Llama-7B
with 32 layers). For different layer counts, scale ~half.
- `--outlier-count-general K` / `--outlier-count-initial-layers K`
(default 8 each). Top-K head_dim coords promoted to the outlier
sketch per group. The calibration step measures per-layer outlier
norm ratio and records it in the sidecar so the inference loader
can validate this budget.
- `--value-bits {2,4}` (default 4). Companion TurboQuant V-side bits
recorded in the sidecar.
- `--group-size N` (default 32). Group of consecutive tokens that
share an outlier-coord index table. Larger = smaller per-token
outlier overhead, slightly worse outlier tracking on bursty
activations.
- `--projection-seed N` (default 42). PRNG seed for the JL matrix.
Pin this to the value recorded in `qjl_config.json` at inference
time; otherwise Π won't match and the dequantized cosine scores are
garbage.
### Validation
`scripts/quantization/test_qjl.py` runs on `google/gemma-4-E2B` (closest
text-only stand-in for `google/gemma-4-E2B` — see caveat above):
1. Attempts to build the vendored CUDA extension. If `nvcc` or
`Python.h` is missing it records the exact remediation command
(`sudo apt install nvidia-cuda-toolkit python3.12-dev`) and skips
the runtime-kernel path.
2. Runs a baseline bf16 generation and records peak VRAM, tok/s, and
sample outputs.
3. Captures the K activations from the first 4 attention layers via a
forward hook, runs a **pure-PyTorch reference QJL quantize** on
them (no CUDA extension needed — JL @ matmul + sign + bit-packing
in plain PyTorch), and reports the realized K-side compression
ratio per layer.
4. Sweeps `projection_dim ∈ {128, 256, 512}` so the report shows the
full size-vs-quality curve at the same K activations.
5. Computes the analytic whole-model KV-bytes-per-token reduction with
the paired V-side TurboQuant numbers via
`qjl_apply.kv_bytes_per_token_analytic`.
6. Asserts (a) K-side ratio ≥ 7× at the canonical 256-dim setting,
matching the closed-form `head_dim*2 / (projection_dim/8 + 2) =
7.53×` for head_dim=128, and (b) baseline outputs are
non-degenerate.
```bash
uv run python scripts/quantization/test_qjl.py
```
The full numeric report is written to
`scripts/quantization/qjl_report.json`. Last measured run on
gemma-4-E2B / 5080 (bf16 baseline cache, projection_dim=256, seed=42):
| metric | value | notes |
|---|---|---|
| nvcc present | **No** | system blocker; `sudo apt install nvidia-cuda-toolkit` |
| Python.h present | **No** | system blocker; `sudo apt install python3.12-dev` |
| QJL CUDA kernel built | **No** | both blockers above must be fixed first |
| K-side ratio (proj_dim=256, real activations) | **7.53×** | head_dim=128, per-token bf16 norm |
| K-side ratio (proj_dim=128, real activations) | 14.22× | smaller sketch — quality tradeoff |
| K-side ratio (proj_dim=512, real activations) | 3.88× | larger sketch — higher fidelity |
| Analytic KV bytes/token (bf16 baseline) | 114,688 B | K + V over 28 full-attention layers |
| Analytic KV bytes/token (QJL-K + TurboQuant-V) | 27,608 B | proj_dim=256 + V 4-bit, group_size=32 |
| **Whole-model KV reduction** | **4.15×** (-75.9%) | K + V combined; matches paper's headline range |
| Baseline tok/s (5 prompts × 128 new) | 28.93 | bf16 DynamicCache baseline |
| Baseline peak VRAM | 3.43 GB | single-prompt generation |
| Baseline output sample | `"<think> Okay, let's see. The user provided the terminal output and wants me to check if the task is complete..."` | non-degenerate |
### Blockers (current state on the 5080 dev box)
- **`nvcc` is not on the system.** PyTorch's bundled cu13 wheel ships
`nvrtc` and `ptxas` but no full `nvcc` driver, and the
`nvidia-cuda-nvcc-cu12` PyPI wheel only ships `ptxas` (verified). Fix:
`sudo apt install nvidia-cuda-toolkit`.
- **`Python.h` is not present** (no `python3.12-dev` package
installed). Fix: `sudo apt install python3.12-dev`.
- **Blackwell (sm_120) is not in the upstream test matrix.** The
kernel sources are written against Ampere/Hopper. After the two
`apt install`s above, the recommended build command is
`TORCH_CUDA_ARCH_LIST="12.0+PTX" python setup.py build_ext
--inplace`, which forces the JIT-PTX fallback path; the kernel will
compile to compute_120 via PTX at first load. If the kernel still
fails at runtime on sm_120, the immediate workaround is to run the
pure-PyTorch reference path in `test_qjl.py:qjl_pure_pytorch_quantize`
for measurement and ship the validated checkpoint to a
Hopper/Ampere host for actual inference.
- **Hard-coded `EMB_DIM 128`** in
`qjl/csrc/qjl_quant_kernel.cu:7`. The code only works for
`head_dim == 128` out of the box. gemma-4-E2B / gemma-4-E2B / Llama-3
all match. If we later need to apply QJL to a model with
`head_dim != 128`, the `#define EMB_DIM` must be edited and the
kernel rebuilt; there is no runtime arg for it.
## Abliteration
Reference: Arditi et al., *Refusal in LLMs is mediated by a single
direction* ([arXiv:2406.11717](https://arxiv.org/abs/2406.11717)).
Practical writeup: [Maxime Labonne, "Uncensor any LLM with
abliteration"](https://huggingface.co/blog/mlabonne/abliteration).
Computes the rank-1 refusal direction
`r = normalize(mean(harmful) - mean(harmless))` from residual-stream
activations at a configurable mid-stack layer, then projects `r` out of
every block's `self_attn.o_proj` and `mlp.down_proj` weights so the
model can never write the refusal direction back into the residual
stream. Destructive transform — save to a NEW directory.
```
uv run python scripts/quantization/abliteration_apply.py \
--checkpoint google/gemma-4-E2B \
--output checkpoints/gemma4-e2b-abliterated \
--harmful-jsonl data/harmful.jsonl \
--harmless-jsonl data/harmless.jsonl
```
Without `--harmful-jsonl` / `--harmless-jsonl` the script falls back to
a small built-in pair list intended for smoke testing only — pass real
prompt corpora (e.g.
[`mlabonne/harmful_behaviors`](https://huggingface.co/datasets/mlabonne/harmful_behaviors))
in production.