fix(quantization): emit axis on DequantizeLinear for per-channel dynamic quantization (#28228)
## Summary
- Fix `quantize_dynamic(per_channel=True)` so weights quantized
per-channel produce a `DequantizeLinear` node with the correct `axis`
attribute.
- Stop dropping the channel axis when `quantize_weight_per_channel`
populates `QuantizedValue` (was hardcoded to `None`).
- Gate the scalar-scale assertion in `_dequantize_value` on `axis is
None` so per-channel scales (1-D tensors) are accepted.
## Motivation
Fixes #19997.
When a model is quantized with `quantize_dynamic(..., per_channel=True)`
and a per-channel weight reaches `_dequantize_value` (e.g. via
`_dequantize_outputs` when the weight is in the graph outputs), two bugs
surface:
1. `quantize_weight_per_channel` stores `QuantizedValue.axis = None`
even though it received a real `channel_axis`, so the per-channel
information is lost.
2. `_dequantize_value` (a) asserts `scale_init.size == 1`, which fails
for a 1-D per-channel scale, and (b) builds the `DequantizeLinear` node
without an `axis` attribute, producing an invalid ONNX node when the
model is consumed.
PR #22283 (Nov 2024) softened the assertion against `None`-typed scales
but left the underlying axis-propagation bug in place.
## Changes
- `onnxruntime/python/tools/quantization/onnx_quantizer.py`
- `quantize_weight_per_channel`: pass `channel_axis` (was `None`) into
`QuantizedValue`.
- `_dequantize_value`: only require a scalar scale on the per-tensor
path (`axis is None`); forward `axis=quantized_value.axis` to
`onnx.helper.make_node("DequantizeLinear", ...)`. `make_node` silently
omits the attribute when `axis` is `None`, so the per-tensor path is
unchanged.
- `onnxruntime/test/python/quantization/test_quant_issues.py`
- New regression test
`test_dynamic_quantize_per_channel_emits_axis_attribute` that builds a
minimal MatMul model with the weight routed to a graph output (to force
the `_dequantize_outputs` -> `_dequantize_value` path), runs
`quantize_dynamic(per_channel=True)`, and asserts the emitted
`DequantizeLinear` has the `axis` attribute and a 1-D multi-element
scale initializer.
## Test Plan
- `python -m pytest
onnxruntime/test/python/quantization/test_quant_issues.py -xvs` — new
test passes; existing test skipped as before.
- `python -m pytest
onnxruntime/test/python/quantization/test_op_matmul.py` — 7 passed, 8
skipped (no regression).
- `python -m pytest onnxruntime/test/python/quantization/test_qdq.py -k
per_channel` — 1 passed.
- `lintrunner -a` on changed files: clean.