From 2cb837e7aadb0b66c022b86751ba08a38af297e3 Mon Sep 17 00:00:00 2001 From: "Shen, Liang" Date: Fri, 7 Aug 2026 00:58:04 +0800 Subject: [PATCH 1/3] Add INT2_ASYM weight compression mode CompressWeightsMode stopped at INT2_SYM, so 2-bit asymmetric compression was not expressible. At 2 bits there are only four levels, and a symmetric grid spends one of them on a sign that one-sided weight groups never use. INT2_ASYM reuses the existing generic asymmetric path: is_asym_mode selects level_low=0 / level_high=2**num_bits-1 with both scale and zero point derived from min/max, which is already generic in num_bits. compression_dtype maps to TensorDataType.int2 because OpenVINO has no i2 type -- int2 weights are physically unsigned, which is also why the symmetric path has to shift by +2**(num_bits-1). Asymmetric codes land in [0, 3] natively, so the weight constant keeps its dtype and the zero point is emitted as u2 as well. Co-Authored-By: Claude Opus 5 (1M context) --- src/nncf/parameters.py | 5 +++++ .../algorithms/weight_compression/config.py | 14 +++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/nncf/parameters.py b/src/nncf/parameters.py index b6d10871b1f..023861ee606 100644 --- a/src/nncf/parameters.py +++ b/src/nncf/parameters.py @@ -92,6 +92,10 @@ class CompressWeightsMode(StrEnum): Weights are quantized to a primary precision symmetrically without zero point. :param INT2_SYM: Stands for a mixed-precision weights quantization with 2-bit integer as a primary precision. Weights are quantized to a primary precision symmetrically without zero point. + :param INT2_ASYM: The same as INT2_SYM mode, but weights are quantized to a primary precision + asymmetrically with a typical non-fixed zero point. At 2 bits the symmetric grid + (-2, -1, 0, 1) spends a level on an unused sign for one-sided groups, so the asymmetric + variant is worth having despite the extra per-group zero point. :param NF4: The the same as INT4_SYM mode, but primary precision is NF4 data type without zero point. :param MXFP4: MX-compliant FP4 format with E2M1 values sharing group-level E8M0 scale. The size of group is 32. :param MXFP8_E4M3: MX-compliant FP8 format with E4M3 values sharing group-level E8M0 scale. The size of group is 32. @@ -110,6 +114,7 @@ class CompressWeightsMode(StrEnum): INT4_ASYM = "int4_asym" INT3_SYM = "int3_sym" INT2_SYM = "int2_sym" + INT2_ASYM = "int2_asym" NF4 = "nf4" CB4 = "cb4" MXFP4 = "mxfp4" diff --git a/src/nncf/quantization/algorithms/weight_compression/config.py b/src/nncf/quantization/algorithms/weight_compression/config.py index 5e99da33329..f464db0df90 100644 --- a/src/nncf/quantization/algorithms/weight_compression/config.py +++ b/src/nncf/quantization/algorithms/weight_compression/config.py @@ -74,6 +74,7 @@ def num_bits(self) -> int: CompressWeightsMode.MXFP8_E4M3: 8, CompressWeightsMode.INT3_SYM: 3, CompressWeightsMode.INT2_SYM: 2, + CompressWeightsMode.INT2_ASYM: 2, } try: @@ -84,7 +85,11 @@ def num_bits(self) -> int: @property def is_asym_mode(self) -> bool: - return self.mode in [CompressWeightsMode.INT4_ASYM, CompressWeightsMode.INT8_ASYM] + return self.mode in [ + CompressWeightsMode.INT2_ASYM, + CompressWeightsMode.INT4_ASYM, + CompressWeightsMode.INT8_ASYM, + ] @property def is_integer(self) -> bool: @@ -130,7 +135,14 @@ def compression_dtype(self) -> TensorDataType: return TensorDataType.uint8 return TensorDataType.uint16 dtype_per_mode = { + # OpenVINO has no i2/i3 element type, so TensorDataType.int2 is physically stored as + # ov.Type.u2 (see DTYPE_MAP in tensor/functions/openvino_numeric.py). That is why the + # symmetric path shifts codes by +2**(num_bits-1) and subtracts a scalar zero point, + # and it is also why INT2_ASYM maps to the same int2 dtype rather than to a new uint2: + # asymmetric codes already land in [0, 2**num_bits - 1], so only the zero point + # differs -- per-group and u2-packed instead of a folded scalar. CompressWeightsMode.INT2_SYM: TensorDataType.int2, + CompressWeightsMode.INT2_ASYM: TensorDataType.int2, CompressWeightsMode.INT3_SYM: TensorDataType.int3, CompressWeightsMode.INT4_SYM: TensorDataType.int4, CompressWeightsMode.INT4_ASYM: TensorDataType.uint4, From 22556179134669023d3b6a63f089304b2b07edbf Mon Sep 17 00:00:00 2001 From: "Shen, Liang" Date: Mon, 10 Aug 2026 16:20:12 +0800 Subject: [PATCH 2/3] Include INT2_ASYM in the LoRA correction and GPTQ mode dispatches Two mode allow-lists predate INT2_ASYM and did not pick it up: - lora_correction.py listed the integer modes explicitly, so INT2_ASYM fell through to `raise InternalError` even though do_integer_dequantization is generic over them (INT4_ASYM already used it). The error message had also gone stale and omitted INT3_SYM/INT2_SYM; it now lists what the branch above it actually accepts. - gptq.py collected zero points only for [INT8_ASYM, INT4_ASYM], so INT2_ASYM took the `else` branch and had its zero points silently set to None -- an asymmetric mode dequantized as if symmetric, with no error. Replaced with `is_asym_mode`, which is the property that defines "has a zero point" and is the idiom used elsewhere (weight_lowering.py, optimized_functions/models.py), so the list cannot go stale again. Reachable in both cases: quantize_model.py only blocks the float modes for the AWQ/scale-estimation/GPTQ/LoRA group on the OpenVINO backend, so `mode=INT2_ASYM` with either algorithm reaches these dispatches. Co-Authored-By: Claude Opus 5 (1M context) --- .../quantization/algorithms/weight_compression/gptq.py | 8 ++++---- .../algorithms/weight_compression/lora_correction.py | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/nncf/quantization/algorithms/weight_compression/gptq.py b/src/nncf/quantization/algorithms/weight_compression/gptq.py index 4c56550723e..0aecbc9c4b4 100644 --- a/src/nncf/quantization/algorithms/weight_compression/gptq.py +++ b/src/nncf/quantization/algorithms/weight_compression/gptq.py @@ -367,10 +367,10 @@ def _quantize_weights( scales = fns.stack(scales, axis=1) if wc_params.compression_config.group_size == -1: scales = fns.squeeze(scales, axis=-1) - if wc_params.compression_config.mode in [ - CompressWeightsMode.INT8_ASYM, - CompressWeightsMode.INT4_ASYM, - ]: + # is_asym_mode rather than an explicit mode list: a zero point exists for exactly the + # asymmetric modes, and the list this replaces had already gone stale once (it predates + # INT2_ASYM, which would have silently fallen through to zero_points = None). + if wc_params.compression_config.is_asym_mode: zero_points = fns.stack(zero_points, axis=1) if wc_params.compression_config.group_size == -1: zero_points = fns.squeeze(zero_points, axis=-1) diff --git a/src/nncf/quantization/algorithms/weight_compression/lora_correction.py b/src/nncf/quantization/algorithms/weight_compression/lora_correction.py index b06a603fcda..7f6db5f2cc2 100644 --- a/src/nncf/quantization/algorithms/weight_compression/lora_correction.py +++ b/src/nncf/quantization/algorithms/weight_compression/lora_correction.py @@ -174,6 +174,7 @@ def calculate_low_rank_matrices( CompressWeightsMode.INT4_ASYM, CompressWeightsMode.INT3_SYM, CompressWeightsMode.INT2_SYM, + CompressWeightsMode.INT2_ASYM, ): fq_weights = do_integer_dequantization( compressed_weight, @@ -183,7 +184,8 @@ def calculate_low_rank_matrices( fq_weights = do_float_dequantization(compressed_weight, reduction_axis) else: msg = ( - f"{mode.value} mode is invalid for Lora Correction algorithm. Supported modes: INT4_SYM, INT4_ASYM, NF4" + f"{mode.value} mode is invalid for Lora Correction algorithm. " + "Supported modes: INT4_SYM, INT4_ASYM, INT3_SYM, INT2_SYM, INT2_ASYM, NF4" ) raise nncf.InternalError(msg) # fq_w + residual = w => residual = w - fq_w From 1d36db8359661ce8930016f9702493ec386b1025 Mon Sep 17 00:00:00 2001 From: "Shen, Liang" Date: Mon, 10 Aug 2026 16:20:31 +0800 Subject: [PATCH 3/3] Add INT2_ASYM weight compression tests Covers what was previously untested for the new mode: - test_int_asym_compressed_weights_range: codes fill [0, 2**num_bits - 1] and the zero point is actually used. Parametrized over all three asymmetric modes so INT2_ASYM is checked to behave like its siblings rather than in isolation. The quantization range is clamped to include zero, so one-sided data is what exercises the zero point: all-positive gives zp == 0, all-negative gives zp == level_high. - test_int2_asym_group_wise_zero_point_shape: a per-group zero point exists and has the same shape as the scale, which INT2_SYM has no equivalent of. - test_int2_asym_config: num_bits, is_asym_mode and the int2 (physically u2) compression dtype. - test_int_quantization_with_precomputed_parameters: INT2_ASYM added to the existing parametrization, including the two cases where supplying only one of scale/zero point must raise. Co-Authored-By: Claude Opus 5 (1M context) --- .../quantization/test_weights_compression.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/openvino/native/quantization/test_weights_compression.py b/tests/openvino/native/quantization/test_weights_compression.py index 3d363a4015a..2677c8eb457 100644 --- a/tests/openvino/native/quantization/test_weights_compression.py +++ b/tests/openvino/native/quantization/test_weights_compression.py @@ -1512,6 +1512,64 @@ def test_int_compressed_weighs_range(mode, data): assert np.allclose(np.abs(compressed_weight.tensor.data), np.abs(w.data)) +@pytest.mark.parametrize( + ("mode", "num_bits"), + ( + (CompressWeightsMode.INT2_ASYM, 2), + (CompressWeightsMode.INT4_ASYM, 4), + (CompressWeightsMode.INT8_ASYM, 8), + ), +) +@pytest.mark.parametrize("sign", (1.0, -1.0), ids=("positive", "negative")) +def test_int_asym_compressed_weights_range(mode, num_bits, sign): + # Asymmetric codes are unsigned and span [0, 2**num_bits - 1]. The quantization range is + # clamped to always include zero (calculate_integer_quantization_params), so one-sided data + # is what actually exercises the zero point: all-positive gives zp == 0, all-negative gives + # zp == level_high. Both must still use the full code range. + # The data starts at 0 so that the lowest code is 0 at every bit width: with a range clamped + # to [0, 4] a smallest value of, say, 0.5 still rounds to 0 on the coarse 2-bit grid but not + # on the 8-bit one. + level_high = 2**num_bits - 1 + data = (sign * np.linspace(0.0, 4.0, 16)).astype(np.float32) + w = Tensor(data) + + config = WeightCompressionConfig(mode=mode) + compressed_weight = do_integer_quantization(w, config, -1) + + codes = compressed_weight.tensor.data + assert codes.min() == 0 + assert codes.max() == level_high + + zero_point = compressed_weight.zero_point + assert zero_point is not None + assert np.all(zero_point.data == (0 if sign > 0 else level_high)) + + +def test_int2_asym_group_wise_zero_point_shape(): + # A per-group asymmetric zero point must have one entry per (channel, group), matching the + # scale -- INT2_SYM folds its zero point away, so this is specific to the asymmetric mode. + group_size = 4 + w = Tensor(np.linspace(-1.0, 1.0, 2 * 16).astype(np.float32).reshape(2, 16)) + + config = WeightCompressionConfig(mode=CompressWeightsMode.INT2_ASYM, group_size=group_size) + compressed_weight = do_integer_quantization(w, config, reduction_axes=(1,)) + + assert compressed_weight.zero_point is not None + assert compressed_weight.zero_point.shape == compressed_weight.scale.shape + assert compressed_weight.zero_point.shape == (2, 16 // group_size, 1) + + +def test_int2_asym_config(): + config = WeightCompressionConfig(mode=CompressWeightsMode.INT2_ASYM) + + assert config.num_bits == 2 + assert config.is_asym_mode + # OpenVINO has no i2 type, so int2 is the physically-unsigned u2 storage that INT2_SYM also + # maps to; only the zero point differs between the two modes. + assert config.compression_dtype == TensorDataType.int2 + assert not config.is_symmetric_represented_by_unsigned + + FP4_REF = { "neg": [ -8.0, @@ -1667,6 +1725,10 @@ def test_codebook_weights_range(data): (WeightCompressionConfig(CompressWeightsMode.INT3_SYM), False, False, False), (WeightCompressionConfig(CompressWeightsMode.INT2_SYM), True, False, False), (WeightCompressionConfig(CompressWeightsMode.INT2_SYM), False, False, False), + (WeightCompressionConfig(CompressWeightsMode.INT2_ASYM), False, False, False), + (WeightCompressionConfig(CompressWeightsMode.INT2_ASYM), True, True, False), + (WeightCompressionConfig(CompressWeightsMode.INT2_ASYM), True, False, True), + (WeightCompressionConfig(CompressWeightsMode.INT2_ASYM), False, True, True), ], ) def test_int_quantization_with_precomputed_parameters(config, precompute_scale, precompute_zero_point, raises):