"""Post-training quantization for KronLLM checkpoints. KronLLM does not contain any ``nn.Linear`` layer: every weight matrix is a Kronecker composition of small factors, so ``torch.ao.quantization`` cannot see it. Instead we quantize the *stored* tensors -- factors, embeddings and the tied head -- with symmetric per-channel scales and dequantize them when the checkpoint is loaded. The forward pass is therefore bit-identical in structure and only sees float weights; the win is a 4x (int8) or 8x (int4) smaller file, which is what makes the model practical on a phone or a microcontroller. Typical use:: from kronllm.quantization import quantize_checkpoint, load_any_checkpoint report = quantize_checkpoint("runs/Q2/best.pt", "runs/Q2/model_int8.pt", bits=8) cfg, state = load_any_checkpoint("runs/Q2/model_int8.pt") """ from __future__ import annotations import time from pathlib import Path from typing import Any, Dict, Mapping, Tuple import torch # Tensors smaller than this are stored in float32: scales would cost more than # the payload, and 1-D parameters (RMSNorm, tau, gate) must stay exact. _MIN_NUMEL = 256 # Name fragments that must never be quantized (normalisation and scalars). _SKIP_FRAGMENTS = ("norm", "tau", "gate", "theta", "anchor") def _is_quantizable(name: str, tensor: torch.Tensor) -> bool: """Return True if ``tensor`` should be stored in low precision. Args: name: Parameter name inside the state dict. tensor: Parameter tensor. Returns: True for 2-D (or higher) float tensors of at least ``_MIN_NUMEL`` elements whose name does not match a normalisation/scalar role. """ if not tensor.is_floating_point() or tensor.dim() < 2: return False if tensor.numel() < _MIN_NUMEL: return False return not any(fragment in name for fragment in _SKIP_FRAGMENTS) def _pack_int4(codes: torch.Tensor) -> torch.Tensor: """Pack signed int4 codes (range ``[-8, 7]``) two per byte. Args: codes: Int8 tensor whose values are already clamped to ``[-8, 7]``. Returns: Flat uint8 tensor with the low nibble holding the even elements. """ flat = codes.reshape(-1).to(torch.int16) if flat.numel() % 2: flat = torch.cat([flat, torch.zeros(1, dtype=flat.dtype)]) shifted = flat + 8 # 0..15, safe in int16 packed = shifted[0::2] | (shifted[1::2] << 4) return packed.to(torch.uint8) def _unpack_int4(packed: torch.Tensor, numel: int) -> torch.Tensor: """Inverse of :func:`_pack_int4`. Args: packed: Flat uint8 tensor produced by :func:`_pack_int4`. numel: Number of original elements. Returns: Int8 tensor of length ``numel`` in ``[-8, 7]``. """ low = (packed & 0x0F).to(torch.int16) - 8 high = ((packed >> 4) & 0x0F).to(torch.int16) - 8 out = torch.empty(packed.numel() * 2, dtype=torch.int16) out[0::2] = low out[1::2] = high return out[:numel].to(torch.int8) def _quantize_tensor(tensor: torch.Tensor, bits: int ) -> Tuple[torch.Tensor, torch.Tensor]: """Symmetric per-channel quantization of one tensor. Args: tensor: Float tensor to quantize. bits: Target bit width, either 8 or 4. Returns: A pair ``(codes, scale)``. ``codes`` is int8 (values clamped to the signed range) and ``scale`` broadcasts against ``tensor``. """ qmax = float(2 ** (bits - 1) - 1) reduce_dims = tuple(range(1, tensor.dim())) amax = tensor.detach().abs().amax(dim=reduce_dims, keepdim=True) scale = (amax / qmax).clamp_min(1e-8) codes = torch.round(tensor.detach() / scale).clamp(-qmax, qmax).to(torch.int8) return codes, scale def quantize_state_dict(state: Mapping[str, torch.Tensor], bits: int = 8 ) -> Dict[str, Any]: """Quantize every eligible tensor of a state dict. Args: state: Float state dict as stored in a training checkpoint. bits: 8 or 4. Returns: A serialisable dict mapping parameter names to either a float tensor (kept exact) or a ``{"q", "scale", "shape"}`` record. """ if bits not in (8, 4): raise ValueError(f"bits must be 8 or 4, got {bits}") out: Dict[str, Any] = {} for name, tensor in state.items(): if not _is_quantizable(name, tensor): out[name] = tensor.detach().clone() continue codes, scale = _quantize_tensor(tensor, bits) record: Dict[str, Any] = {"shape": tuple(tensor.shape), "scale": scale} if bits == 8: record["q"] = codes.reshape(tensor.shape) else: record["q"] = _pack_int4(codes) record["numel"] = tensor.numel() out[name] = record return out def dequantize_state_dict(quantized: Mapping[str, Any], bits: int ) -> Dict[str, torch.Tensor]: """Rebuild a float state dict from :func:`quantize_state_dict` output. Args: quantized: Output of :func:`quantize_state_dict`. bits: Bit width used during quantization. Returns: Float state dict that can be passed to ``model.load_state_dict``. """ state: Dict[str, torch.Tensor] = {} for name, value in quantized.items(): if not isinstance(value, Mapping): state[name] = value.float() if value.is_floating_point() else value continue scale = value["scale"].float() if bits == 8: codes = value["q"].float() else: codes = _unpack_int4(value["q"], int(value["numel"])).float() codes = codes.reshape(value["shape"]) state[name] = codes * scale return state def quantize_checkpoint(src: str | Path, dst: str | Path, bits: int = 8 ) -> Dict[str, Any]: """Quantize a training checkpoint and write a compact checkpoint. Args: src: Path to a checkpoint written by ``kronllm.train`` (``best.pt``). dst: Destination path for the quantized checkpoint. bits: 8 or 4. Returns: A report with the original and quantized sizes plus the parameter count. Raises: FileNotFoundError: If ``src`` does not exist. """ src, dst = Path(src), Path(dst) if not src.exists(): raise FileNotFoundError(src) checkpoint = torch.load(src, map_location="cpu", weights_only=False) state = checkpoint.get("model", checkpoint) quantized = quantize_state_dict(state, bits=bits) payload = { "quantized": True, "bits": bits, "cfg": checkpoint.get("cfg"), "step": checkpoint.get("step"), "val_ppl": checkpoint.get("val_ppl"), "state": quantized, } dst.parent.mkdir(parents=True, exist_ok=True) torch.save(payload, dst) return { "src": str(src), "dst": str(dst), "bits": bits, "params": int(sum(v.numel() for v in state.values())), "size_before_mb": round(src.stat().st_size / 1e6, 3), "size_after_mb": round(dst.stat().st_size / 1e6, 3), "compression": round(src.stat().st_size / dst.stat().st_size, 2), "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), } def load_any_checkpoint(path: str | Path) -> Tuple[Dict[str, Any], Dict[str, torch.Tensor]]: """Load a float or a quantized checkpoint. Args: path: Checkpoint path. Returns: A pair ``(cfg, state_dict)`` where ``state_dict`` is always float and can be loaded into :class:`kronllm.model.KronTransformer`. Raises: ValueError: If the checkpoint is quantized but carries no config. """ path = Path(path) checkpoint = torch.load(path, map_location="cpu", weights_only=False) if not checkpoint.get("quantized"): cfg = checkpoint.get("cfg") return cfg, checkpoint.get("model", checkpoint) cfg = checkpoint.get("cfg") if cfg is None: raise ValueError(f"quantized checkpoint {path} has no config") state = dequantize_state_dict(checkpoint["state"], int(checkpoint["bits"])) return cfg, state