Files
elizaos--eliza/packages/training/scripts/quantization/qjl_apply.py
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

649 lines
25 KiB
Python

"""Apply QJL (1-bit Quantized JL) key-side KV-cache compression to a checkpoint.
QJL
Zandieh, Daliri, Han. *QJL: 1-Bit Quantized JL Transform for KV Cache
Quantization with Zero Overhead*. arXiv:2406.03482, AAAI 2025.
Reference impl vendored under ``./qjl/`` from
https://github.com/amirzandieh/QJL @ 648b3641.
QJL is the key-side companion to TurboQuant's value-side compressor.
Both were authored by Amir Zandieh; they compose. With QJL on K and
TurboQuant on V the KV cache shrinks ~10x at long context (1 bit per K
coord + 4 bits per V coord).
Like TurboQuant, QJL is a runtime KV-cache compressor: it does not
shrink ``model.safetensors`` on disk. This script:
1. Loads a HF causal-LM checkpoint (merging a LoRA adapter if present).
2. Optionally walks a small calibration set to estimate per-layer
outlier-coordinate statistics.
3. Saves the (unchanged) merged weights and a ``qjl_config.json``
sidecar with the projection geometry, outlier counts, layer split,
and PRNG seed needed to reconstruct the JL projection at inference.
The vendored CUDA C++ extension under ``./qjl/`` must be compiled before
inference can use the quantized cache. This script does not trigger the
build (apply-time vs runtime separation). See ``qjl/`` for build details.
"""
from __future__ import annotations
import argparse
import importlib
import json
import logging
import math
import sys
from pathlib import Path
from typing import Callable
import torch
import torch.nn as nn
from transformers.cache_utils import Cache
from transformers.tokenization_utils_base import PreTrainedTokenizerBase
_HERE = Path(__file__).resolve().parent
if str(_HERE) not in sys.path:
sys.path.insert(0, str(_HERE))
from _common import ( # noqa: E402
full_attention_layer_indices,
get_text_config,
head_dim_of,
kernel_manifest_fragment,
load_calibration_prompts,
load_model_and_tokenizer,
save_model,
write_sidecar,
)
logging.basicConfig(
level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s"
)
log = logging.getLogger("qjl_apply")
# Outlier sketch dimension per layer-class (matches upstream defaults).
_OUTLIER_DIM_GENERAL = 256
_OUTLIER_DIM_INITIAL = 128
def calibrate_key_outliers(
model: nn.Module,
tokenizer: PreTrainedTokenizerBase,
prompts: list[str],
*,
head_dim: int,
outlier_count_general: int,
outlier_count_initial_layers: int,
initial_layers_count: int,
layer_indices: list[int],
) -> dict[str, object]:
"""Forward calibration prompts and record per-layer K coordinate norms.
The actual outlier *values* are recomputed at inference time by upstream
``QJLKeyQuantizer.build_sketch``; we fix the per-layer *budget* here.
"""
text_cfg = get_text_config(model.config)
num_kv_heads = (
getattr(text_cfg, "num_key_value_heads", None) or text_cfg.num_attention_heads
)
layer_norms: dict[int, torch.Tensor] = {}
def _make_hook(layer_idx: int) -> Callable[[nn.Module, object, object], None]:
def _hook(_module: nn.Module, _input: object, output: object) -> None:
t = output[0] if isinstance(output, tuple) else output
if not isinstance(t, torch.Tensor):
return
if t.dim() == 3:
B, T, D = t.shape
t = t.view(B, T, num_kv_heads, head_dim)
norms = t.float().norm(dim=1).mean(dim=(0, 1)) # (head_dim,)
prev = layer_norms.get(layer_idx)
if prev is None:
layer_norms[layer_idx] = norms.detach().cpu()
else:
layer_norms[layer_idx] = prev * 0.5 + norms.detach().cpu() * 0.5
return _hook
handles: list[object] = []
for i in layer_indices:
if i >= len(model.model.layers):
continue
layer = model.model.layers[i]
attn = getattr(layer, "self_attn", None)
if attn is None:
continue
k_proj = getattr(attn, "k_proj", None)
if k_proj is None:
continue
handles.append(k_proj.register_forward_hook(_make_hook(i)))
try:
model.eval()
for i, prompt in enumerate(prompts):
ids = tokenizer(
prompt, return_tensors="pt", truncation=True, max_length=2048
).to(model.device)
with torch.no_grad():
model(**ids, use_cache=False)
if (i + 1) % 16 == 0:
log.info(" calibration: %d/%d prompts", i + 1, len(prompts))
finally:
for h in handles:
h.remove() # type: ignore[attr-defined]
ratios: list[float] = []
per_layer: dict[int, int] = {}
for i, norms in layer_norms.items():
if norms.numel() == 0:
continue
med = float(norms.median())
mx = float(norms.max())
ratios.append(mx / max(med, 1e-9))
is_initial = i < initial_layers_count
per_layer[i] = (
outlier_count_initial_layers if is_initial else outlier_count_general
)
return {
"n_prompts": len(prompts),
"n_full_attention_layers_calibrated": len(layer_norms),
"median_outlier_norm_ratio": (
float(sum(ratios) / len(ratios)) if ratios else 0.0
),
"per_layer_outlier_count": {str(k): v for k, v in sorted(per_layer.items())},
"initial_layers_count_used": initial_layers_count,
}
def kv_bytes_per_token_analytic(
config: object,
*,
key_quantization_bits: int,
key_quantization_bits_initial_layers: int,
initial_layers_count: int,
outlier_count_general: int,
outlier_count_initial_layers: int,
value_bits: int,
group_size: int = 32,
) -> tuple[int, int]:
"""Per-token KV bytes for baseline (bf16 cache) vs QJL+TurboQuant.
K-cache geometry mirrors upstream ``QJLKeyQuantizer.build_sketch``:
every ``group_size`` tokens share one outlier-index table and one
outlier sketch row, but each token has its own 1-bit JL sketch row +
bf16 norm. V-cache geometry matches TurboQuant.
"""
text_cfg = get_text_config(config)
head_dim = head_dim_of(text_cfg)
num_kv_heads = (
getattr(text_cfg, "num_key_value_heads", None) or text_cfg.num_attention_heads
)
full_idx = full_attention_layer_indices(text_cfg)
bytes_per_group = 0
bytes_per_group_baseline = 0
for i in full_idx:
coords_k = num_kv_heads * head_dim
coords_v = num_kv_heads * head_dim
bytes_per_group_baseline += group_size * 2 * (coords_k + coords_v)
if i < initial_layers_count:
kbits = key_quantization_bits_initial_layers
okount = outlier_count_initial_layers
odim = _OUTLIER_DIM_INITIAL
else:
kbits = key_quantization_bits
okount = outlier_count_general
odim = _OUTLIER_DIM_GENERAL
per_token_per_head = math.ceil(kbits / 8) + 2
per_group_per_head_amortized = math.ceil(okount * odim / 8) + okount
bytes_per_group += num_kv_heads * (
per_token_per_head * group_size + per_group_per_head_amortized
)
v_per_token = math.ceil(coords_v * value_bits / 8) + num_kv_heads * 2
bytes_per_group += group_size * v_per_token
return bytes_per_group_baseline // group_size, bytes_per_group // group_size
def _qjl_kernel_module() -> object:
"""Add the vendored qjl/ directory to sys.path and import the dispatch wrapper."""
qjl_dir = str(_HERE / "qjl")
if qjl_dir not in sys.path:
sys.path.insert(0, qjl_dir)
return importlib.import_module("qjl_kernel")
def _build_jl_projections(
*,
head_dim: int,
full_attn_layer_indices: list[int],
initial_layers_count: int,
proj_dim_general: int,
proj_dim_initial: int,
seed: int,
device: torch.device,
dtype: torch.dtype,
) -> dict[int, torch.Tensor]:
"""Per-layer ``(head_dim, proj_dim)`` row-major JL projection matrices.
Layout matches the canonical kernel reference at
``eliza/packages/native/plugins/qjl-cpu/include/qjl/qjl.h`` (Π row-major,
indexed as ``prj[i*proj_dim + j]``) and the verify harness reference at
``eliza/plugins/plugin-local-inference/native/verify/qjl_polar_ref.c``. A row of the matrix
is ``proj_dim`` floats; with the canonical (head_dim=128, proj_dim=256)
that's 1024 bytes per row and 131072 bytes (128 KiB) per matrix.
Bytes emitted by this function can be passed directly to qjl-cpu /
Metal / Vulkan / CUDA kernel call sites with no transpose at the
boundary. Sketch math is ``q_sketch = q @ pi`` where ``q`` has trailing
dim ``head_dim`` and ``pi`` is the returned ``(head_dim, proj_dim)``
matrix; the result has trailing dim ``proj_dim``.
Deterministic in ``seed`` — torch's Mersenne-Twister stream over
``torch.randn(head_dim, proj_dim, ...)`` is invariant under the layout
swap (the buffer is the same fp32 numbers, only the interpretation of
rows-vs-columns differs). The legacy CUDA bridge that consumes
``(sketch_dim, head_dim)`` gets a transpose at the call site (see
``attach_qjl_to_cache``); the canonical training-side artifact stays
in ``(head_dim, proj_dim)``.
"""
projections: dict[int, torch.Tensor] = {}
for layer_idx in full_attn_layer_indices:
proj_dim = (
proj_dim_initial if layer_idx < initial_layers_count else proj_dim_general
)
gen = torch.Generator(device="cpu").manual_seed(seed + int(layer_idx))
proj = torch.randn(head_dim, proj_dim, generator=gen, dtype=torch.float32)
projections[layer_idx] = proj.to(device=device, dtype=dtype)
return projections
def _find_decoder_layers(model: nn.Module) -> nn.Module:
"""Locate the decoder layer list (Llama-style)."""
candidate_paths = (
("model", "layers"),
("language_model", "model", "layers"),
("model", "model", "layers"),
("transformer", "layers"),
)
for path in candidate_paths:
obj: object = model
for attr in path:
obj = getattr(obj, attr, None)
if obj is None:
break
else:
if hasattr(obj, "__getitem__"):
return obj # type: ignore[return-value]
raise RuntimeError(
f"could not find decoder layers on model of type {type(model).__name__}; "
f"tried: {candidate_paths}"
)
def attach_qjl_to_cache(model: nn.Module, cache: Cache, **qjl_cfg: object) -> Cache:
"""Wrap ``cache`` so K writes go through the QJL 1-bit JL sketch.
Recognized ``qjl_cfg`` keys (defaults match the paper):
head_dim, projection_dim_per_head (256), projection_dim_per_head_initial (512),
initial_layers_count (15), outlier_count_general (8),
outlier_count_initial_layers (8), group_size (32), buffer_size (128),
projection_seed (42), calibration (dict with per_layer_outlier_count).
"""
if cache is None:
raise ValueError("attach_qjl_to_cache requires an existing cache; got None")
text_cfg = get_text_config(model.config)
head_dim = int(qjl_cfg.get("head_dim") or head_dim_of(text_cfg))
proj_dim_general = int(qjl_cfg.get("projection_dim_per_head", 256))
proj_dim_initial = int(qjl_cfg.get("projection_dim_per_head_initial", 512))
initial_layers_count = int(qjl_cfg.get("initial_layers_count", 15))
outlier_count_general = int(qjl_cfg.get("outlier_count_general", 8))
outlier_count_initial = int(qjl_cfg.get("outlier_count_initial_layers", 8))
group_size = int(qjl_cfg.get("group_size", 32))
buffer_size = int(qjl_cfg.get("buffer_size", 128))
seed = int(qjl_cfg.get("projection_seed", 42))
per_layer_outlier_count: dict[int, int] = {}
calib = qjl_cfg.get("calibration") or {}
raw = calib.get("per_layer_outlier_count") or {} # type: ignore[union-attr]
for k, v in raw.items():
per_layer_outlier_count[int(k)] = int(v)
full_attn_layer_indices = full_attention_layer_indices(text_cfg)
if not full_attn_layer_indices:
raise RuntimeError(
"attach_qjl_to_cache: no full-attention layers found; nothing to compress."
)
kernel_module = _qjl_kernel_module()
try:
kernel_module._load_kernel("cuda_qjl_quant") # type: ignore[attr-defined]
backend = "cuda"
except (ImportError, OSError):
backend = "pytorch"
log.warning(
"QJL CUDA extension not built -- using pure-PyTorch reference "
"(correct but slow). Build for production: cd %s && ./build.sh",
_HERE / "qjl",
)
device = next(model.parameters()).device
proj_dtype = next(model.parameters()).dtype
projections = _build_jl_projections(
head_dim=head_dim,
full_attn_layer_indices=full_attn_layer_indices,
initial_layers_count=initial_layers_count,
proj_dim_general=proj_dim_general,
proj_dim_initial=proj_dim_initial,
seed=seed,
device=device,
dtype=proj_dtype,
)
qjl_state: dict[int, dict[str, torch.Tensor | int]] = {}
decoder_layers = _find_decoder_layers(model)
def _make_k_hook(
layer_idx: int,
) -> Callable[[nn.Module, object, object], object]:
proj = projections[layer_idx]
outlier_count = per_layer_outlier_count.get(
layer_idx,
outlier_count_initial
if layer_idx < initial_layers_count
else outlier_count_general,
)
outlier_sketch_dim = (
_OUTLIER_DIM_INITIAL if layer_idx < initial_layers_count else _OUTLIER_DIM_GENERAL
)
def _hook(module: nn.Module, _inputs: object, output: object) -> object:
k_flat = output[0] if isinstance(output, tuple) else output
B, T, _ = k_flat.shape
n_kv = k_flat.shape[-1] // head_dim
k = k_flat.view(B, T, n_kv, head_dim).permute(0, 2, 1, 3).contiguous()
num_full_groups = T // group_size
if num_full_groups == 0:
return output
groupable = num_full_groups * group_size
grouped = k[:, :, :groupable, :].view(
B, n_kv, num_full_groups, group_size, head_dim,
).contiguous()
group_norms = grouped.float().norm(dim=-2)
_, outlier_idx = group_norms.topk(outlier_count, dim=-1)
outlier_idx_u8 = outlier_idx.to(torch.uint8).contiguous()
# The vendored CUDA / pure-PyTorch fallback in qjl/qjl_kernel.py
# expects ``rand_prj`` shaped ``(sketch_dim, head_dim)`` (it
# transposes internally). The training-side artifact is stored
# in the canonical ``(head_dim, proj_dim)`` row-major layout so
# the on-device qjl-cpu / Metal / Vulkan kernels can consume
# it with no transpose. Bridge the layout difference at the
# legacy-bridge boundary, not in the canonical store.
proj_legacy = proj.transpose(0, 1).contiguous()
if backend == "cuda":
key_quant, key_outlier_quant, key_outliers_norm = (
kernel_module.qjl_quant( # type: ignore[attr-defined]
grouped, outlier_idx_u8, proj_legacy, outlier_sketch_dim,
)
)
else:
key_quant, key_outlier_quant, key_outliers_norm = (
kernel_module.qjl_quantize_pytorch( # type: ignore[attr-defined]
grouped, outlier_idx_u8, proj_legacy, outlier_sketch_dim,
)
)
key_norms = grouped.float().norm(dim=-1)
entry = {
"packed": key_quant,
"outlier_quant": key_outlier_quant,
"outlier_indices": outlier_idx_u8,
"outlier_norms": key_outliers_norm,
"norms": key_norms,
"rand_prj": proj,
"head_dim": head_dim,
"n_kv_heads": n_kv,
"group_size": group_size,
}
prev = qjl_state.get(layer_idx)
if prev is None:
qjl_state[layer_idx] = entry
else:
qjl_state[layer_idx] = {
"packed": torch.cat([prev["packed"], entry["packed"]], dim=2),
"outlier_quant": torch.cat(
[prev["outlier_quant"], entry["outlier_quant"]], dim=2
),
"outlier_indices": torch.cat(
[prev["outlier_indices"], entry["outlier_indices"]], dim=2
),
"outlier_norms": torch.cat(
[prev["outlier_norms"], entry["outlier_norms"]], dim=2
),
"norms": torch.cat([prev["norms"], entry["norms"]], dim=2),
"rand_prj": proj,
"head_dim": head_dim,
"n_kv_heads": n_kv,
"group_size": group_size,
}
return output
return _hook
handles: list[object] = []
patched = 0
for i in full_attn_layer_indices:
if i >= len(decoder_layers):
continue
layer = decoder_layers[i]
attn = getattr(layer, "self_attn", None)
if attn is None:
continue
k_proj = getattr(attn, "k_proj", None)
if k_proj is None:
continue
handles.append(k_proj.register_forward_hook(_make_k_hook(i)))
patched += 1
if patched == 0:
raise RuntimeError(
"attach_qjl_to_cache: zero full-attention layers patched. The "
"model must expose model.model.layers[i].self_attn.k_proj on the "
"full-attention slots."
)
cache._qjl_state = qjl_state # type: ignore[attr-defined]
cache._qjl_handles = handles # type: ignore[attr-defined]
cache._qjl_projections = projections # type: ignore[attr-defined]
cache._qjl_backend = backend # type: ignore[attr-defined]
def _qjl_get_compressed_key(layer_idx: int) -> dict[str, object] | None:
return qjl_state.get(int(layer_idx))
def _qjl_decode_keys(layer_idx: int) -> torch.Tensor:
"""Reconstruct K from the 1-bit JL sketch + per-token bf16 norms.
Best-effort inverse for debugging only -- the JL sketch is lossy
by construction (sign of a random projection).
"""
entry = qjl_state.get(int(layer_idx))
if entry is None:
raise RuntimeError(
f"decode_keys({layer_idx}) called but no compressed key exists yet."
)
packed = entry["packed"]
proj = entry["rand_prj"] # (head_dim, proj_dim) row-major canonical
norms = entry["norms"]
B, H, G, GS, packed_dim = packed.shape
sketch_dim = packed_dim * 8
bit_idx = torch.arange(8, device=packed.device, dtype=torch.uint8)
unpacked = ((packed.unsqueeze(-1) >> bit_idx) & 1).to(torch.float32)
signs = unpacked.view(B, H, G, GS, sketch_dim) * 2.0 - 1.0
# Right pseudo-inverse of (head_dim, proj_dim) with head_dim < proj_dim:
# pinv(P) = P.T @ inv(P @ P.T), shape (proj_dim, head_dim).
# So k_hat = signs @ pinv(P), shape (..., head_dim).
proj_f = proj.to(torch.float32)
gram = proj_f @ proj_f.transpose(0, 1) # (head_dim, head_dim)
proj_dagger = proj_f.transpose(0, 1) @ torch.linalg.inv(gram) # (proj_dim, head_dim)
k_hat = signs @ proj_dagger
k_hat_norms = k_hat.norm(dim=-1, keepdim=True).clamp_min(1e-8)
k_hat = k_hat * (norms.unsqueeze(-1) / k_hat_norms)
head_dim_out = proj.shape[0]
return k_hat.view(B, H, G * GS, head_dim_out)
cache.get_compressed_key = _qjl_get_compressed_key # type: ignore[attr-defined]
cache.decode_keys = _qjl_decode_keys # type: ignore[attr-defined]
log.info(
"attach_qjl_to_cache: patched %d full-attention layers (backend=%s, "
"head_dim=%d, group_size=%d, buffer_size=%d)",
patched, backend, head_dim, group_size, buffer_size,
)
return cache
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
ap.add_argument(
"--model",
required=True,
help="HF repo id or local path. LoRA adapter dirs are merged automatically.",
)
ap.add_argument("--output", required=True, type=Path)
ap.add_argument("--calibration", type=Path, default=None)
ap.add_argument("--calibration-samples", type=int, default=128)
ap.add_argument("--key-bits", type=int, default=1, choices=(1,))
ap.add_argument("--projection-dim-per-head", type=int, default=256)
ap.add_argument("--projection-dim-per-head-initial", type=int, default=512)
ap.add_argument("--initial-layers-count", type=int, default=15)
ap.add_argument("--outlier-count-general", type=int, default=8)
ap.add_argument("--outlier-count-initial-layers", type=int, default=8)
ap.add_argument("--value-bits", type=int, default=4, choices=(2, 4))
ap.add_argument("--group-size", type=int, default=32)
ap.add_argument("--buffer-size", type=int, default=128)
ap.add_argument("--projection-seed", type=int, default=42)
ap.add_argument("--device", default="cuda")
ap.add_argument("--dtype", default="bfloat16", choices=("float16", "bfloat16"))
ap.add_argument("--no-calibration-pass", action="store_true")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args(argv)
if args.dry_run:
print(json.dumps(vars(args), indent=2, default=str))
return 0
if not torch.cuda.is_available() and args.device == "cuda":
raise RuntimeError("CUDA requested but not available")
dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
out_dir = Path(args.output)
model, tok = load_model_and_tokenizer(args.model, device_map=args.device, dtype=dtype)
text_cfg = get_text_config(model.config)
head_dim = head_dim_of(text_cfg)
full_idx = full_attention_layer_indices(text_cfg)
if args.no_calibration_pass or not args.calibration:
log.info("no calibration pass; using paper defaults")
calibration_block: dict[str, object] = {
"skipped": True,
"reason": (
"no --calibration provided"
if not args.calibration
else "--no-calibration-pass set"
),
"per_layer_outlier_count": {
str(i): (
args.outlier_count_initial_layers
if i < args.initial_layers_count
else args.outlier_count_general
)
for i in full_idx
},
}
else:
prompts = load_calibration_prompts(args.calibration, n=args.calibration_samples)
log.info("calibrating key outliers on %d prompts", len(prompts))
calibration_block = calibrate_key_outliers(
model,
tok,
prompts,
head_dim=head_dim,
outlier_count_general=args.outlier_count_general,
outlier_count_initial_layers=args.outlier_count_initial_layers,
initial_layers_count=args.initial_layers_count,
layer_indices=full_idx,
)
save_model(model, tok, out_dir)
base_bpt, quant_bpt = kv_bytes_per_token_analytic(
model.config,
key_quantization_bits=args.projection_dim_per_head,
key_quantization_bits_initial_layers=args.projection_dim_per_head_initial,
initial_layers_count=args.initial_layers_count,
outlier_count_general=args.outlier_count_general,
outlier_count_initial_layers=args.outlier_count_initial_layers,
value_bits=args.value_bits,
)
sidecar_payload = {
"method": "qjl",
"paper": "arXiv:2406.03482",
"vendored_upstream": "https://github.com/amirzandieh/QJL",
"vendored_commit": "648b3641f96b6e95e091217220b94e4739fd4d82",
"source_model": args.model,
"key_bits": args.key_bits,
"projection_dim_per_head": args.projection_dim_per_head,
"projection_dim_per_head_initial": args.projection_dim_per_head_initial,
"initial_layers_count": args.initial_layers_count,
"outlier_count_general": args.outlier_count_general,
"outlier_count_initial_layers": args.outlier_count_initial_layers,
"value_bits": args.value_bits,
"group_size": args.group_size,
"buffer_size": args.buffer_size,
"projection_seed": args.projection_seed,
"head_dim": head_dim,
"num_hidden_layers": int(text_cfg.num_hidden_layers),
"n_full_attention_layers": len(full_idx),
"kv_bytes_per_token_baseline": base_bpt,
"kv_bytes_per_token_qjl_plus_value": quant_bpt,
"kv_reduction_factor_estimated": (
base_bpt / quant_bpt if quant_bpt else 0.0
),
"calibration_file": str(args.calibration) if args.calibration else None,
"calibration": calibration_block,
"kernel_manifest": kernel_manifest_fragment("qjl"),
"build_required_command": (
"cd scripts/quantization/qjl && python setup.py build_ext --inplace"
),
}
sidecar_path = write_sidecar(out_dir, "qjl_config.json", sidecar_payload)
log.info("wrote %s", sidecar_path)
log.info(
"estimated KV reduction: %.2fx (baseline=%d, qjl+value=%d bytes/token)",
sidecar_payload["kv_reduction_factor_estimated"],
base_bpt,
quant_bpt,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())