| """ |
| vqkv.compressed_cache — inference-ready KV cache with real memory savings. |
| |
| VQQuantizedCache persists uint8 codebook indices for target layers (not bf16 |
| reconstructions). Dequantizes transiently per layer on each attention read. |
| Without a fused kernel this saves memory but not wall-clock; see memory_footprint(). |
| """ |
|
|
| from __future__ import annotations |
|
|
| import math |
|
|
| import torch |
|
|
| try: |
| from vqkv.quantizers import ProductVQKV, ScalarKV, KIVIScalarKV |
| except ModuleNotFoundError: |
| from quantizers import ProductVQKV, ScalarKV, KIVIScalarKV |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| def _stacked(q: ProductVQKV, which: str): |
| q._ensure_stacked() |
| return q._k_stacked if which == "k" else q._v_stacked |
|
|
|
|
| def pvq_encode(q: ProductVQKV, x: torch.Tensor, which: str = "k") -> torch.Tensor: |
| """x: (N, head_dim) -> idx: (N, n_sub) uint8 (nearest codeword per sub-block).""" |
| cb, mu, sd = _stacked(q, which) |
| n_sub, K, sub_dim = cb.shape |
| if K > 256: |
| raise ValueError(f"n_codes={K} > 256 needs >1 byte/index; this path is uint8. " |
| f"Use K<=256 (all headline configs do) or add bit-packing.") |
| N = x.shape[0] |
| c_sq = (cb * cb).sum(-1).unsqueeze(1) |
| mu_b = mu.permute(1, 0, 2) if mu is not None else None |
| sd_b = sd.permute(1, 0, 2) if sd is not None else None |
| chunk = max(1, (256 * 1024 * 1024) // (n_sub * K * 4)) |
| out = [] |
| for s0 in range(0, N, chunk): |
| xc = x[s0:s0 + chunk] |
| c = xc.shape[0] |
| xb = xc.reshape(c, n_sub, sub_dim).permute(1, 0, 2).contiguous() |
| if mu_b 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)) |
| idx = (x_sq - 2 * cross + c_sq).argmin(-1) |
| out.append(idx.to(torch.uint8)) |
| return torch.cat(out, dim=1).T.contiguous() |
|
|
|
|
| def pvq_decode(q: ProductVQKV, idx: torch.Tensor, which: str = "k") -> torch.Tensor: |
| """idx: (N, n_sub) uint8 -> x_hat: (N, head_dim) (codebook dtype, e.g. fp32).""" |
| cb, mu, sd = _stacked(q, which) |
| n_sub, K, sub_dim = cb.shape |
| mu_b = mu.permute(1, 0, 2) if mu is not None else None |
| sd_b = sd.permute(1, 0, 2) if sd is not None else None |
| idxT = idx.T.long() |
| rec = torch.gather(cb, 1, idxT.unsqueeze(-1).expand(-1, -1, sub_dim)) |
| if mu_b is not None: |
| rec = rec * sd_b + mu_b |
| N = idxT.shape[1] |
| return rec.permute(1, 0, 2).reshape(N, n_sub * sub_dim) |
|
|
|
|
| def pvq_codebook_bytes(q: ProductVQKV) -> int: |
| """Fixed per-layer codebook overhead (bytes), amortized over all tokens.""" |
| cb_k, _, _ = _stacked(q, "k") |
| cb_v, _, _ = _stacked(q, "v") |
| return cb_k.numel() * cb_k.element_size() + cb_v.numel() * cb_v.element_size() |
|
|
|
|
| |
| |
| |
| try: |
| from transformers.cache_utils import DynamicCache |
| _HAVE_TF = True |
| except Exception: |
| DynamicCache = object |
| _HAVE_TF = False |
|
|
|
|
| class VQQuantizedCache(DynamicCache): |
| """Drop-in cache that persists ProductVQ indices for ``target_layers`` and |
| leaves all other layers in native precision (Laguna's sliding-window layers |
| are bounded at 512 tokens and don't dominate, so we don't touch them). |
| |
| Memory model: ``key_cache``/``value_cache`` for target layers are never |
| populated with full tensors. We keep ``k_codes[layer]`` / ``v_codes[layer]`` |
| as (seq, n_kv_heads, n_sub) uint8 and dequantize the whole buffer transiently |
| each time the layer runs attention. |
| |
| NOTE ON transformers VERSIONS: signatures around DynamicCache shift between |
| releases. This targets the modern |
| ``update(key_states, value_states, layer_idx, cache_kwargs=None) -> (k, v)`` |
| interface and overrides ``get_seq_length``. If your pinned version calls |
| additional hooks (``reorder_cache`` for beam search, ``crop`` for assisted |
| decoding), forward them to the code buffers the same way ``update`` does. |
| """ |
|
|
| def __init__(self, per_layer_quantizers: dict, target_layers, *a, **k): |
| super().__init__(*a, **k) |
| self.q = per_layer_quantizers |
| self.target = set(int(i) for i in target_layers) |
| self.k_codes: dict[int, torch.Tensor] = {} |
| self.v_codes: dict[int, torch.Tensor] = {} |
| self._dtype = None |
| self._device = None |
|
|
| |
| def update(self, key_states, value_states, layer_idx, cache_kwargs=None): |
| if layer_idx not in self.target or layer_idx not in self.q: |
| |
| return super().update(key_states, value_states, layer_idx, cache_kwargs) |
|
|
| self._dtype = key_states.dtype |
| self._device = key_states.device |
| q = self.q[layer_idx] |
| b, h, s, d = key_states.shape |
|
|
| |
| kf = key_states[0].transpose(0, 1).reshape(-1, d).float() |
| vf = value_states[0].transpose(0, 1).reshape(-1, d).float() |
| kc = pvq_encode(q, kf, "k").reshape(s, h, -1) |
| vc = pvq_encode(q, vf, "v").reshape(s, h, -1) |
|
|
| |
| if layer_idx in self.k_codes: |
| self.k_codes[layer_idx] = torch.cat([self.k_codes[layer_idx], kc], dim=0) |
| self.v_codes[layer_idx] = torch.cat([self.v_codes[layer_idx], vc], dim=0) |
| else: |
| self.k_codes[layer_idx] = kc |
| self.v_codes[layer_idx] = vc |
|
|
| |
| allk, allv = self.k_codes[layer_idx], self.v_codes[layer_idx] |
| S = allk.shape[0] |
| kfull = pvq_decode(q, allk.reshape(-1, allk.shape[-1]), "k").reshape(S, h, d) |
| vfull = pvq_decode(q, allv.reshape(-1, allv.shape[-1]), "v").reshape(S, h, d) |
| kfull = kfull.permute(1, 0, 2)[None].to(self._dtype).to(self._device) |
| vfull = vfull.permute(1, 0, 2)[None].to(self._dtype).to(self._device) |
| return kfull, vfull |
|
|
| def get_seq_length(self, layer_idx: int = 0) -> int: |
| for li in sorted(self.target): |
| if li in self.k_codes: |
| return self.k_codes[li].shape[0] |
| return super().get_seq_length(layer_idx) if _HAVE_TF else 0 |
|
|
| |
| def memory_footprint(self) -> dict: |
| """Persistent bytes actually held on device, split by source.""" |
| code_bytes = sum(t.numel() for t in self.k_codes.values()) \ |
| + sum(t.numel() for t in self.v_codes.values()) |
| cb_bytes = sum(pvq_codebook_bytes(self.q[li]) for li in self.k_codes) |
| native_bytes = 0 |
| if _HAVE_TF: |
| for kc in getattr(self, "key_cache", []): |
| if isinstance(kc, torch.Tensor): |
| native_bytes += kc.numel() * kc.element_size() |
| for vc in getattr(self, "value_cache", []): |
| if isinstance(vc, torch.Tensor): |
| native_bytes += vc.numel() * vc.element_size() |
| return {"compressed_indices_B": code_bytes, |
| "codebooks_B": cb_bytes, |
| "native_layers_B": native_bytes, |
| "total_B": code_bytes + cb_bytes + native_bytes} |
|
|
|
|
| |
| |
| |
| class LagunaGeom: |
| """Laguna-XS.2 cache geometry (from the config / proposal).""" |
| n_layers = 40 |
| full_layers = 10 |
| sliding_layers = 30 |
| sliding_window = 512 |
| n_kv_heads = 8 |
| head_dim = 128 |
|
|
|
|
| def kv_cache_bytes(context_len: int, bits_per_elt_full: float, |
| geom: LagunaGeom = LagunaGeom(), |
| bits_per_elt_sliding: float = 16.0) -> dict: |
| """Total KV-cache bytes at a context length. |
| |
| Only the full-attention layers carry the growing cache; sliding layers are |
| capped at ``sliding_window`` tokens. ``bits_per_elt_full`` is the rate the |
| quantizer reports for the compressed layers (use 16.0 for the fp16 baseline). |
| The K and V tensors are both counted. |
| """ |
| elts_per_token = geom.n_kv_heads * geom.head_dim * 2 |
| full_tokens = context_len * geom.full_layers |
| slide_tokens = min(context_len, geom.sliding_window) * geom.sliding_layers |
| full_B = full_tokens * elts_per_token * bits_per_elt_full / 8 |
| slide_B = slide_tokens * elts_per_token * bits_per_elt_sliding / 8 |
| return {"full_B": full_B, "sliding_B": slide_B, "total_B": full_B + slide_B} |
|
|
|
|
| def compression_vs_fp16(context_len: int, bits_per_elt_full: float, |
| geom: LagunaGeom = LagunaGeom()) -> float: |
| base = kv_cache_bytes(context_len, 16.0, geom)["total_B"] |
| comp = kv_cache_bytes(context_len, bits_per_elt_full, geom)["total_B"] |
| return base / comp |
|
|
|
|
| |
| |
| |
| if __name__ == "__main__": |
| torch.manual_seed(0) |
| N, hd = 4096, 128 |
| k = torch.randn(N, hd) |
| v = torch.randn(N, hd) |
|
|
| q = ProductVQKV(n_sub=32, n_codes=256, iters=10).fit(k, v) |
| idx = pvq_encode(q, k, "k") |
| k_hat = pvq_decode(q, idx, "k") |
| rt = q.roundtrip_k(k) |
|
|
| print(f"idx dtype/shape : {idx.dtype} {tuple(idx.shape)}") |
| print(f"encode->decode == roundtrip : " |
| f"{torch.allclose(k_hat, rt, atol=1e-4)} " |
| f"(max abs diff {(k_hat - rt).abs().max():.2e})") |
|
|
| raw_B = k.numel() * 2 |
| comp_B = idx.numel() * 1 |
| print(f"stored bytes fp16={raw_B} vq-2b={comp_B} ratio={raw_B / comp_B:.1f}x") |
| print(f"reported bits/elt: {q.bits_per_element(hd):.3f}") |
|
|
| for L in (4096, 32768, 131072): |
| r = compression_vs_fp16(L, q.bits_per_element(hd)) |
| gb = kv_cache_bytes(L, q.bits_per_element(hd))["total_B"] / 1e9 |
| base = kv_cache_bytes(L, 16.0)["total_B"] / 1e9 |
| print(f"context {L:>7}: fp16={base:6.2f} GB vq-2b={gb:6.2f} GB ({r:.1f}x)") |
|
|