Skip to content

test(onnx): cover per-tensor weight scales in qdq_to_dq - #2613

Open
Yi-111-a wants to merge 2 commits into
NVIDIA:mainfrom
Yi-111-a:fix/qdq-to-dq-per-tensor-weight-scale
Open

Yi-111-a wants to merge 2 commits into
NVIDIA:mainfrom
Yi-111-a:fix/qdq-to-dq-per-tensor-weight-scale

Conversation

@Yi-111-a

@Yi-111-a Yi-111-a commented Oct 1, 2026 •

Copy link
Copy Markdown

What does this PR do?

Type of change: Bug fix

Fixes #2561.

_convert_weight() assumed every scale was per-axis. It compared scale.shape[0] against the weight's quantization axis and reshaped the scale along that axis, which breaks for a per-tensor scale — the ONNX default for QuantizeLinear/DequantizeLinear, where a scalar (or single-element) scale broadcasts across the whole weight:

scale behavior on main
scalar, shape () IndexError: tuple index out of range
1-element, shape (1,) ValueError: Scale shape (1,) does not match weight shape (16, 32) along axis 1

Both spellings are valid ONNX — they pass onnx.checker.check_model and run under onnxruntime before conversion — so any --dq_only / dq_only=True export of a model quantized per-tensor failed. (The scalar expansion suggested in the earlier #581 turns the IndexError into the (1,) case's shape mismatch rather than fixing it.)

The change decides per-tensor vs per-axis before anything indexes the scale: a scale with a single element is broadcast over the whole weight, and only a multi-element scale is validated against the axis length. The per-axis path and its error message are unchanged.

Usage

No API change. Existing per-tensor exports now convert:

from modelopt.onnx.quantization.qdq_utils import qdq_to_dq

converted = qdq_to_dq(model)  # model with per-tensor quantized weights

Testing

  • Added TestQdqToDqPerTensorScale covering both per-tensor spellings (parametrized over scalar and (1,)) plus a guard that a per-axis scale of the wrong length is still rejected. The equivalence test runs the real model through onnxruntime and compares the converted DQ-only graph against the original QDQ graph — output is bit-identical (atol=1e-5) for both spellings, and the rewritten weight is int8 with the Q node removed.
  • pytest tests/unit/onnx/quantization/test_qdq_utils.py — 31 passed. The 3 TestColumnMajorTransformation failures in this file also fail on unmodified main (333ace1) in this environment; they are unrelated to this change.
  • pytest tests/unit/onnx — 652 passed, 9 failed. The same 9 failures occur on unmodified main (649 passed there; the +3 are the new tests).
  • ruff check / ruff format --check clean on both touched files (the 3 remaining PLR0917 hits are on untouched lines).

Before your PR is "Ready for review"

  • Is this change backward compatible?: ✅ — the per-axis path is untouched; only scales that previously raised are now converted.
  • If you copied code from any other sources or added a new PIP dependency, did you follow guidance in CONTRIBUTING.md: N/A
  • Did you write any new necessary tests?: ✅
  • Did you update Changelog?: ❌ — fixing a bug in the current unreleased cycle.
  • Did you get Claude approval on this PR?: ❌

Additional Information

  • CPU-only test environment (onnx 1.21.0, onnxruntime 1.22.1, torch 2.14.1+cpu); no GPU needed to reproduce or validate.
  • No CHANGELOG entry per the AGENTS.md guidance on bugs fixed within the same unreleased cycle.
  • @SID-6921 you mentioned this was unowned — happy to adjust if you'd prefer a different shape for the per-tensor check.

Summary by CodeRabbit

  • Tests
    • Expanded automated coverage for quantized model conversion across scalar and one-element scale configurations.
    • Added checks that converted models preserve output accuracy and that incompatible scale shapes produce an error. These tests improve confidence in conversion behavior across supported and invalid input configurations.

_convert_weight treated every scale as per-axis: it compared
scale.shape[0] against the weight's quantization axis and reshaped the
scale along that axis. A per-tensor scale is the ONNX default for
QuantizeLinear/DequantizeLinear and has scalar or single-element shape,
so a scalar scale raised IndexError on scale.shape[0] and a (1,) scale
raised a spurious shape mismatch. Either way the conversion of a
per-tensor quantized weight failed.

Decide per-tensor vs per-axis before any scale indexing: a scale with a
single element is broadcast over the whole weight, and only a
multi-element scale is validated against the axis length.

The per-axis path and its error are unchanged.

Signed-off-by: Yi-111-a <153097222+Yi-111-a@users.noreply.github.com>
@Yi-111-a
Yi-111-a requested review from a team as code owners October 1, 2026 01:15
@Yi-111-a
Yi-111-a requested a review from gcunhase October 1, 2026 01:15
@copy-pr-bot

copy-pr-bot Bot commented Oct 1, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 2821e9d and 8ce37ad.

Files selected for processing: 1
  • tests/unit/onnx/quantization/test_qdq_utils.py

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 11 remain after this review.

Walkthrough

The change adds MatMul QDQ tests for scalar and one-element scales. The tests check converted model structure, output equivalence, and error handling for a mismatched scale shape.

Changes: QDQ conversion tests

Layer / File(s) Summary
MatMul QDQ conversion tests
tests/unit/onnx/quantization/test_qdq_utils.py
A fixture builds MatMul QDQ models with selected scale and zero-point shapes. Tests expect scalar and one-element scales to remove QuantizeLinear, convert the weight initializer to int8, and preserve outputs within atol=1e-5. A mismatch case expects a RuntimeError identifying the scale and weight shape mismatch.

Priority: Normal

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Other · Severity of issue fixed: Medium

Merge Risk: Minimal · up to 8ce37

No actionable merge-blocking risk is established in the test-only change.

Pre-merge checks: Passed 5 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documenta… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 5 passed
Check name Status Explanation
Title check Passed The title clearly and concisely describes the added ONNX tests for per-tensor weight scales in qdq_to_dq.
Linked Issues check Passed Issue #2561 requires successful conversion for scalar and (1,) per-tensor scales, numerical equivalence, and rejection of an invalid per-axis scale. The reviewed head contains the per-tensor convers…
Out of Scope Changes check Passed The pull request adds only focused QDQ conversion tests and their test helpers. The tests directly cover issue #2561 requirements and preserve the per-axis validation case. No unrelated change is show…
Security Anti-Patterns Passed PASS. The PR changes only tests/unit/onnx/quantization/test_qdq_utils.py; it adds no modelopt or examples Python code and changes no dependency manifest. Added lines contain none of the specifie…
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Full details: Linked Issues check

Explanation

Issue #2561 requires successful conversion for scalar and (1,) per-tensor scales, numerical equivalence, and rejection of an invalid per-axis scale. The reviewed head contains the per-tensor conversion implementation. TestQdqToDqPerTensorScale covers both scale shapes, checks removal of QuantizeLinear, checks an int8 weight, compares ONNX Runtime outputs, and verifies rejection of a mismatched (8,) per-axis scale. The reported focused test run passed.

Full details: Out of Scope Changes check

Explanation

The pull request adds only focused QDQ conversion tests and their test helpers. The tests directly cover issue #2561 requirements and preserve the per-axis validation case. No unrelated change is shown in the whole-pull-request diff.

Full details: Security Anti-Patterns

Explanation

PASS. The PR changes only tests/unit/onnx/quantization/test_qdq_utils.py; it adds no modelopt or examples Python code and changes no dependency manifest. Added lines contain none of the specified security anti-patterns: unsafe torch.load, numpy.load(..., allow_pickle=True), hardcoded trust_remote_code=True, external-input eval/exec, or # nosec.

Full details: Docstring Coverage

Explanation

Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 2 functions across 1 files.

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.

Comment @coderabbitai help to get the list of available commands.

@SID-6921

SID-6921 commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Heads-up on an overlap, since I filed #2561 and have changes pending in the same function — flagging it so whoever merges does not get a surprise, not to hold this up.

#2554 touches the lines immediately above this diff. It already introduces scale_array.size > 1 as the per-tensor/per-axis discriminator, but for axis selection:

if scale_array.size > 1:
    # per-axis: take the axis the Q/DQ pair declares
    axis = 1 if dq_axis is None else dq_axis
    ...
else:
    axis = axis_map[op_type]

This PR adds the same distinction a few lines down, for the shape validation and the reshape_dims indexing. Both merge cleanly against main today, but whichever lands first will conflict with the other, and if both land as written _convert_weight ends up asking "is this per-tensor?" twice within about ten lines. It would be better as one decision both halves read — which is the contract @kvnloo described on #2561.

#2575 is unrelated, but #2567 also touches this function slightly lower down (it derives the stored weight type and clip range from the zero point instead of hardcoding int8), so that one is worth knowing about too.

I am happy to rebase mine on top of this once it lands, or to reorder — entirely the maintainers' call which goes first. Just did not want two near-identical per-tensor checks to land independently.

SID-6921 added a commit to SID-6921/Model-Optimizer that referenced this pull request Oct 2, 2026
A one-element scale was routed to the consumer's axis_map entry, which only
converts when that dimension happens to be 1. A single-output projection is the
counterexample: weight (1, 32) with a (1,) scale picked MatMul axis 1 and failed
the length check against 32. It fails the same way without a Transpose, so the
gate was wrong rather than the tracing.

A single scale covers the whole weight. It broadcasts, and no axis applies to
it, so leave reshape_dims all ones and run the length check only for a per-axis
scale.

That leaves nothing reading axis_map's values -- the per-axis branch takes its
axis from the Q/DQ pair -- so it becomes the set of supported consumers it was
really being used as, and the Gemm transB lookup that fed it goes with it.

One consequence worth flagging: a scalar scale now converts as well, which is
the subject of NVIDIA#2613.

Signed-off-by: Siddhardha Nanda <99672439+SID-6921@users.noreply.github.com>
gcunhase added a commit that referenced this pull request Oct 9, 2026
…in qdq_to_dq (#2554)

### What does this PR do?

Type of change: Bug fix

> Rewritten 2026-10-02 to describe what the branch actually does. The
first version described a composed-permutation approach that review
replaced; that machinery is gone.

`qdq_to_dq()` assumed `DequantizeLinear` feeds `Conv`/`Gemm`/`MatMul`
directly, tolerating only a `Cast` in between. A weight that reaches
`MatMul` transposed — as in MaxViT's attention blocks — put `Transpose`
in front of `_convert_weight`, which has no entry for it:

```
RuntimeError: Failed to convert node .../qkv/weight_quantizer/QuantizeLinear:
Unsupported op_type for real weight quantization: Transpose
```

**Two changes, and the second is the one that matters.**

**1. Trace `Cast`/`Transpose` to reach the real consumer.** That walk
now exists only to find the node whose op type decides whether this
conversion supports the weight at all.

**2. Take the quantization axis from the Q/DQ pair, not from the
consumer.** After conversion the `DequantizeLinear` stays in the graph
with its input rewired to the weight initializer, so *its* axis is what
indexes the stored weight — regardless of any `Transpose` downstream.
The conversion follows that axis, defaulting to ONNX's `axis = 1` when
the attribute is omitted. No permutation is composed and there is no
consumer-axis fallback.

That also fixes a case that is broken on `main` today and has nothing to
do with `Transpose`. Measured end to end under onnxruntime, square
weight into `MatMul`:

| Q/DQ axis | Transpose | on `main` | this PR |
|---|---|---|---|
| 0 | no | **silently wrong** (max diff 4.62) | correct |
| 0 | yes | raises `Unsupported op_type` | correct |
| 1 | no | correct | correct |
| 1 | yes | n/a (raised before this PR) | correct |

A wrong axis on a square weight still satisfies the scale-length check,
so nothing raises and the model simply computes something else.

**Two smaller consequences of making the Q/DQ pair authoritative:**

- A **single-element scale** covers the whole weight, so it broadcasts
and no axis applies to it. It no longer borrows the consumer's axis,
which only worked when that dimension happened to be 1 — a `(1, 32)`
single-output projection failed before. A scalar scale converts as well,
which is also the subject of #2613.
- A **per-axis Q/DQ pair that disagrees** on the axis now raises instead
of silently following the DQ and dropping the Q. Effective axes are
compared, so declaring `axis` on only one side is not a disagreement.

With nothing left reading the per-op axis values, `axis_map` became the
set of supported consumers it was really being used as.

### Testing

`TestQdqToDqTranspose`, 10 cases, each failing on `main`:

- `test_conversion_matches_qdq` — 5 combinations of shape x axis x
transpose x declared/omitted axis, comparing onnxruntime output before
and after conversion
- `test_dq_transpose_without_perm_is_traced` — ONNX lets `Transpose`
omit `perm`
- `test_one_element_scale_broadcasts_through_transpose` — `(1, 32)` and
`(32, 1)`
- `test_one_element_scale_is_not_per_axis` — single-output `Conv`
- `test_disagreeing_qdq_axes_raise`

On the model from #2442: a quantized `maxvit_tiny_rw_256` exported with
`dq_only=True` raises on `main` and exports here, and the resulting
DQ-only graph is **bit-identical to the QDQ graph under onnxruntime —
max abs diff `0.0e+00`** across 457 quantizers.

`tests/unit/onnx` is 659 passed locally with 11 failures that also occur
on pristine `main` in this environment (int4 paths needing cupy, three
`TestColumnMajorTransformation` tests needing MSVC build tools). `ruff
check` reports the same 3 pre-existing `PLR0917` findings before and
after.

### Before your PR is "*Ready for review*"

- Is this change backward compatible?: ✅ — a correctly emitted
per-channel Q/DQ pair already declares the axis this conversion now
reads, so well-formed graphs convert bit-identically; only graphs where
the two disagreed change behaviour
- If you copied code from any other sources or added a new PIP
dependency, did you follow guidance in `CONTRIBUTING.md`: N/A
- Did you write any new necessary tests?: ✅
- Did you update
[Changelog](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?:
✅
- Did you get Claude approval on this PR?: N/A

### Additional Information

Fixes #2442.

#2613 edits `_convert_weight` against the lines this PR restructures,
and #2567 touches the same function lower down. Happy to rebase in
whatever order suits you.

---------

Signed-off-by: SID <99672439+SID-6921@users.noreply.github.com>
Signed-off-by: Siddhardha Nanda <99672439+SID-6921@users.noreply.github.com>
Co-authored-by: Gwena Cunha <4861122+gcunhase@users.noreply.github.com>
@kevalmorabia97

Copy link
Copy Markdown
Collaborator

Thanks for the contribution! This PR now has merge conflicts with main. Could you update your branch with the latest main and fix the conflicts so we can continue the review?

We've added the waiting for feedback label while we wait for your update.

main now handles single-element scales in _convert_weight (NVIDIA#2554, NVIDIA#2678),
so take main's implementation and keep only the per-tensor regression
tests for a plain MatMul weight with a scalar or (1,) scale.

Signed-off-by: Yi-111-a <153097222+Yi-111-a@users.noreply.github.com>
@Yi-111-a Yi-111-a changed the title fix(onnx): support per-tensor scales in qdq_to_dq test(onnx): cover per-tensor weight scales in qdq_to_dq Oct 28, 2026
@Yi-111-a

Copy link
Copy Markdown
Author

Thanks @kevalmorabia97, merged current main in 8ce37ad (no force-push). The conflict was in _convert_weight: #2554 / #2678 already landed the single-element-scale handling this PR was fixing, so I took main's implementation as-is and dropped my version. What's left is test-only: TestQdqToDqPerTensorScale covers a plain MatMul weight with a scalar () and a (1,) scale (numerically equivalent after conversion), plus a per-axis length mismatch that must still raise. I retitled the PR to match. If you'd rather not take the extra tests now that the fix is in, feel free to close it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] qdq_to_dq cannot convert per-tensor quantized weights (scalar scale raises IndexError, (1,) scale raises shape mismatch)

3 participants