| """ |
| vqkv.quantizers — KV-cache quantizers for AttnVQ. |
| |
| ScalarKV, KIVIScalarKV, ProductVQKV (attention-weighted batched LBG), RoPESplitVQKV, |
| SignScalarKV, TernaryScalarKV. Each exposes fit() + roundtrip_k/v() and |
| bits_per_element(). ProductVQ / RoPESplit Lloyd updates are weighted by key |
| attention mass (see vqkv.metrics.calibration_sample_weights); quality is |
| reported via attention-output error, not cache MSE alone. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import math |
| from dataclasses import dataclass, field |
|
|
| import torch |
|
|
|
|
| |
| |
| |
| def _affine_quantize(x: torch.Tensor, nbits: int, dim: int): |
| """Symmetric-range affine quantization along `dim`. Returns (q, scale, zero).""" |
| qmax = (1 << nbits) - 1 |
| xmin = x.amin(dim=dim, keepdim=True) |
| xmax = x.amax(dim=dim, keepdim=True) |
| scale = (xmax - xmin).clamp_min(1e-8) / qmax |
| zero = torch.round(-xmin / scale) |
| q = torch.clamp(torch.round(x / scale) + zero, 0, qmax) |
| return q, scale, zero |
|
|
|
|
| def _affine_dequantize(q, scale, zero): |
| return (q - zero) * scale |
|
|
|
|
| |
| |
| |
| @dataclass |
| class ScalarKV: |
| """Per-token affine quantization of both K and V (the transformers default).""" |
| nbits: int = 4 |
|
|
| def fit(self, k_calib, v_calib): |
| return self |
|
|
| def roundtrip_k(self, k): |
| |
| q, s, z = _affine_quantize(k, self.nbits, dim=-1) |
| return _affine_dequantize(q, s, z) |
|
|
| def roundtrip_v(self, v): |
| q, s, z = _affine_quantize(v, self.nbits, dim=-1) |
| return _affine_dequantize(q, s, z) |
|
|
| def bits_per_element(self, head_dim): |
| |
| return self.nbits + (2 * 16) / head_dim |
|
|
|
|
| @dataclass |
| class KIVIScalarKV: |
| """KIVI-style: keys quantized per-channel, values per-token. |
| |
| Keys are quantized along the TOKEN axis (per channel); values along the |
| head_dim axis (per token). Requires a token-axis view, so fit/roundtrip |
| operate on a full (N_tokens, head_dim) block per head. |
| """ |
| nbits: int = 4 |
|
|
| def fit(self, k_calib, v_calib): |
| return self |
|
|
| def roundtrip_k(self, k): |
| |
| q, s, z = _affine_quantize(k, self.nbits, dim=0) |
| return _affine_dequantize(q, s, z) |
|
|
| def roundtrip_v(self, v): |
| q, s, z = _affine_quantize(v, self.nbits, dim=-1) |
| return _affine_dequantize(q, s, z) |
|
|
| def bits_per_element(self, head_dim): |
| return self.nbits + (2 * 16) / head_dim |
|
|
|
|
| |
| |
| |
| def lbg_codebook(data: torch.Tensor, n_codes: int, iters: int = 25, |
| seed: int = 0, sample_weights: torch.Tensor | None = None) -> torch.Tensor: |
| """Linde-Buzo-Gray / Lloyd design on sub-vectors. |
| |
| Assignment: nearest centroid (squared L2). Update: weighted mean when |
| ``sample_weights`` (N,) is given — attention-weighted LBG for AttnVQ. |
| """ |
| g = torch.Generator().manual_seed(seed) |
| N, d = data.shape |
| dev = data.device |
| w = sample_weights |
| if w is not None: |
| w = w.to(device=dev, dtype=data.dtype).clamp_min(1e-8) |
| idx = torch.randperm(N, generator=g)[:n_codes].to(dev) |
| cb = data[idx].clone() |
| ones = torch.ones(N, dtype=data.dtype, device=dev) |
| for _ in range(iters): |
| |
| |
| assign = _sq_l2(data, cb).argmin(dim=1) |
|
|
| |
| |
| new_cb = torch.zeros_like(cb) |
| counts = torch.zeros(n_codes, dtype=data.dtype, device=dev) |
| pts = data if w is None else data * w.unsqueeze(1) |
| new_cb.scatter_add_(0, assign.unsqueeze(1).expand(-1, d), pts) |
| counts.scatter_add_(0, assign, ones if w is None else w) |
| live = counts > 0 |
| new_cb[live] /= counts[live].unsqueeze(1) |
| |
| dead = (~live).nonzero(as_tuple=True)[0] |
| if dead.numel() > 0: |
| ri = torch.randint(0, N, (dead.numel(),), generator=g).to(dev) |
| new_cb[dead] = data[ri] |
|
|
| shift = (new_cb - cb).norm() |
| cb = new_cb |
| if shift < 1e-5: |
| break |
| return cb |
|
|
|
|
| def lbg_codebook_batched(xb: torch.Tensor, n_codes: int, iters: int = 25, |
| seed: int = 0, |
| sample_weights: torch.Tensor | None = None) -> torch.Tensor: |
| """Fit n_sub independent attention-weighted LBG codebooks in one batched pass. |
| |
| Mirrors the structure of ProductVQKV._roundtrip: replaces the Python loop |
| over sub-blocks with a single bmm-based assignment and a scatter_add-based |
| update, so n_sub sub-block fits become one operation at each Lloyd step. |
| |
| xb: (n_sub, N, sub_dim) training vectors, pre-normalized if needed |
| sample_weights: optional (N,) masses from key_attention_mass (AttnVQ fit) |
| returns: (n_sub, n_codes, sub_dim) codebooks, one per sub-block |
| """ |
| g = torch.Generator().manual_seed(seed) |
| n_sub, N, sub_dim = xb.shape |
| dev = xb.device |
| w = sample_weights |
| if w is not None: |
| w = w.to(device=dev, dtype=xb.dtype).clamp_min(1e-8) |
|
|
| |
| idx = torch.stack([torch.randperm(N, generator=g)[:n_codes] |
| for _ in range(n_sub)]).to(dev) |
| cb = xb[torch.arange(n_sub, device=dev).unsqueeze(1), idx].clone() |
|
|
| ones = torch.ones(N, dtype=xb.dtype, device=dev) |
|
|
| for _ in range(iters): |
| |
| x_sq = (xb * xb).sum(-1, keepdim=True) |
| c_sq = (cb * cb).sum(-1).unsqueeze(1) |
| cross = torch.bmm(xb, cb.transpose(1, 2)) |
| assign = (x_sq - 2 * cross + c_sq).argmin(dim=-1) |
|
|
| |
| new_cb = torch.zeros_like(cb) |
| counts = torch.zeros(n_sub, n_codes, dtype=xb.dtype, device=dev) |
| assign_exp = assign.unsqueeze(-1).expand(-1, -1, sub_dim) |
| pts = xb if w is None else xb * w.view(1, N, 1) |
| new_cb.scatter_add_(1, assign_exp, pts) |
| cnt_src = ones if w is None else w |
| counts.scatter_add_(1, assign, cnt_src.unsqueeze(0).expand(n_sub, -1)) |
|
|
| live = counts > 0 |
| new_cb[live] /= counts[live].unsqueeze(-1) |
|
|
| |
| dead_any = ~live |
| if dead_any.any(): |
| for s in range(n_sub): |
| dead = dead_any[s].nonzero(as_tuple=True)[0] |
| if dead.numel(): |
| ri = torch.randint(0, N, (dead.numel(),), generator=g).to(dev) |
| new_cb[s, dead] = xb[s, ri] |
|
|
| shift = (new_cb - cb).norm() |
| cb = new_cb |
| if shift < 1e-5: |
| break |
|
|
| return cb |
|
|
|
|
| def _sq_l2(x: torch.Tensor, cb: torch.Tensor) -> torch.Tensor: |
| """Squared L2 distance matrix (N, K) via BLAS GEMM. |
| |
| torch.cdist for small d (e.g. d=8 for n_sub=16) falls back to a naive |
| expand-and-subtract path that allocates an (N, K, d) intermediate. |
| For N=131072, K=256, d=8 that is 1.07 GB per call, causing massive |
| CPU allocation pressure and 100x slowdowns versus the BLAS path. |
| |
| ||x-c||^2 = ||x||^2 - 2*(x @ c.T) + ||c||^2 uses only (N,K) memory. |
| """ |
| return ((x * x).sum(1, keepdim=True) |
| + (cb * cb).sum(1) |
| - 2 * (x @ cb.T)) |
|
|
|
|
| def vq_encode(x: torch.Tensor, cb: torch.Tensor) -> torch.Tensor: |
| """Nearest-codeword indices. x:(N,d) cb:(K,d) -> (N,) long.""" |
| return _sq_l2(x, cb).argmin(dim=1) |
|
|
|
|
| |
| |
| |
| @dataclass |
| class ProductVQKV: |
| """Product vector quantization of head-dim sub-blocks. |
| |
| Each head's `head_dim` vector is split into `n_sub` contiguous sub-vectors |
| of length `sub_dim = head_dim / n_sub`; each sub-vector is quantized |
| against its own codebook of size `n_codes`. |
| |
| If `normalize=True`, each sub-vector is standardized by its per-dimension |
| calibration mean/std before codebook design and restored after decode. |
| This decorrelates the scale variation RoPE introduces in the rotated half |
| of the key, and is the mechanism that lets RoPE-split specialize. |
| |
| Rate (bits/element) = n_sub * log2(n_codes) / head_dim. |
| e.g. head_dim=128, n_sub=8, n_codes=256 -> 8*8/128 = 0.5 bits/element. |
| """ |
| n_sub: int = 8 |
| n_codes: int = 256 |
| iters: int = 25 |
| normalize: bool = False |
| k_codebooks: list = field(default_factory=list) |
| v_codebooks: list = field(default_factory=list) |
| _k_stats: list = field(default_factory=list) |
| _v_stats: list = field(default_factory=list) |
| _k_stacked: tuple = None |
| _v_stacked: tuple = None |
|
|
| def _split(self, x): |
| |
| return list(torch.chunk(x, self.n_sub, dim=-1)) |
|
|
| def _fit_one(self, x, sample_weights=None): |
| N, head_dim = x.shape |
| sub_dim = head_dim // self.n_sub |
| |
| xb = x.reshape(N, self.n_sub, sub_dim).permute(1, 0, 2).contiguous() |
|
|
| if self.normalize: |
| mu = xb.mean(dim=1, keepdim=True) |
| sd = xb.std(dim=1, keepdim=True).clamp_min(1e-6) |
| xb = (xb - mu) / sd |
| |
| stats = [(mu[s], sd[s]) for s in range(self.n_sub)] |
| else: |
| stats = [None] * self.n_sub |
|
|
| cb_batched = lbg_codebook_batched( |
| xb, self.n_codes, self.iters, sample_weights=sample_weights) |
| cbs = list(cb_batched.unbind(dim=0)) |
| return cbs, stats |
|
|
| def fit(self, k_calib, v_calib, sample_weights=None, n_q_heads=None, |
| k_struct=None): |
| if sample_weights is None and k_struct is not None and k_struct.dim() == 3: |
| from vqkv.metrics import calibration_sample_weights |
| sample_weights = calibration_sample_weights(k_struct, n_q_heads) |
| self.k_codebooks, self._k_stats = self._fit_one(k_calib, sample_weights) |
| self.v_codebooks, self._v_stats = self._fit_one(v_calib, sample_weights) |
| return self |
|
|
| def _stack(self, codebooks, stats): |
| """Lazily stack per-sub-block codebooks/stats into batched tensors so the |
| whole product-VQ encode is a few large ops instead of n_sub small ones. |
| |
| Returns: |
| cb_stacked: (n_sub, K, sub_dim) |
| mu_stacked: (1, n_sub, sub_dim) or None (None => no normalization) |
| sd_stacked: (1, n_sub, sub_dim) or None |
| Requires uniform sub_dim and n_codes across sub-blocks, which ProductVQ |
| guarantees (torch.chunk into equal pieces, single n_codes). |
| """ |
| cb_stacked = torch.stack(codebooks, dim=0) |
| if any(st is not None for st in stats): |
| mu = torch.stack([st[0].reshape(-1) for st in stats], dim=0) |
| sd = torch.stack([st[1].reshape(-1) for st in stats], dim=0) |
| mu_stacked = mu.unsqueeze(0) |
| sd_stacked = sd.unsqueeze(0) |
| else: |
| mu_stacked = sd_stacked = None |
| return cb_stacked, mu_stacked, sd_stacked |
|
|
| def _ensure_stacked(self): |
| if getattr(self, "_k_stacked", None) is None: |
| self._k_stacked = self._stack(self.k_codebooks, self._k_stats) |
| if getattr(self, "_v_stacked", None) is None: |
| self._v_stacked = self._stack(self.v_codebooks, self._v_stats) |
|
|
| def _roundtrip(self, x, stacked): |
| """Batched product-VQ round-trip. |
| |
| x: (N, head_dim). Splits into (N, n_sub, sub_dim), then does ONE batched |
| squared-L2 (n_sub, N, K), one argmin, one gather -- replacing the Python |
| loop over sub-blocks and its 3*n_sub small kernels. |
| |
| Chunks over N so peak memory (the (n_sub, chunk, K) distance tensor) |
| stays bounded: at N=131072, n_sub=16, K=256 the un-chunked tensor is |
| ~2 GB in fp32. The batched path is a GPU optimization -- on CPU it is |
| roughly on par with the per-sub-block loop (no launch overhead to hide). |
| """ |
| cb, mu, sd = stacked |
| n_sub, K, sub_dim = cb.shape |
| N = x.shape[0] |
| c_sq = (cb * cb).sum(-1).unsqueeze(1) |
| if mu is not None: |
| mu_b = mu.permute(1, 0, 2) |
| sd_b = sd.permute(1, 0, 2) |
|
|
| |
| chunk = max(1, (256 * 1024 * 1024) // (n_sub * K * 4)) |
| out_chunks = [] |
| for start in range(0, N, chunk): |
| xc = x[start:start + chunk] |
| c = xc.shape[0] |
| xb = xc.reshape(c, n_sub, sub_dim).permute(1, 0, 2).contiguous() |
| if mu is not None: |
| xb = (xb - mu_b) / sd_b |
| x_sq = (xb * xb).sum(-1, keepdim=True) |
| cross = torch.bmm(xb, cb.transpose(1, 2)) |
| d2 = x_sq - 2 * cross + c_sq |
| idx = d2.argmin(dim=-1) |
| idx_exp = idx.unsqueeze(-1).expand(-1, -1, sub_dim) |
| rec = torch.gather(cb, 1, idx_exp) |
| if mu is not None: |
| rec = rec * sd_b + mu_b |
| out_chunks.append(rec.permute(1, 0, 2).reshape(c, n_sub * sub_dim)) |
| return torch.cat(out_chunks, dim=0) |
|
|
| def roundtrip_k(self, k): |
| self._ensure_stacked() |
| return self._roundtrip(k, self._k_stacked) |
|
|
| def roundtrip_v(self, v): |
| self._ensure_stacked() |
| return self._roundtrip(v, self._v_stacked) |
|
|
| def to(self, device): |
| self.k_codebooks = [cb.to(device) for cb in self.k_codebooks] |
| self.v_codebooks = [cb.to(device) for cb in self.v_codebooks] |
| self._k_stats = [(st[0].to(device), st[1].to(device)) if st is not None else None |
| for st in self._k_stats] |
| self._v_stats = [(st[0].to(device), st[1].to(device)) if st is not None else None |
| for st in self._v_stats] |
| |
| self._k_stacked = None |
| self._v_stacked = None |
| return self |
|
|
| def bits_per_element(self, head_dim): |
| sub_dim = head_dim / self.n_sub |
| return math.log2(self.n_codes) / sub_dim |
|
|
|
|
| @dataclass |
| class TurboQuantKV: |
| """Data-oblivious rotation + per-coordinate scalar quantization, in the |
| spirit of TurboQuant (Zandieh et al., ICLR 2026). |
| |
| Pipeline: random rotation Pi (so coordinates become near-iid in high dim), |
| then a per-coordinate scalar codebook. We use a uniform Lloyd-Max-style |
| codebook on the rotated coordinates as a faithful stand-in for their |
| precomputed Beta-optimal codebook (the exact codebook is an implementation |
| detail; the rotation is the load-bearing idea). We store the per-vector L2 |
| norm in fp16 and rescale on dequant, as the paper specifies. |
| |
| This is included so we can compare a rotation-based SCALAR method against |
| product VQ on the attention-output COSINE metric -- the comparison the |
| TurboQuant paper does not make (it optimizes cache-vector MSE / inner prod). |
| """ |
| nbits: int = 4 |
| seed: int = 0 |
| _rot: torch.Tensor = None |
| _levels: torch.Tensor = None |
|
|
| def _make_rotation(self, d): |
| g = torch.Generator().manual_seed(self.seed) |
| a = torch.randn(d, d, generator=g) |
| q, _ = torch.linalg.qr(a) |
| return q |
|
|
| def fit(self, k_calib, v_calib): |
| d = k_calib.shape[-1] |
| self._rot = self._make_rotation(d) |
| |
| |
| |
| |
| kn = k_calib / k_calib.norm(dim=-1, keepdim=True).clamp_min(1e-8) |
| rk = kn @ self._rot |
| lo = torch.quantile(rk.flatten()[:200000], 0.001) |
| hi = torch.quantile(rk.flatten()[:200000], 0.999) |
| self._levels = torch.linspace(lo.item(), hi.item(), (1 << self.nbits)) |
| return self |
|
|
| def _roundtrip(self, x): |
| norms = x.norm(dim=-1, keepdim=True).clamp_min(1e-8) |
| xn = x / norms |
| y = xn @ self._rot |
| idx = torch.bucketize(y, self._levels) |
| idx = idx.clamp(0, self._levels.numel() - 1) |
| y_hat = self._levels[idx] |
| x_hat = y_hat @ self._rot.T |
| return x_hat * norms |
|
|
| def roundtrip_k(self, k): |
| return self._roundtrip(k) |
|
|
| def roundtrip_v(self, v): |
| return self._roundtrip(v) |
|
|
| def to(self, device): |
| self._rot = self._rot.to(device) |
| self._levels = self._levels.to(device) |
| return self |
|
|
| def bits_per_element(self, head_dim): |
| |
| return self.nbits + 16 / head_dim |
|
|
|
|
| @dataclass |
| class RoPESplitVQKV: |
| """ProductVQ with separate codebooks for the RoPE'd vs pass-through halves |
| of each KEY head. |
| |
| Laguna full-attention layers use partial_rotary_factor=0.5: the first half |
| of head_dim is rotated (position-dependent, broad distribution), the second |
| half is identity (position-independent). A single codebook must straddle |
| two regimes; splitting lets each codebook specialize. |
| |
| Values receive no RoPE, so V uses a plain ProductVQ. |
| """ |
| n_sub_half: int = 4 |
| n_codes: int = 256 |
| iters: int = 25 |
| rotary_fraction: float = 0.5 |
| _rope_vq: ProductVQKV = None |
| _pass_vq: ProductVQKV = None |
| _v_vq: ProductVQKV = None |
|
|
| def fit(self, k_calib, v_calib, sample_weights=None, n_q_heads=None, |
| k_struct=None): |
| if sample_weights is None and k_struct is not None and k_struct.dim() == 3: |
| from vqkv.metrics import calibration_sample_weights |
| sample_weights = calibration_sample_weights(k_struct, n_q_heads) |
| fit_kw = dict(sample_weights=sample_weights, n_q_heads=n_q_heads, |
| k_struct=k_struct) |
| d = k_calib.shape[-1] |
| cut = int(d * self.rotary_fraction) |
| k_rope, k_pass = k_calib[..., :cut], k_calib[..., cut:] |
| self._cut = cut |
| self._rope_vq = ProductVQKV(self.n_sub_half, self.n_codes, self.iters, |
| normalize=True).fit(k_rope, k_rope, **fit_kw) |
| self._pass_vq = ProductVQKV(self.n_sub_half, self.n_codes, self.iters, |
| normalize=False).fit(k_pass, k_pass, **fit_kw) |
| self._v_vq = ProductVQKV(2 * self.n_sub_half, self.n_codes, self.iters, |
| normalize=True).fit(v_calib, v_calib, **fit_kw) |
| return self |
|
|
| def roundtrip_k(self, k): |
| kr = self._rope_vq.roundtrip_k(k[..., :self._cut]) |
| kp = self._pass_vq.roundtrip_k(k[..., self._cut:]) |
| return torch.cat([kr, kp], dim=-1) |
|
|
| def roundtrip_v(self, v): |
| return self._v_vq.roundtrip_v(v) |
|
|
| def to(self, device): |
| self._rope_vq.to(device) |
| self._pass_vq.to(device) |
| self._v_vq.to(device) |
| return self |
|
|
| def bits_per_element(self, head_dim): |
| |
| half = head_dim / 2 |
| sub_dim = half / self.n_sub_half |
| return math.log2(self.n_codes) / sub_dim |
|
|
|
|
| |
| |
| |
| @dataclass |
| class RandomRotationScalarKV: |
| """Simplified, data-OBLIVIOUS rotation-then-scalar quantizer in the spirit |
| of TurboQuant / PolarQuant (Zandieh et al., ICLR 2026). |
| |
| NOT the full method: TurboQuant adds PolarQuant's normalization-free polar |
| transform and a 1-bit QJL residual correction for UNBIASED inner-product |
| estimation. This stand-in captures only the core data-oblivious idea -- |
| apply a fixed random orthogonal rotation so coordinates concentrate (a |
| Beta/Gaussian-like distribution), then scalar-quantize each coordinate with |
| a fixed range. It exists so the harness can run the central scientific |
| comparison of this project: |
| |
| data-OBLIVIOUS rotation+scalar vs. data-DEPENDENT product VQ |
| |
| on real Laguna cache statistics. If you want the real thing, drop in the |
| unofficial impl (github.com/0xSero/turboquant or hackimov/turboquant-kv) |
| behind this same fit/roundtrip interface. The OpenReview discussion of the |
| paper is contested precisely on the oblivious-vs-data-dependent claim, so a |
| clean head-to-head on a NEW model is a genuine contribution either way. |
| """ |
| nbits: int = 3 |
| seed: int = 0 |
| _R: torch.Tensor = None |
| _Rk_range: tuple = None |
| _Rv_range: tuple = None |
|
|
| def _rotation(self, d): |
| g = torch.Generator().manual_seed(self.seed) |
| a = torch.randn(d, d, generator=g) |
| q, _ = torch.linalg.qr(a) |
| return q |
|
|
| def fit(self, k_calib, v_calib): |
| d = k_calib.shape[-1] |
| self._R = self._rotation(d) |
| |
| rk = k_calib @ self._R |
| rv = v_calib @ self._R |
| self._Rk_range = (rk.quantile(0.001), rk.quantile(0.999)) |
| self._Rv_range = (rv.quantile(0.001), rv.quantile(0.999)) |
| return self |
|
|
| def _rt(self, x, rng): |
| xr = x @ self._R |
| lo, hi = rng |
| qmax = (1 << self.nbits) - 1 |
| scale = (hi - lo).clamp_min(1e-8) / qmax |
| q = torch.clamp(torch.round((xr - lo) / scale), 0, qmax) |
| xr_hat = q * scale + lo |
| return xr_hat @ self._R.T |
|
|
| def roundtrip_k(self, k): |
| return self._rt(k, self._Rk_range) |
|
|
| def roundtrip_v(self, v): |
| return self._rt(v, self._Rv_range) |
|
|
| def bits_per_element(self, head_dim): |
| |
| return float(self.nbits) |
|
|
|
|
| |
| |
| |
| @dataclass |
| class SignScalarKV: |
| """Symmetric 1-bit (sign) quantization with a per-group scale. |
| |
| x_hat = scale * sign(x), scale = mean(|x|) over the group. |
| |
| This is the honest 1-bit floor of the KIVI/quanto scalar family: unlike the |
| affine `ScalarKV(nbits=1)`, it stores NO zero-point (cache K/V are ~zero-mean |
| after norm), so it is both cheaper and better-centered. Group axis matches |
| KIVI conventions: keys per-channel (token axis), values per-token. |
| `per_channel_key` toggles the key axis. |
| """ |
| per_channel_key: bool = True |
| group_dim_k: int = 0 |
| bits: float = 1.0 |
|
|
| def fit(self, k_calib, v_calib): |
| return self |
|
|
| def _sign_q(self, x, dim): |
| scale = x.abs().mean(dim=dim, keepdim=True).clamp_min(1e-8) |
| return torch.sign(x) * scale |
|
|
| def roundtrip_k(self, k): |
| dim = 0 if self.per_channel_key else -1 |
| return self._sign_q(k, dim) |
|
|
| def roundtrip_v(self, v): |
| return self._sign_q(v, dim=-1) |
|
|
| def bits_per_element(self, head_dim): |
| |
| |
| |
| |
| return 1.0 + 16.0 / head_dim |
|
|
|
|
| @dataclass |
| class TernaryScalarKV: |
| """1.58-bit ternary quantization {-1, 0, +1} with a per-group scale, in the |
| style of BitNet b1.58 -- the "just go (almost) 1-bit" school that the |
| Bonsai/PrismML line popularized. Zeros are assigned by a threshold at a |
| fraction of the mean-abs, letting small-magnitude coordinates drop out. |
| |
| thr = alpha * mean(|x|); x_hat = scale * {sign(x) if |x|>thr else 0} |
| |
| Rate ~ log2(3) ~ 1.58 bits/element. Included so the table spans the full |
| aggressive regime and so a reviewer who knows BitNet sees the comparison. |
| """ |
| alpha: float = 0.7 |
| per_channel_key: bool = True |
|
|
| def fit(self, k_calib, v_calib): |
| return self |
|
|
| def _tern_q(self, x, dim): |
| m = x.abs().mean(dim=dim, keepdim=True).clamp_min(1e-8) |
| thr = self.alpha * m |
| mask = (x.abs() > thr).to(x.dtype) |
| scale = (x.abs() * mask).sum(dim=dim, keepdim=True) / \ |
| mask.sum(dim=dim, keepdim=True).clamp_min(1.0) |
| return torch.sign(x) * mask * scale |
|
|
| def roundtrip_k(self, k): |
| dim = 0 if self.per_channel_key else -1 |
| return self._tern_q(k, dim) |
|
|
| def roundtrip_v(self, v): |
| return self._tern_q(v, dim=-1) |
|
|
| def bits_per_element(self, head_dim): |
| import math as _m |
| return _m.log2(3) + 16.0 / head_dim |