Skip to content

Commit 0ebd8cc

Browse files
authored
fix: auto-upgrade model opset to 21 for int16/uint16 QDQ quantization (microsoft#28202)
## Summary - Extends existing `update_opset_version` helper to auto-bump opset from < 21 to 21 when QUInt16/QInt16 weight quantization is requested - Mirrored after the existing float8 quantization opset upgrade pattern - Adds test coverage with parametric subtests for 16-bit int quantization ## Motivation Fixes microsoft#25223. Users exporting models from `torch.export` with uint16/int16 quantization hit a gap where models below opset 21 were not being upgraded. Mirroring the existing float8 branch gives users a consistent, predictable upgrade path for 16-bit QDQ. ## Changes - `onnxruntime/python/tools/quantization/quant_utils.py`: new `elif` branch in `update_opset_version` that bumps opset to 21 when `weight_quant_type` is `INT16` / `UINT16` and current opset is < 21. Emits a warning matching the existing float8 branch style. - `onnxruntime/test/python/quantization/test_quant_util.py`: new `test_update_opset_version_16bit` with parametric subtests covering `QUInt16` / `QInt16` bumping from opset 20 → 21 and a no-op regression check for models already at opset 21. ## Test Plan ``` python -m pytest onnxruntime/test/python/quantization/test_quant_util.py -v ``` All tests pass. `lintrunner -a` produces no changes.
1 parent 4d9aa47 commit 0ebd8cc

5 files changed

Lines changed: 340 additions & 24 deletions

File tree

onnxruntime/python/tools/quantization/quant_utils.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1017,10 +1017,45 @@ def get_opset_version(model: ModelProto) -> int:
10171017
return opset_version
10181018

10191019

1020-
def update_opset_version(model: ModelProto, weight_type: QuantType) -> ModelProto:
1020+
def update_opset_version(
1021+
model: ModelProto,
1022+
weight_type: QuantType,
1023+
activation_type: QuantType | None = None,
1024+
tensor_quant_overrides: dict | None = None,
1025+
) -> ModelProto:
10211026
opset_version = get_opset_version(model)
10221027
target_opset_version = opset_version
10231028
weight_quant_type = getattr(weight_type, "tensor_type", weight_type)
1029+
activation_quant_type = (
1030+
getattr(activation_type, "tensor_type", activation_type) if activation_type is not None else None
1031+
)
1032+
1033+
_int16_types = (onnx.TensorProto.UINT16, onnx.TensorProto.INT16)
1034+
needs_opset21_for_16bit = weight_quant_type in _int16_types or activation_quant_type in _int16_types
1035+
1036+
# Also check TensorQuantOverrides for any 16-bit types, including per-override convert.quant_type.
1037+
# Validation of structure is deferred to TensorQuantOverridesHelper.is_valid(); skip bump heuristic on malformed input.
1038+
if not needs_opset21_for_16bit and tensor_quant_overrides:
1039+
_int16_quant_types = {QuantType.QInt16, QuantType.QUInt16}
1040+
try:
1041+
for overrides_list in tensor_quant_overrides.values():
1042+
for override in overrides_list:
1043+
qt = override.get("quant_type")
1044+
if qt in _int16_quant_types:
1045+
needs_opset21_for_16bit = True
1046+
break
1047+
convert = override.get("convert")
1048+
if convert is not None:
1049+
convert_qt = convert.get("quant_type")
1050+
if convert_qt in _int16_quant_types:
1051+
needs_opset21_for_16bit = True
1052+
break
1053+
if needs_opset21_for_16bit:
1054+
break
1055+
except (AttributeError, TypeError):
1056+
# Malformed overrides; structural validation is deferred to
1057+
# TensorQuantOverridesHelper.is_valid(). Skip bump heuristic.
1058+
logging.debug("Skipping 16-bit opset bump heuristic for TensorQuantOverrides: structure not as expected.")
10241059

10251060
if opset_version < 19 and weight_quant_type == onnx.TensorProto.FLOAT8E4M3FN:
10261061
logging.warning(
@@ -1030,6 +1065,15 @@ def update_opset_version(model: ModelProto, weight_type: QuantType) -> ModelProt
10301065
)
10311066
target_opset_version = 19
10321067

1068+
elif opset_version < 21 and needs_opset21_for_16bit:
1069+
logging.warning(
1070+
f"The original model opset version is {opset_version}, which does not support 16-bit integer "
1071+
"quantization natively. "
1072+
"Please update the model to opset >= 21. Automatically update the model to opset 21. "
1073+
"Please verify the quantized model."
1074+
)
1075+
target_opset_version = 21
1076+
10331077
elif opset_version == 10:
10341078
logging.warning(
10351079
f"The original model opset version is {opset_version}, which does not support node fusions. "

onnxruntime/python/tools/quantization/quantize.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
QuantFormat,
3131
QuantizationMode,
3232
QuantType,
33+
get_opset_version,
3334
load_model_with_shape_infer,
3435
model_has_pre_process_metadata,
3536
save_and_reload_model_with_shape_infer,
@@ -380,14 +381,24 @@ def get_qdq_config(
380381
}
381382
final_extra_options.update(calib_extra_options)
382383

383-
# ONNX opset < 21 does not support 16-bit quantization, so must use 'com.microsoft' domain
384-
# on Q/DQ operators if using 16-bit or 4-bit quantization.
385-
onnx_opset = next(x for x in model.opset_import if x.domain == "" or x.domain == "ai.onnx")
386-
if onnx_opset.version < 21:
387-
opset21_types = q16_types.union(q4_types)
388-
overrides_have_opset21_types = any(t in opset21_types for t in overrides_helper.get_quant_types())
389-
if activation_type in opset21_types or weight_type in opset21_types or overrides_have_opset21_types:
390-
final_extra_options["UseQDQContribOps"] = True
384+
# ONNX opset < 21 does not support 4-bit quantization natively, so must use 'com.microsoft' domain
385+
# on Q/DQ operators if using 4-bit quantization. 16-bit weight/activation types are excluded here
386+
# because quantize_static() will automatically bump the model opset to 21, where native ONNX
387+
# QuantizeLinear/DequantizeLinear supports INT16/UINT16 and INT4/UINT4 without contrib-domain ops.
388+
# 16-bit types in TensorQuantOverrides also trigger the same opset bump, so a mixed 16-bit + 4-bit
389+
# override config will be served at opset 21 where neither type needs contrib ops.
390+
onnx_opset_version = get_opset_version(model)
391+
if onnx_opset_version < 21:
392+
override_types = overrides_helper.get_quant_types()
393+
overrides_have_16bit = any(t in q16_types for t in override_types)
394+
# If any 16-bit type is present (top-level or override), quantize_static() will bump the
395+
# model to opset 21, making contrib ops unnecessary for all types.
396+
will_bump_to_opset21 = activation_type in q16_types or weight_type in q16_types or overrides_have_16bit
397+
if not will_bump_to_opset21:
398+
overrides_have_q4_types = any(t in q4_types for t in override_types)
399+
needs_contrib_ops = activation_type in q4_types or weight_type in q4_types or overrides_have_q4_types
400+
if needs_contrib_ops:
401+
final_extra_options["UseQDQContribOps"] = True
391402

392403
# Allow user's extra_options to override our final_extra_options.
393404
if extra_options:
@@ -728,7 +739,12 @@ def inc_dataloader():
728739
nodes_to_exclude.extend([i.name for i in model.model.graph.node if i.name not in orig_nodes])
729740
model = load_model_with_shape_infer(Path(model_input)) # use smooth quant model for calibration
730741

731-
updated_model = update_opset_version(model, weight_type)
742+
updated_model = update_opset_version(
743+
model,
744+
weight_type,
745+
activation_type,
746+
tensor_quant_overrides=(extra_options or {}).get("TensorQuantOverrides"),
747+
)
732748
is_model_updated = updated_model is not model
733749
if is_model_updated:
734750
model = updated_model

onnxruntime/test/python/quantization/test_get_qdq_config.py

Lines changed: 159 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from op_test_utils import TestDataFeeds, check_model_correctness, check_op_type_count
1616

1717
from onnxruntime.quantization import CalibrationMethod, QuantFormat, QuantType, get_qdq_config, quantize
18+
from onnxruntime.quantization.quant_utils import get_opset_version
1819

1920

2021
class TestGetQDQConfig(unittest.TestCase):
@@ -271,10 +272,12 @@ def test_external_data(self):
271272
self.assertIsNotNone(weight_quantized)
272273
self.assertEqual(weight_quantized.data_location, onnx.TensorProto.EXTERNAL)
273274

274-
def test_use_qdq_contrib_ops_for_int16_opset19(self):
275+
def test_no_qdq_contrib_ops_for_int16_opset_lt21(self):
275276
"""
276-
Test that get_qdq_config() returns a config that forces 'com.microsoft' Q/DQ ops for
277-
use of int16 in opset < 21.
277+
Test that get_qdq_config() does NOT set UseQDQContribOps for int16 types even when
278+
the model opset is < 21. quantize_static() will bump the opset to 21 automatically,
279+
where native ONNX QuantizeLinear/DequantizeLinear supports INT16/UINT16, so contrib-
280+
domain ops are not needed.
278281
"""
279282

280283
shape = [1, 8, 8]
@@ -297,7 +300,53 @@ def test_use_qdq_contrib_ops_for_int16_opset19(self):
297300
)
298301

299302
self.assertEqual(qdq_config.activation_type, QuantType.QUInt16)
300-
self.assertTrue(qdq_config.extra_options["UseQDQContribOps"])
303+
# UseQDQContribOps must NOT be auto-set for 16-bit types; the opset bump handles them.
304+
self.assertFalse(qdq_config.extra_options.get("UseQDQContribOps", False))
305+
306+
def test_quantize_via_config_int16_opset_lt21_uses_native_qdq(self):
307+
"""
308+
Test that the config-based quantize() path produces a model at opset 21 using native
309+
ONNX QuantizeLinear/DequantizeLinear (not com.microsoft domain) when int16 activation
310+
types are requested on a model whose original opset is < 21.
311+
"""
312+
313+
shape = [1, 8, 8]
314+
tensor_type = onnx.TensorProto.FLOAT
315+
np_dtype = onnx.helper.tensor_dtype_to_np_dtype(tensor_type)
316+
weight = onnx.numpy_helper.from_array(np.ones(shape, dtype=np_dtype), "weight")
317+
# Build a model at opset 20 (< 21) with int16 activation type
318+
float_model = self.build_add_model(shape, tensor_type, weight, opset=20)
319+
320+
input_data_list = [
321+
{"input_0": np.ones(shape, dtype=np_dtype) * np.array(-2, dtype=np_dtype)},
322+
{"input_0": np.ones(shape, dtype=np_dtype) * np.array(2, dtype=np_dtype)},
323+
]
324+
data_reader = TestDataFeeds(input_data_list)
325+
326+
qdq_config = get_qdq_config(
327+
float_model,
328+
data_reader,
329+
activation_type=QuantType.QUInt16,
330+
weight_type=QuantType.QInt8,
331+
)
332+
333+
qdq_model_path = os.path.join(self._tmp_dir_path, "add_int16_opset20_qdq.onnx")
334+
quantize(float_model, qdq_model_path, qdq_config)
335+
336+
qdq_model = onnx.load_model(qdq_model_path)
337+
338+
# The quantized model must have been bumped to opset 21.
339+
onnx_opset_version = get_opset_version(qdq_model)
340+
self.assertEqual(onnx_opset_version, 21)
341+
342+
# All Q/DQ nodes must use the default ONNX domain (not com.microsoft).
343+
for node in qdq_model.graph.node:
344+
if node.op_type in ("QuantizeLinear", "DequantizeLinear"):
345+
self.assertEqual(
346+
node.domain,
347+
"",
348+
f"Expected native ONNX domain for {node.op_type} but got '{node.domain}'",
349+
)
301350

302351
def test_use_qdq_contrib_ops_for_int4_opset19(self):
303352
"""
@@ -329,6 +378,112 @@ def test_use_qdq_contrib_ops_for_int4_opset19(self):
329378
self.assertEqual(qdq_config.extra_options["TensorQuantOverrides"]["weight"][0]["quant_type"], QuantType.QInt4)
330379
self.assertTrue(qdq_config.extra_options["UseQDQContribOps"])
331380

381+
def test_overrides_16bit_opset_lt21_bumps_opset_no_contrib_ops(self):
382+
"""
383+
Regression test: when TensorQuantOverrides request a 16-bit type on a model whose opset is
384+
< 21, the quantized model must be bumped to opset 21 and UseQDQContribOps must NOT be set.
385+
"""
386+
387+
shape = [1, 8, 8]
388+
tensor_type = onnx.TensorProto.FLOAT
389+
np_dtype = onnx.helper.tensor_dtype_to_np_dtype(tensor_type)
390+
weight = onnx.numpy_helper.from_array(np.ones(shape, dtype=np_dtype), "weight")
391+
# Build a model at opset 18 (< 21) so the opset bump is required.
392+
float_model = self.build_add_model(shape, tensor_type, weight, opset=18)
393+
394+
input_data_list = [
395+
{"input_0": np.ones(shape, dtype=np_dtype) * np.array(-2, dtype=np_dtype)},
396+
{"input_0": np.ones(shape, dtype=np_dtype) * np.array(2, dtype=np_dtype)},
397+
]
398+
data_reader = TestDataFeeds(input_data_list)
399+
400+
# Override the weight to use QUInt16 via TensorQuantOverrides; top-level types are 8-bit.
401+
qdq_config = get_qdq_config(
402+
float_model,
403+
data_reader,
404+
activation_type=QuantType.QUInt8,
405+
weight_type=QuantType.QInt8,
406+
tensor_quant_overrides={"weight": [{"quant_type": QuantType.QUInt16}]},
407+
)
408+
409+
# UseQDQContribOps must NOT be set: the 16-bit override triggers an opset bump to 21,
410+
# where native ONNX Q/DQ ops handle all types.
411+
self.assertFalse(qdq_config.extra_options.get("UseQDQContribOps", False))
412+
413+
qdq_model_path = os.path.join(self._tmp_dir_path, "add_override_uint16_opset18_qdq.onnx")
414+
quantize(float_model, qdq_model_path, qdq_config)
415+
416+
qdq_model = onnx.load_model(qdq_model_path)
417+
418+
# The quantized model must have been bumped to opset 21.
419+
onnx_opset_version = get_opset_version(qdq_model)
420+
self.assertEqual(onnx_opset_version, 21)
421+
422+
# All Q/DQ nodes must use the default ONNX domain (not com.microsoft).
423+
for node in qdq_model.graph.node:
424+
if node.op_type in ("QuantizeLinear", "DequantizeLinear"):
425+
self.assertEqual(
426+
node.domain,
427+
"",
428+
f"Expected native ONNX domain for {node.op_type} but got '{node.domain}'",
429+
)
430+
431+
def test_overrides_mixed_16bit_4bit_opset_lt21_no_contrib_ops(self):
432+
"""
433+
Regression test: when TensorQuantOverrides contain both a 16-bit type (for one tensor) and
434+
a 4-bit type (for another tensor) on a model whose opset is < 21, UseQDQContribOps must NOT
435+
be set because the 16-bit override triggers an opset bump to 21 where all types are native.
436+
"""
437+
438+
shape = [1, 8, 8]
439+
tensor_type = onnx.TensorProto.FLOAT
440+
np_dtype = onnx.helper.tensor_dtype_to_np_dtype(tensor_type)
441+
weight = onnx.numpy_helper.from_array(np.ones(shape, dtype=np_dtype), "weight")
442+
# Build a model at opset 18 (< 21).
443+
float_model = self.build_add_model(shape, tensor_type, weight, opset=18)
444+
445+
input_data_list = [
446+
{"input_0": np.ones(shape, dtype=np_dtype) * np.array(-2, dtype=np_dtype)},
447+
{"input_0": np.ones(shape, dtype=np_dtype) * np.array(2, dtype=np_dtype)},
448+
]
449+
data_reader = TestDataFeeds(input_data_list)
450+
451+
# Override: weight uses QUInt16 (16-bit, triggers opset bump), input_0 uses QInt4 (4-bit).
452+
# The presence of the 16-bit override means the model is bumped to opset 21, so native
453+
# Q/DQ ops handle everything — UseQDQContribOps must NOT be set.
454+
qdq_config = get_qdq_config(
455+
float_model,
456+
data_reader,
457+
activation_type=QuantType.QUInt8,
458+
weight_type=QuantType.QInt8,
459+
tensor_quant_overrides={
460+
"weight": [{"quant_type": QuantType.QUInt16}],
461+
"input_0": [{"quant_type": QuantType.QInt4}],
462+
},
463+
)
464+
465+
# UseQDQContribOps must NOT be set: the 16-bit override triggers an opset bump to 21,
466+
# making native Q/DQ ops sufficient for all types including the 4-bit one.
467+
self.assertFalse(qdq_config.extra_options.get("UseQDQContribOps", False))
468+
469+
qdq_model_path = os.path.join(self._tmp_dir_path, "add_mixed_16bit_4bit_opset18_qdq.onnx")
470+
quantize(float_model, qdq_model_path, qdq_config)
471+
472+
qdq_model = onnx.load_model(qdq_model_path)
473+
474+
# The quantized model must have been bumped to opset 21.
475+
onnx_opset_version = get_opset_version(qdq_model)
476+
self.assertGreaterEqual(onnx_opset_version, 21)
477+
478+
# All Q/DQ nodes must use the default ONNX domain (not com.microsoft).
479+
for node in qdq_model.graph.node:
480+
if node.op_type in ("QuantizeLinear", "DequantizeLinear"):
481+
self.assertEqual(
482+
node.domain,
483+
"",
484+
f"Expected native ONNX domain for {node.op_type} but got '{node.domain}'",
485+
)
486+
332487

333488
if __name__ == "__main__":
334489
unittest.main()

onnxruntime/test/python/quantization/test_quant_util.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515
from onnx import TensorProto, helper, numpy_helper
1616

1717
from onnxruntime.quantization.quant_utils import (
18+
QuantType,
1819
compute_scale_zp,
1920
load_model_with_shape_infer,
2021
model_has_infer_metadata,
2122
pack_bytes_to_4bit,
2223
quantize_data,
24+
update_opset_version,
2325
)
2426

2527

@@ -173,6 +175,49 @@ def test_quantize_data_4bit(self):
173175

174176
self.assertEqual(numpy.array(actual_quant_val), expected_quant_val)
175177

178+
def test_update_opset_version_16bit(self):
179+
graph = helper.make_graph([], "test_graph", [], [])
180+
181+
# 16-bit weight type alone should auto-bump opset < 21 -> 21
182+
for weight_type, label in (
183+
(QuantType.QUInt16, "QUInt16"),
184+
(QuantType.QInt16, "QInt16"),
185+
):
186+
with self.subTest(weight_type=label, opset=20):
187+
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 20)])
188+
result = update_opset_version(model, weight_type)
189+
result_opset = result.opset_import[0].version
190+
self.assertEqual(result_opset, 21)
191+
192+
# Already at opset 21 - should stay at 21
193+
for weight_type, label in (
194+
(QuantType.QUInt16, "QUInt16"),
195+
(QuantType.QInt16, "QInt16"),
196+
):
197+
with self.subTest(weight_type=label, opset=21):
198+
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 21)])
199+
result = update_opset_version(model, weight_type)
200+
result_opset = result.opset_import[0].version
201+
self.assertEqual(result_opset, 21)
202+
203+
# 16-bit activation type with 8-bit weight should also bump opset < 21 -> 21
204+
for activation_type, label in (
205+
(QuantType.QUInt16, "QUInt16"),
206+
(QuantType.QInt16, "QInt16"),
207+
):
208+
with self.subTest(activation_type=label, weight_type="QInt8", opset=20):
209+
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 20)])
210+
result = update_opset_version(model, QuantType.QInt8, activation_type)
211+
result_opset = result.opset_import[0].version
212+
self.assertEqual(result_opset, 21)
213+
214+
# Both 8-bit should NOT bump to 21; opset stays at 20
215+
with self.subTest(weight_type="QInt8", activation_type="QUInt8", opset=20):
216+
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 20)])
217+
result = update_opset_version(model, QuantType.QInt8, QuantType.QUInt8)
218+
result_opset = result.opset_import[0].version
219+
self.assertEqual(result_opset, 20)
220+
176221

177222
if __name__ == "__main__":
178223
unittest.main()

0 commit comments

Comments
 (0)