Skip to content

Commit cd47ebb

Browse files
jambaykCopilot
andauthored
Add PyTorch-native K-quant pass (microsoft#2479)
## Describe your changes Add `KQuant` pass under `olive.passes.pytorch.kquant` that implements the ggml K-quant weight-only search in PyTorch. A unified `_kquant_search` driver covers both variants: - Asymmetric (analogue of ggml's `make_qkx2_quants` used by Q2_K/Q4_K/Q5_K): tracks per-group `(min, max)`, refines `(scale, offset)` via 2-D LSQ over a sweep of perturbed `iscale` factors. - Symmetric (analogue of ggml's `make_qx_quants` used by Q3_K/Q6_K): uses `max|x|` as normalizer, zero point fixed at `midq`, refines scale via 1-D LSQ `sumlx/suml2`. Outputs `(scales, zero_points)` shaped to match `WeightQuantizer.find_qparams`, with the search parameterized by `(maxq, minq)` pulled directly from each module's `WeightQuantizer` (via `get_maxq_minq`) so the algorithm and `finalize` agree on the integer range. Uses the shared `prepare_model`/`finalize` plumbing so embeddings and `lm_head` can be quantized and retied like the other PyTorch quant passes (RTN, GPTQ). ## Checklist before requesting a review - [x] Add unit tests for this change. - [x] Make sure all tests can pass. - [ ] Update documents if necessary. - [x] Lint and apply fixes to your code by running `lintrunner -a` - [x] Is this a user-facing change? If yes, give a description of this change to be included in the release notes. Release note: New `KQuant` PyTorch pass for weight-only K-quant quantization (asymmetric + symmetric, 2/4/8-bit). `Rtn` and `KQuant` now also advertise `uint2`/`int2` precisions. ## (Optional) Issue link --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1738550 commit cd47ebb

3 files changed

Lines changed: 426 additions & 0 deletions

File tree

olive/olive_config.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,14 @@
600600
"run_on_target": true,
601601
"extra_dependencies": [ "amd-quark" ]
602602
},
603+
"KQuant": {
604+
"module_path": "olive.passes.pytorch.kquant.KQuant",
605+
"supported_providers": [ "*" ],
606+
"supported_accelerators": [ "*" ],
607+
"supported_precisions": [ "int4", "int8", "uint4", "uint8" ],
608+
"supported_algorithms": [ "kquant" ],
609+
"supported_quantization_encodings": [ ]
610+
},
603611
"QuaRot": {
604612
"module_path": "olive.passes.pytorch.rotate.QuaRot",
605613
"supported_providers": [ "*" ],

olive/passes/pytorch/kquant.py

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
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

Comments
 (0)