fix(quantization): skip MaxPool during FP8 static quantization#28488
Conversation
MaxPool does not accept float8 inputs per the ONNX spec, but the static
quantization path was propagating FP8 types into MaxPool nodes, producing
invalid graphs that fail with INVALID_GRAPH at InferenceSession load:
Type 'tensor(float8e4m3fn)' of input parameter
(input_QuantizeLinear_Output) of operator (MaxPool) in node (/MaxPool)
is invalid.
Adds a guard in both QMaxPool.quantize() and QDQMaxPool.quantize() that
falls back to the unquantized passthrough (the same escape hatch used for
opset_version < 12) when the quantizer's activation type is one of the
float8 TensorProto variants. A new module-level FLOAT8_TYPES tuple in
quant_utils provides a single source of truth for the four FP8 enum values.
Adds a regression test in test_op_maxpool.py that runs FP8 static
quantization over a Conv+MaxPool graph and asserts the resulting MaxPool
node neither carries float8 tensor types nor is wrapped by QDQ.
Fixes microsoft#21090
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR fixes invalid ONNX graphs produced by FP8 static quantization by skipping MaxPool quantization when FP8 activation types are used, and adds a regression test to ensure FP8 tensors are not propagated into MaxPool.
Changes:
- Add a shared
FLOAT8_TYPESconstant (TensorProto enum values) for FP8 detection. - Skip
MaxPoolquantization for FP8 activation types in both QOperator and QDQ paths. - Add a regression test that checks graph structure for the FP8
Conv -> MaxPoolcase.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| onnxruntime/test/python/quantization/test_op_maxpool.py | Adds FP8 regression coverage to ensure MaxPool remains unquantized and does not receive FP8 tensors. |
| onnxruntime/python/tools/quantization/quant_utils.py | Introduces FLOAT8_TYPES tuple as a single source of truth for FP8 TensorProto types. |
| onnxruntime/python/tools/quantization/operators/maxpool.py | Guards MaxPool quantization to avoid emitting unsupported FP8-typed MaxPool inputs/outputs. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
CodeQL flagged super(Direct8BitOp, self).quantize() inside QMaxPool.quantize() — the first argument to super() must be the enclosing class. The intent of both call sites is to bypass Direct8BitOp.quantize() and fall through to the QuantOperatorBase passthrough, so call QuantOperatorBase.quantize(self) explicitly. This is behaviorally identical and resilient to future changes in the MRO between Direct8BitOp and QuantOperatorBase.
|
The CodeQL alert ("First argument to super() is not enclosing class") was raised against commit 69095c5, where Follow-up commit 9facab2 already addresses this. Both call sites in The alert should clear on the next CodeQL re-scan of the PR head. |
|
The CodeQL |
tianleiwu
left a comment
There was a problem hiding this comment.
Small, correct, well-scoped fix for #21090. The FP8 early-return guard in both QMaxPool.quantize() and QDQMaxPool.quantize() mirrors the existing opset_version < 12 passthrough and keeps MaxPool unquantized, which is what avoids propagating FP8 types into the MaxPool input. self.quantizer.activation_qType is the resolved integer TensorProto enum (base_quantizer.py sets self.activation_qType = getattr(activation_qType, "tensor_type", activation_qType)), so the in FLOAT8_TYPES membership test is a correct int-vs-tuple comparison. Replacing super(Direct8BitOp, self).quantize() with the explicit QuantOperatorBase.quantize(self) is clearer and equivalent.
A couple of non-blocking points:
- Single-source-of-truth not fully realized:
operators/matmul.py(lines 178-183) still hardcodes the identical 4-element FP8 tuple for itsdomainselection. Reusing the newFLOAT8_TYPESconstant there (if self.quantizer.weight_qType in FLOAT8_TYPES) would complete the stated goal and prevent future drift. Optional here sincematmul.pyis outside this diff. - Test coverage: Only the QDQ path (
QDQMaxPool) is exercised. TheQMaxPool(QOperator-format) guard is untested; a short note or QOperator-format assertion would document intent. (FP8 + QOperator may not be reachable via the publicquantize_staticAPI today.)
The automated-review note about onnx_proto.TensorProto vs TensorProto is a false positive — both names refer to the same object in this module, and onnx_proto.TensorProto matches the existing precedent in matmul.py.
…ol path - Replace hardcoded FP8 tuple in QLinearMatMul.quantize with the shared FLOAT8_TYPES constant from quant_utils to keep a single source of truth. - Drop the now-unused onnx_proto import in matmul.py. - Add test_quantize_maxpool_fp8_qoperator to exercise the QOperator-format MaxPool skip guard, mirroring the existing QDQ-format coverage. Addresses follow-up review feedback on microsoft#28488.
|
Thanks for the review. Both follow-up suggestions are addressed in e8e27ef:
Local |
tianleiwu
left a comment
There was a problem hiding this comment.
The fix is correct and well-scoped for #21090. The FP8 early-return guard in both QMaxPool.quantize() and QDQMaxPool.quantize() mirrors the existing opset_version < 12 passthrough, keeping MaxPool unquantized so FP8 types are no longer propagated into its input. self.quantizer.activation_qType is the resolved integer TensorProto enum, so the in FLOAT8_TYPES membership test is a correct int-vs-tuple comparison.
The earlier request to complete the single-source-of-truth goal is now addressed — operators/matmul.py consumes the new FLOAT8_TYPES constant instead of re-hardcoding the 4-element FP8 set, so the two lists can no longer drift. Resolving that thread.
Two minor, non-blocking items remain (both already anchored inline by the automated reviewers):
-
QMaxPool.quantize()FP8 branch —return QuantOperatorBase.quantize(self)returnsNone(the basequantize()has no return value), which is what CodeQL flags. For consistency with the barereturnused in theQDQMaxPoolguard and theopset_version < 12branch just above it, drop thereturnkeyword: callQuantOperatorBase.quantize(self)thenreturn. Cosmetic only. -
Test assertions — in both new tests the FP8-type checks are guarded by
if tensor_name in tensor_types:. If aMaxPoolinput/output never appears invalue_info/graph IO (e.g., it is only an initializer or its type is not materialized), theassertNotInis silently skipped and the test can pass vacuously. Consider also indexing initializers intotensor_types, or asserting the tensor was found, so the guard genuinely fails when an FP8 type leaks ontoMaxPool.
Neither item blocks merge.
Summary
opset_version < 12.FLOAT8_TYPEStuple inquant_utilsas a single source of truth for the FP8 TensorProto enum values.Motivation
Fixes #21090.
ONNX
MaxPooldoes not acceptfloat8inputs (per the spec), but the static quantization path was propagating FP8 types into MaxPool nodes. Loading the resulting model failed with:The user reproduces this with a simple Conv + MaxPool graph quantized via
quantize_static(..., activation_type=QuantType.QFLOAT8E4M3FN, weight_type=QuantType.QFLOAT8E4M3FN, calibrate_method=CalibrationMethod.Distribution).Changes
onnxruntime/python/tools/quantization/quant_utils.py: addFLOAT8_TYPESmodule-level tuple.onnxruntime/python/tools/quantization/operators/maxpool.py: add an FP8 early-return guard in bothQMaxPool.quantize()andQDQMaxPool.quantize()that mirrors the existingopset_version < 12fallback for each class.onnxruntime/test/python/quantization/test_op_maxpool.py: addTestOpMaxPoolFP8::test_quantize_maxpool_fp8covering the regression. The test asserts structural correctness (no FP8-typed tensor on MaxPool inputs/outputs, no QuantizeLinear node directly feeding MaxPool) rather than running inference, sinceQLinearConv-FP8is not runnable on the CPU EP.Total non-test LOC: 15 (9 in
maxpool.py, 6 inquant_utils.py).Test Plan
python -m pytest onnxruntime/test/python/quantization/test_op_maxpool.py::TestOpMaxPool::test_quantize_maxpool -v— existing INT8 path: PASS.python -m pytest onnxruntime/test/python/quantization/test_op_maxpool.py::TestOpMaxPool::test_quantize_maxpool_s8s8 -v— existing S8S8 path: PASS.python -m pytest onnxruntime/test/python/quantization/test_op_maxpool.py::TestOpMaxPoolFP8::test_quantize_maxpool_fp8 -v— new FP8 regression: PASS.lintrunneron the diff: clean (no patches applied, no warnings or errors).The calibration-method validation in
quantize_staticis intentionally left unchanged — per the maintainer comment on the issue,CalibrationMethod.Distributionis the supported FP8 calibration path.