|
| 1 | +# ------------------------------------------------------------------------- |
| 2 | +# Copyright (c) Microsoft Corporation. All rights reserved. |
| 3 | +# Licensed under the MIT License. |
| 4 | +# -------------------------------------------------------------------------- |
| 5 | +# K-quant weight-only quantization for PyTorch models. |
| 6 | +# |
| 7 | +# Algorithm originates from llama.cpp's ggml k-quants |
| 8 | +# (``make_qkx2_quants`` for the asymmetric variant and ``make_qx_quants`` for |
| 9 | +# the symmetric variant): |
| 10 | +# https://github.com/ggml-org/llama.cpp/blob/64eda5deb9859e87a020e56bab5d2f9ca956f1de/ggml/src/ggml-quants.c |
| 11 | +# -------------------------------------------------------------------------- |
| 12 | +from __future__ import annotations |
| 13 | + |
| 14 | +import logging |
| 15 | +from typing import TYPE_CHECKING, Callable |
| 16 | + |
| 17 | +import torch |
| 18 | + |
| 19 | +from olive.passes import Pass |
| 20 | +from olive.passes.pass_config import PassConfigParam |
| 21 | +from olive.passes.pytorch.quant_utils import finalize, get_quantizer_config, prepare_model |
| 22 | + |
| 23 | +if TYPE_CHECKING: |
| 24 | + from olive.hardware.accelerator import AcceleratorSpec |
| 25 | + from olive.model import HfModelHandler |
| 26 | + from olive.passes.pass_config import BasePassConfig |
| 27 | + |
| 28 | + |
| 29 | +logger = logging.getLogger(__name__) |
| 30 | + |
| 31 | + |
| 32 | +_ASYM_NSTEP = 20 |
| 33 | +_ASYM_RDELTA = 0.1 |
| 34 | +_ASYM_RRMIN = -1.0 |
| 35 | + |
| 36 | +_SYM_STEPS = tuple(s for s in range(-9, 10) if s != 0) |
| 37 | +_SYM_STEP_DELTA = 0.1 |
| 38 | + |
| 39 | + |
| 40 | +RefineFn = Callable[ |
| 41 | + [torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor], |
| 42 | + tuple[torch.Tensor, torch.Tensor], |
| 43 | +] |
| 44 | + |
| 45 | + |
| 46 | +def _safe_reciprocal(x: torch.Tensor) -> torch.Tensor: |
| 47 | + safe = torch.where(x != 0, x, torch.ones_like(x)) |
| 48 | + return torch.where(x != 0, 1.0 / safe, torch.ones_like(x)) |
| 49 | + |
| 50 | + |
| 51 | +def _refine_asymmetric( |
| 52 | + data: torch.Tensor, |
| 53 | + weights: torch.Tensor, |
| 54 | + quant_l: torch.Tensor, |
| 55 | + iscale: torch.Tensor, |
| 56 | + quant_offset: torch.Tensor, |
| 57 | +) -> tuple[torch.Tensor, torch.Tensor]: |
| 58 | + sum_w = torch.sum(weights, dim=1, keepdim=True) |
| 59 | + sum_x = torch.sum(weights * data, dim=1, keepdim=True) |
| 60 | + wq = weights * quant_l |
| 61 | + sum_l = torch.sum(wq, dim=1, keepdim=True) |
| 62 | + sum_l2 = torch.sum(wq * quant_l, dim=1, keepdim=True) |
| 63 | + sum_xl = torch.sum(wq * data, dim=1, keepdim=True) |
| 64 | + |
| 65 | + det = sum_w * sum_l2 - sum_l * sum_l |
| 66 | + valid = det > 0 |
| 67 | + det_safe = torch.where(valid, det, torch.ones_like(det)) |
| 68 | + |
| 69 | + scale_lsq = (sum_w * sum_xl - sum_x * sum_l) / det_safe |
| 70 | + offset_lsq = (sum_l2 * sum_x - sum_l * sum_xl) / det_safe |
| 71 | + |
| 72 | + scale = torch.where(valid, scale_lsq, _safe_reciprocal(iscale)) |
| 73 | + offset = torch.where(valid, offset_lsq, quant_offset) |
| 74 | + return scale, offset |
| 75 | + |
| 76 | + |
| 77 | +def _refine_symmetric( |
| 78 | + data: torch.Tensor, |
| 79 | + weights: torch.Tensor, |
| 80 | + quant_l: torch.Tensor, |
| 81 | + iscale: torch.Tensor, |
| 82 | + quant_offset: torch.Tensor, |
| 83 | +) -> tuple[torch.Tensor, torch.Tensor]: |
| 84 | + wq = weights * quant_l |
| 85 | + sum_l2 = torch.sum(wq * quant_l, dim=1, keepdim=True) |
| 86 | + sum_xl = torch.sum(wq * data, dim=1, keepdim=True) |
| 87 | + |
| 88 | + valid = sum_l2 > 0 |
| 89 | + sum_l2_safe = torch.where(valid, sum_l2, torch.ones_like(sum_l2)) |
| 90 | + scale = torch.where(valid, sum_xl / sum_l2_safe, _safe_reciprocal(iscale)) |
| 91 | + offset = torch.zeros_like(scale) |
| 92 | + return scale, offset |
| 93 | + |
| 94 | + |
| 95 | +def _kquant_search( |
| 96 | + data: torch.Tensor, |
| 97 | + weights: torch.Tensor, |
| 98 | + normalizer: torch.Tensor, |
| 99 | + quant_offset: torch.Tensor, |
| 100 | + l_min: float, |
| 101 | + l_max: float, |
| 102 | + initial_factor: float, |
| 103 | + candidate_factors: tuple[float, ...], |
| 104 | + refine_fn: RefineFn, |
| 105 | +) -> tuple[torch.Tensor, torch.Tensor]: |
| 106 | + nontrivial = normalizer != 0 |
| 107 | + ones = torch.ones_like(normalizer) |
| 108 | + norm_safe = torch.where(nontrivial, normalizer, ones) |
| 109 | + |
| 110 | + def quantize(factor: float) -> tuple[torch.Tensor, torch.Tensor]: |
| 111 | + iscale = torch.where(nontrivial, factor / norm_safe, ones) |
| 112 | + quant_l = torch.clamp(torch.round(iscale * (data - quant_offset)), l_min, l_max) |
| 113 | + return iscale, quant_l |
| 114 | + |
| 115 | + def mad_of(scale: torch.Tensor, offset: torch.Tensor, quant_l: torch.Tensor) -> torch.Tensor: |
| 116 | + diff = scale * quant_l + offset - data |
| 117 | + return torch.sum(weights * diff * diff, dim=1, keepdim=True) |
| 118 | + |
| 119 | + iscale, quant_l = quantize(initial_factor) |
| 120 | + scale = _safe_reciprocal(iscale) |
| 121 | + offset = quant_offset.expand_as(scale).clone() |
| 122 | + best_mad = mad_of(scale, offset, quant_l) |
| 123 | + |
| 124 | + for factor in candidate_factors: |
| 125 | + iscale_c, quant_l_c = quantize(factor) |
| 126 | + scale_c, offset_c = refine_fn(data, weights, quant_l_c, iscale_c, quant_offset) |
| 127 | + mad = mad_of(scale_c, offset_c, quant_l_c) |
| 128 | + accept = mad < best_mad |
| 129 | + scale = torch.where(accept, scale_c, scale) |
| 130 | + offset = torch.where(accept, offset_c, offset) |
| 131 | + best_mad = torch.where(accept, mad, best_mad) |
| 132 | + |
| 133 | + return scale, offset |
| 134 | + |
| 135 | + |
| 136 | +@torch.no_grad() |
| 137 | +def kquant_find_qparams( |
| 138 | + weight: torch.Tensor, |
| 139 | + group_size: int, |
| 140 | + maxq: int, |
| 141 | + minq: int, |
| 142 | + symmetric: bool = False, |
| 143 | +) -> tuple[torch.Tensor, torch.Tensor]: |
| 144 | + """Compute k-quant per-group scale and zero point for a 2D weight tensor. |
| 145 | +
|
| 146 | + Args: |
| 147 | + weight: 2D tensor of shape ``(out_features, in_features)``. For embeddings |
| 148 | + this is ``(num_embeddings, embedding_dim)``. |
| 149 | + group_size: Group size along the last dimension. Must be > 0 and evenly |
| 150 | + divide ``weight.shape[-1]``. |
| 151 | + maxq: Inclusive maximum integer code (as produced by ``get_maxq_minq``). |
| 152 | + minq: Inclusive minimum integer code (as produced by ``get_maxq_minq``). |
| 153 | + symmetric: When True, run the symmetric variant (zero point fixed to the |
| 154 | + midpoint of the quantization range); otherwise run the asymmetric |
| 155 | + variant that solves for both per-group scale and zero point. |
| 156 | +
|
| 157 | + Returns: |
| 158 | + Tuple ``(scales, zero_points)`` matching ``WeightQuantizer.find_qparams``: |
| 159 | + * ``scales``: shape ``(out_features, num_groups)``, dtype matches the |
| 160 | + input weight dtype. |
| 161 | + * ``zero_points``: shape ``(out_features, num_groups)``, dtype |
| 162 | + ``int32``, values in ``[minq, maxq]``. |
| 163 | +
|
| 164 | + """ |
| 165 | + if maxq <= minq: |
| 166 | + raise ValueError(f"k-quant requires maxq > minq, got maxq={maxq}, minq={minq}.") |
| 167 | + if group_size <= 0: |
| 168 | + raise ValueError(f"k-quant requires group_size > 0, got {group_size}.") |
| 169 | + if weight.dim() != 2: |
| 170 | + raise ValueError(f"Expected a 2D weight tensor, got shape {tuple(weight.shape)}.") |
| 171 | + out_features, in_features = weight.shape |
| 172 | + if in_features % group_size != 0: |
| 173 | + raise ValueError(f"in_features ({in_features}) must be divisible by group_size ({group_size}) for k-quant.") |
| 174 | + |
| 175 | + orig_dtype = weight.dtype |
| 176 | + data = weight.detach().to(torch.float32).reshape(-1, group_size) |
| 177 | + |
| 178 | + sum_x2 = torch.sum(data * data, dim=1, keepdim=True) |
| 179 | + av_x = torch.sqrt(sum_x2 / group_size) |
| 180 | + weights = av_x + torch.abs(data) |
| 181 | + |
| 182 | + midq = (maxq + minq + 1) // 2 |
| 183 | + qrange = maxq - minq |
| 184 | + |
| 185 | + if symmetric: |
| 186 | + l_min, l_max = float(minq - midq), float(maxq - midq) |
| 187 | + normalizer = torch.max(torch.abs(data), dim=1, keepdim=True).values |
| 188 | + normalizer = torch.where(normalizer == 0, torch.ones_like(normalizer), normalizer) |
| 189 | + quant_offset = torch.zeros_like(normalizer) |
| 190 | + nmax = l_max |
| 191 | + candidate_factors = tuple(nmax + _SYM_STEP_DELTA * s for s in _SYM_STEPS) |
| 192 | + scale, _ = _kquant_search( |
| 193 | + data, |
| 194 | + weights, |
| 195 | + normalizer, |
| 196 | + quant_offset, |
| 197 | + l_min, |
| 198 | + l_max, |
| 199 | + initial_factor=nmax, |
| 200 | + candidate_factors=candidate_factors, |
| 201 | + refine_fn=_refine_symmetric, |
| 202 | + ) |
| 203 | + zero_point = torch.full_like(scale, float(midq)).to(torch.int32) |
| 204 | + else: |
| 205 | + l_min, l_max = float(minq), float(maxq) |
| 206 | + rmin = torch.minimum(torch.min(data, dim=1, keepdim=True).values, torch.zeros((), dtype=data.dtype)) |
| 207 | + rmax = torch.maximum(torch.max(data, dim=1, keepdim=True).values, torch.zeros((), dtype=data.dtype)) |
| 208 | + degenerate = (rmin == 0) & (rmax == 0) |
| 209 | + rmin = torch.where(degenerate, torch.full_like(rmin, -1.0), rmin) |
| 210 | + rmax = torch.where(degenerate, torch.full_like(rmax, 1.0), rmax) |
| 211 | + normalizer = rmax - rmin |
| 212 | + candidate_factors = tuple(_ASYM_RRMIN + _ASYM_RDELTA * s + qrange for s in range(_ASYM_NSTEP)) |
| 213 | + scale, offset = _kquant_search( |
| 214 | + data, |
| 215 | + weights, |
| 216 | + normalizer, |
| 217 | + rmin, |
| 218 | + l_min, |
| 219 | + l_max, |
| 220 | + initial_factor=float(qrange), |
| 221 | + candidate_factors=candidate_factors, |
| 222 | + refine_fn=_refine_asymmetric, |
| 223 | + ) |
| 224 | + zero_point = torch.clamp(torch.round(float(minq) - offset / scale), l_min, l_max).to(torch.int32) |
| 225 | + |
| 226 | + num_groups = in_features // group_size |
| 227 | + scales = scale.reshape(out_features, num_groups).to(orig_dtype).contiguous() |
| 228 | + zero_points = zero_point.reshape(out_features, num_groups).contiguous() |
| 229 | + return scales, zero_points |
| 230 | + |
| 231 | + |
| 232 | +class KQuant(Pass): |
| 233 | + """K-quant weight-only quantization (PyTorch-native). |
| 234 | +
|
| 235 | + Per-group weight quantization using the iterative weighted-least-squares |
| 236 | + search from llama.cpp's ggml k-quants. Supports both asymmetric (scale and |
| 237 | + zero point) and symmetric (scale only) variants for 2-, 4-, and 8-bit |
| 238 | + weights of ``nn.Linear`` and ``nn.Embedding`` modules. |
| 239 | + """ |
| 240 | + |
| 241 | + @classmethod |
| 242 | + def _default_config(cls, accelerator_spec: AcceleratorSpec) -> dict[str, PassConfigParam]: |
| 243 | + config = get_quantizer_config(allow_embeds=True) |
| 244 | + config["group_size"] = PassConfigParam( |
| 245 | + type_=int, |
| 246 | + default_value=32, |
| 247 | + description="Group size for k-quant quantization. Must be > 0. Default value is 32.", |
| 248 | + ) |
| 249 | + return config |
| 250 | + |
| 251 | + @classmethod |
| 252 | + def validate_config( |
| 253 | + cls, |
| 254 | + config: type[BasePassConfig], |
| 255 | + accelerator_spec: AcceleratorSpec, |
| 256 | + ) -> bool: |
| 257 | + if not super().validate_config(config, accelerator_spec): |
| 258 | + return False |
| 259 | + |
| 260 | + if config.group_size <= 0 and config.group_size != -1: |
| 261 | + logger.info("group_size must be -1 or greater than 0") |
| 262 | + return False |
| 263 | + |
| 264 | + return True |
| 265 | + |
| 266 | + @torch.no_grad() |
| 267 | + def _run_for_config( |
| 268 | + self, model: HfModelHandler, config: type[BasePassConfig], output_model_path: str |
| 269 | + ) -> HfModelHandler: |
| 270 | + """Run k-quant quantization on the model. |
| 271 | +
|
| 272 | + Args: |
| 273 | + model: The HuggingFace model to quantize. |
| 274 | + config: Configuration object containing quantization parameters. |
| 275 | + output_model_path: Path where the quantized model will be saved. |
| 276 | +
|
| 277 | + Returns: |
| 278 | + HfModelHandler for the quantized model. |
| 279 | +
|
| 280 | + """ |
| 281 | + wrapper, qcfg, retie_word_embeddings = prepare_model(model, config, allow_quantized=True) |
| 282 | + device = "cuda" if torch.cuda.is_available() else "cpu" |
| 283 | + |
| 284 | + from tqdm.auto import tqdm |
| 285 | + |
| 286 | + modules = [(name, m) for name, m in wrapper.model.named_modules() if hasattr(m, "quant_info")] |
| 287 | + pbar = tqdm(modules, desc="Quantizing modules") |
| 288 | + for name, module in pbar: |
| 289 | + pbar.set_postfix(module=name, refresh=False) |
| 290 | + quantizer = module.quant_info.quantizer |
| 291 | + |
| 292 | + weight = module.weight.data.to(device) |
| 293 | + effective_group_size = quantizer.group_size if quantizer.group_size > 0 else weight.shape[1] |
| 294 | + scales, zero_points = kquant_find_qparams( |
| 295 | + weight, |
| 296 | + group_size=effective_group_size, |
| 297 | + maxq=quantizer.maxq, |
| 298 | + minq=quantizer.minq, |
| 299 | + symmetric=quantizer.symmetric, |
| 300 | + ) |
| 301 | + module.quant_info.scales = scales.to("cpu") |
| 302 | + module.quant_info.zero_points = zero_points.to("cpu") |
| 303 | + |
| 304 | + if torch.cuda.is_available(): |
| 305 | + torch.cuda.empty_cache() |
| 306 | + pbar.close() |
| 307 | + |
| 308 | + return finalize(model, output_model_path, wrapper, qcfg, device, retie_word_embeddings=retie_word_embeddings) |
0 commit comments