Skip to content

Commit 61ea196

Browse files
anzr299ljaljushkinCopilot
authored
Support 3D Weights in GPTQ Algorithm (#3835)
### Changes The approach here is quite straightfoward, `_quantize_weights` works as usual for 2D weights. The difference is in calculate hessian where the hessian is 3D in both 3D and 2D weights case. By default hessian has the shape (1, hidden_dim, hidden_dim). Before this was just (hidden_dim, hidden_dim). For 3D, it is (num_experts/batch, hidden_dim, hidden_dim). Now, this 3D hessian or "batched" hessian is looped over and the 2D weight is extracted and passed to the old `_quantize_weights` function as usual and scale/zp are returned. These scales and zp are then stacked together in a collector variable. For 2D case, it is flattened. For 3D the stacked scale, zp are returned. NOTE: Scale Estimation + GPTQ support is not added for 3D weights yet ### Reason for changes Support 3D weights for models like MoE in GPTQ ### Related tickets 175789 & 175212 ### Tests Model: Qwen/Qwen3-30B-A3B NNCF Backend: OpenVINO Higher is better. Task: gsm8k Limit: 100 Max New Tokens: 10000 OpenVINO version: 2026.0.0.dev20251111 (with WA for 176465) n-shots: 5(default) Precision Type | Filter | Value -- | -- | -- INT4 SYM GS128 (with GPTQ) Calibrated on GSM8k with 128 samples | flexible-extract | 0.79   | strict-match | 0.64 INT4 SYM GS128 (with GPTQ after bug fix commit dc355fe) Calibrated on GSM8k with 128 samples | flexible-extract | 0.78   | strict-match | 0.57 INT4 SYM GS128 | flexible-extract | 0.55   | strict-match | 0.29 FP32 | flexible-extract | 0.92   | strict-match | 0.82 --------- Co-authored-by: Lyalyushkin Nikolay <nikolay.lyalyushkin@intel.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 4b3eed1 commit 61ea196

2 files changed

Lines changed: 163 additions & 50 deletions

File tree

  • src/nncf/quantization/algorithms/weight_compression
  • tests/openvino/native/quantization

src/nncf/quantization/algorithms/weight_compression/gptq.py

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
# limitations under the License.
1111

1212
import math
13+
from functools import reduce
14+
from operator import mul
1315
from typing import Optional, TypeVar
1416

1517
import nncf
@@ -130,8 +132,39 @@ def apply(
130132
raise nncf.UnsupportedModelError(msg)
131133

132134
_, input_tensors = next(iter(inputs.items()))
133-
hessian = self._calculate_hessian(node, input_tensors)
134-
scale, zero_point = self._quantize_weights(model, graph, wc_params, hessian, input_tensors)
135+
weight_tensor = self._backend_entity.get_weight(
136+
wc_params.node_with_weight, wc_params.weight_port_id, model, graph
137+
)
138+
weight_tensor = fns.astype(weight_tensor, TensorDataType.float32)
139+
140+
is_3d_weight = len(weight_tensor.shape) == 3
141+
142+
node = wc_params.node_with_weight
143+
hessian = self._calculate_hessian(node, input_tensors, is_3d_weight)
144+
weight_tensor = fns.unsqueeze(weight_tensor, 0) if not is_3d_weight else weight_tensor
145+
scales = []
146+
zero_points = []
147+
weights = []
148+
for batch_idx in range(hessian.shape[0]):
149+
batch_hessian = hessian[batch_idx]
150+
batch_weight = weight_tensor[batch_idx]
151+
reduction_axes = wc_params.reduction_axes
152+
assert len(reduction_axes) == 1, "2D reduction axes is not currently supported in GPTQ"
153+
wc_params.reduction_axes = (reduction_axes[0] - 1,) if is_3d_weight else reduction_axes
154+
# Input tensors is a List of tensors with shape [batch_size, seq_len, hidden_dim] for 3D weights case
155+
# So we need to prepare the list by selecting only the current batch inputs only
156+
input_tensor = [inp[batch_idx] for inp in input_tensors] if is_3d_weight else input_tensors
157+
batch_quantized_weight, batch_scale, batch_zero_point = self._quantize_weights(
158+
wc_params, batch_hessian, batch_weight, input_tensor
159+
)
160+
wc_params.reduction_axes = reduction_axes
161+
weights.append(batch_quantized_weight)
162+
scales.append(batch_scale)
163+
zero_points.append(batch_zero_point)
164+
scale = fns.stack(scales, axis=0) if is_3d_weight else scales[0]
165+
zero_point = fns.stack(zero_points, axis=0) if is_3d_weight and None not in zero_points else zero_points[0]
166+
weight = fns.stack(weights, axis=0) if is_3d_weight else weights[0]
167+
self._backend_entity.set_weight(wc_params.node_with_weight, wc_params.weight_port_id, model, graph, weight)
135168
res[wc_params.weight_name] = CompressedWeight(None, scale, zero_point, None)
136169

137170
return model, res
@@ -163,7 +196,7 @@ def get_statistic_points(
163196

164197
return self._layerwise_engine.get_statistic_points(model, graph, filtered_nodes)
165198

166-
def _calculate_hessian(self, node: NNCFNode, inputs: list[Tensor]) -> Tensor:
199+
def _calculate_hessian(self, node: NNCFNode, inputs: list[Tensor], is_3d_weight: bool = False) -> Tensor:
167200
"""
168201
Calculates the Hessian matrix for the given node and inputs.
169202
@@ -179,30 +212,39 @@ def _calculate_hessian(self, node: NNCFNode, inputs: list[Tensor]) -> Tensor:
179212
if node.layer_attributes.input_attributes["transpose"]:
180213
msg = "Transposed input is not supported"
181214
raise nncf.UnsupportedModelError(msg)
182-
215+
# Make hessian 3D. Such that for 2D weights it is only 1 batch and can be squeezed later.
216+
# For 3D weights this dimension matches the weights dimensions
217+
hessian_batch = 1 if not is_3d_weight else reduce(mul, inputs[0].shape[:-2])
183218
hessian = fns.zeros(
184-
(inputs[0].shape[-1], inputs[0].shape[-1]), backend=inputs[0].backend, dtype=TensorDataType.float32
219+
(hessian_batch, inputs[0].shape[-1], inputs[0].shape[-1]),
220+
backend=inputs[0].backend,
221+
dtype=TensorDataType.float32,
185222
)
186223

187224
for inp in inputs:
188-
batch_size = 1 if len(inp.shape) == 2 else inp.shape[0]
225+
is_3d_act = len(inp.shape) == 3
226+
# For 3D weights case, batch size will always be 1. Each "batch"/expert of the activation is treated as
227+
# single 2D matmuls
228+
batch_size = 1 if is_3d_weight or not is_3d_act else inp.shape[0]
189229
if node.metatype in self._backend_entity.matmul_metatypes:
190-
if len(inp.shape) == 3:
230+
# For 3D act + 2D weight case we should reshape activation to 2D to match weight
231+
# For 3D act + 3D weight it should remain in 3D and the last 2 dimensions should be activation per
232+
# batch/0-th dimension
233+
if is_3d_act and not is_3d_weight:
191234
inp = inp.reshape((-1, inp.shape[-1]))
192-
inp = fns.transpose(inp)
235+
inp = fns.moveaxis(inp, -1, -2)
193236
hessian *= nsamples / (nsamples + batch_size)
194237
nsamples += batch_size
195238
inp = fns.astype(inp, TensorDataType.float32) * math.sqrt(2 / nsamples)
196-
hessian += fns.matmul(inp, fns.transpose(inp))
239+
hessian += fns.matmul(inp, fns.moveaxis(inp, -1, -2))
197240

198241
return hessian
199242

200243
def _quantize_weights(
201244
self,
202-
model: TModel,
203-
graph: NNCFGraph,
204245
wc_params: WeightCompressionParameters,
205246
hessian: Tensor,
247+
weight_tensor: Tensor,
206248
inputs: list[Tensor],
207249
):
208250
"""
@@ -221,10 +263,7 @@ def _quantize_weights(
221263
msg = "Transpose is not supported"
222264
raise RuntimeError(msg)
223265

224-
weight_tensor = self._backend_entity.get_weight(
225-
wc_params.node_with_weight, wc_params.weight_port_id, model, graph
226-
)
227-
weight_tensor = fns.astype(weight_tensor, TensorDataType.float32)
266+
assert len(hessian.shape) == 2, "Hessian should be 2D"
228267

229268
dead_indices = fns.diag(hessian) == 0
230269
hessian[dead_indices, dead_indices] = 1
@@ -323,9 +362,6 @@ def _quantize_weights(
323362
weight_tensor[:, i2:] -= fns.matmul(error_block, hessian_inv[i1:i2, i2:])
324363

325364
quantized_tensor = quantized_tensor.reshape(weight_tensor.shape).astype(weight_tensor.dtype)
326-
self._backend_entity.set_weight(
327-
wc_params.node_with_weight, wc_params.weight_port_id, model, graph, quantized_tensor
328-
)
329365

330366
scales = fns.stack(scales, axis=1)
331367
if wc_params.compression_config.group_size == -1:
@@ -339,4 +375,4 @@ def _quantize_weights(
339375
zero_points = fns.squeeze(zero_points, axis=-1)
340376
else:
341377
zero_points = None
342-
return scales, zero_points
378+
return quantized_tensor, scales, zero_points

tests/openvino/native/quantization/test_gptq.py

Lines changed: 108 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616

1717
import numpy as np
1818
import openvino as ov
19+
import pytest
1920
import torch
2021

22+
from nncf import Dataset
2123
from nncf.common.factory import build_graph
2224
from nncf.parameters import CompressWeightsMode
2325
from nncf.quantization.algorithms.weight_compression.config import WeightCompressionConfig
@@ -323,53 +325,128 @@ def fasterquant(
323325
return scale, zero, g_idx
324326

325327

326-
def test_calculate_scale_linear():
327-
# generate inputs
328+
class Linear3DModel(torch.nn.Module):
329+
def __init__(self, weight_3d: np.ndarray):
330+
super().__init__()
331+
w = torch.from_numpy(weight_3d)
332+
self.weight = torch.nn.Parameter(w)
333+
334+
def forward(self, x):
335+
# OV expects transposed constant when applying GPTQ
336+
return torch.matmul(x, self.weight.transpose(1, 2))
337+
338+
339+
def _create_ov_model(weights: np.ndarray, input_shape: tuple, is_3d_weights: bool = False):
340+
import openvino.runtime.opset13 as opset
341+
342+
param = opset.parameter(input_shape, dtype=np.float32, name="input")
343+
const = opset.constant(weights, dtype=np.float32, name="self.weight")
344+
matmul = opset.matmul(param, const, transpose_a=False, transpose_b=True)
345+
result = opset.result(matmul, name="output")
346+
return ov.Model([result], [param])
347+
348+
349+
def _make_nncf_dataset(ov_model, inputs: list[np.ndarray]) -> Dataset:
350+
input_name = ov_model.inputs[0].get_any_name()
351+
items = [{input_name: inp} for inp in inputs]
352+
return Dataset(items, lambda x: x)
353+
354+
355+
@pytest.mark.parametrize("is_3d_weights", [False, True], ids=["2D_weights", "3D_weights"])
356+
def test_calculate_scale_linear(is_3d_weights: bool):
328357
np.random.seed(0)
329-
inputs = [np.random.rand(128, 32).astype(np.float32) for _ in range(10)]
330-
weights = np.random.rand(20, 32).astype(np.float32)
331358

332-
# calculate reference
333-
with torch.no_grad():
334-
layer = torch.nn.Linear(32, 20)
335-
layer.weight.copy_(torch.from_numpy(weights))
359+
hidden_dims = 32
360+
out_dims = 20
361+
group_size = 16
362+
n_inputs = 10
363+
batch_size = 4
336364

337-
ref_gptq = GPTQReference(layer)
338-
for inp in inputs:
339-
ref_gptq.add_batch(torch.from_numpy(inp))
365+
inputs = [np.random.rand(batch_size, 128, hidden_dims).astype(np.float32) for _ in range(n_inputs)]
366+
weights = np.random.rand(batch_size, out_dims, hidden_dims).astype(np.float32)
340367

341-
ref_scale, _, _ = ref_gptq.fasterquant(percdamp=0.1, group_size=16)
368+
# Select only first batch for 2D case and make it 2D
369+
inputs = inputs if is_3d_weights else [activation[0] for activation in inputs]
370+
weights = weights if is_3d_weights else weights[0]
342371

343-
# convert PyTorch model to OpenVINO
344-
ov_model = ov.convert_model(layer, example_input=inputs[0])
372+
# Step 1: We apply a reference GPTQ implementation on the Pytorch model. This gives us a ground truth of scales and
373+
# Quantized values
374+
with torch.no_grad():
375+
weight = weights if is_3d_weights else np.expand_dims(weights, axis=0)
376+
# Inputs for 3D is a list of shape [n_inputs, batch_size, 128, in_dim]
377+
# For 2D it is [n_inputs, 1, 128, in_dim]. We unsqueeze at 0 for every input tensor
378+
batched_inputs = inputs if is_3d_weights else [np.expand_dims(x, axis=0) for x in inputs]
379+
380+
pt_model = Linear3DModel(weights) if is_3d_weights else torch.nn.Linear(hidden_dims, out_dims, bias=False)
381+
if not is_3d_weights:
382+
pt_model.weight.copy_(torch.from_numpy(weights))
383+
384+
ref_gptqs, ref_scales = [], []
385+
for batch in range(weight.shape[0]):
386+
layer = torch.nn.Linear(hidden_dims, out_dims, bias=False)
387+
layer.weight.copy_(torch.from_numpy(weight[batch]))
388+
389+
ref_gptq = GPTQReference(layer)
390+
for inp in batched_inputs:
391+
ref_gptq.add_batch(torch.from_numpy(inp[batch]))
392+
393+
ref_scale_for_batch, _, _ = ref_gptq.fasterquant(percdamp=0.1, group_size=group_size)
394+
ref_gptqs.append(ref_gptq)
395+
ref_scales.append(ref_scale_for_batch)
396+
397+
ref_scale = np.stack([s.detach().cpu().numpy() for s in ref_scales], axis=0)
398+
399+
# Step 2: Create OV models so that we can use nncf to compress this with our own GPTQ
400+
# We do not use ov.convert_model() here since we expect a specific transposed weight and
401+
# not transposed activation. It is hard to create and convert such a model from PT -> OV
402+
# due to things like constant folding etc. which are automatically performed or generally
403+
# hard to translate.
404+
ov_model = _create_ov_model(weights, inputs[0].shape, is_3d_weights)
345405
graph = build_graph(ov_model)
346406

347-
# GPTQ
407+
# Step 3: Setup and apply GPTQ as usual
348408
gptq = GPTQ()
349409
gptq._set_backend_entity(ov_model)
350410

351-
nodes = graph.get_all_nodes()
411+
node_with_weight = graph.get_all_nodes()[1]
412+
352413
wrapped_inputs = [Tensor(inp) for inp in inputs]
353-
H = gptq._calculate_hessian(nodes[1], wrapped_inputs)
414+
H = gptq._calculate_hessian(node_with_weight, wrapped_inputs, is_3d_weight=is_3d_weights)
354415

355-
ref_H = ref_gptq.H.numpy()
356-
assert np.all(np.isclose(ref_H, H.data))
416+
reference_gptq_list = ref_gptqs if is_3d_weights else [ref_gptq]
417+
nncf_hessian_list = H.data if is_3d_weights else np.expand_dims(H.data, axis=0)
357418

358-
wc_params = WeightCompressionParameters(
419+
for batch_index, (reference_gptq, nncf_hessian) in enumerate(zip(reference_gptq_list, nncf_hessian_list)):
420+
reference_hessian = reference_gptq.H.detach().numpy()
421+
assert np.all(np.isclose(reference_hessian, nncf_hessian)), f"Hessian mismatch for batch {batch_index}"
422+
423+
nncf_dataset = _make_nncf_dataset(ov_model, inputs)
424+
reduction_axes = (1,) if not is_3d_weights else (2,)
425+
wc_param = WeightCompressionParameters(
359426
weight_name="self.weight",
360-
node_with_weight=nodes[1],
427+
node_with_weight=node_with_weight,
361428
weight_port_id=1,
362429
weight_dtype=TensorDataType.float32,
363430
weight_shape=weights.shape,
364-
reduction_axes=(1,),
431+
reduction_axes=reduction_axes,
432+
)
433+
wc_param.compression_config = WeightCompressionConfig(mode=CompressWeightsMode.INT4_SYM, group_size=group_size)
434+
wc_params = [wc_param]
435+
436+
_, res = gptq.apply(ov_model, graph, nncf_dataset, wc_params)
437+
438+
# Step 4: Obtain the scales from our GPTQ implementation and compare with reference
439+
scale_from_nncf = res.get("self.weight").scale.data
440+
ref_scale = ref_scale.numpy() if isinstance(ref_scale, torch.Tensor) else ref_scale
441+
ref_scale = ref_scale.reshape(scale_from_nncf.shape)
442+
443+
assert np.all(np.isclose(ref_scale, scale_from_nncf))
444+
# Here we obtain weights from the model instead of apply directly so that we also check
445+
# if the weight is changed in the OV model.
446+
ov_weight = gptq._backend_entity.get_weight(node_with_weight, 1, ov_model, graph)
447+
ref_weights = np.stack(
448+
[ref_gptq.layer.weight.detach().numpy() for ref_gptq in ref_gptqs],
449+
axis=0,
365450
)
366-
wc_params.compression_config = WeightCompressionConfig(mode=CompressWeightsMode.INT4_SYM, group_size=16)
367-
368-
scale, _ = gptq._quantize_weights(ov_model, graph, wc_params, H, wrapped_inputs)
369-
ref_scale = ref_scale.numpy()
370-
scale = scale.reshape(ref_scale.shape)
371-
assert np.all(np.isclose(ref_scale, scale.data))
372451

373-
torch_weight = layer.weight.detach().numpy()
374-
ov_weight = gptq._backend_entity.get_weight(nodes[1], 1, ov_model, graph)
375-
assert np.all(np.isclose(torch_weight, ov_weight.data))
452+
assert np.all(np.isclose(ref_weights, ov_weight.data))

0 commit comments

Comments
 (0)