Skip to content

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

Open

Description

@SID-6921

Before submitting an issue, please make sure it hasn't been already addressed by searching through the existing and past issues.

Searched tuple index out of range, per-tensor qdq_to_dq, _convert_weight scale, dq_only per-tensor. The scalar half of this was reported in #581 and closed as completed — see "Relationship to #581" below; the suggested fix there does not actually fix it, and both forms still reproduce on main today.

Describe the bug

qdq_to_dq() cannot convert a per-tensor quantized weight. Both ONNX spellings of a per-tensor scale fail, in different ways:

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

_convert_weight derives axis from a per-op map and then indexes the scale as if it were per-axis:

https://github.com/NVIDIA/Model-Optimizer/blob/main/modelopt/onnx/quantization/qdq_utils.py#L665-L673

  • Line 665 guards the shape check with if scale_array.shape, so a scalar scale skips validation and reaches line 671, where scale_array.shape[0] raises IndexError.
  • A (1,) scale does not skip validation, and 1 != weight_shape[axis] raises ValueError — even though a per-tensor scale is legitimately broadcast across every channel.

Per-tensor is the ONNX default for QuantizeLinear/DequantizeLinear (a scalar y_scale means per-tensor), so any --dq_only / dq_only=True export of a model whose weights were quantized per-tensor fails.

Steps/Code to reproduce bug

import copy
import numpy as np, onnx, onnxruntime as ort
from onnx import helper, numpy_helper, TensorProto
from modelopt.onnx.quantization.qdq_utils import qdq_to_dq

def build(scalar_scale: bool):
    w = np.random.RandomState(3).randn(16, 32).astype(np.float32)
    amax = np.abs(w).max() / 127.0
    s = np.array(amax, np.float32) if scalar_scale else np.array([amax], np.float32)
    z = np.array(0, np.int8) if scalar_scale else np.array([0], np.int8)
    g = helper.make_graph(
        [helper.make_node("QuantizeLinear", ["W", "s", "z"], ["wq"], name="q"),
         helper.make_node("DequantizeLinear", ["wq", "s", "z"], ["wdq"], name="dq"),
         helper.make_node("MatMul", ["x", "wdq"], ["y"], name="mm")],
        "g",
        [helper.make_tensor_value_info("x", TensorProto.FLOAT, [4, 16])],
        [helper.make_tensor_value_info("y", TensorProto.FLOAT, [4, 32])],
        [numpy_helper.from_array(w, "W"), numpy_helper.from_array(s, "s"),
         numpy_helper.from_array(z, "z")])
    m = helper.make_model(g, opset_imports=[helper.make_opsetid("", 21)])
    m.ir_version = 10
    onnx.checker.check_model(m)   # valid either way
    return m

for scalar in (True, False):
    try:
        qdq_to_dq(copy.deepcopy(build(scalar)))
        print(f"scalar={scalar}: converted")
    except Exception as e:
        print(f"scalar={scalar}: {type(e).__name__}: {e}")

Output:

scalar=True: RuntimeError: Failed to convert node q: tuple index out of range
scalar=False: RuntimeError: Failed to convert node q: Scale shape (1,) does not match weight shape (16, 32) along axis 1

Both graphs pass onnx.checker.check_model and both run under onnxruntime before conversion, so the input is valid in each case.

Expected behavior

A per-tensor scale is broadcast across the whole weight, so conversion should succeed and the resulting DQ-only graph should match the QDQ graph numerically — or, if per-tensor weights are deliberately out of scope for qdq_to_dq, fail with a message that says so rather than an IndexError.

Relationship to #581

#581 reported the scalar case and was closed as completed. The fix suggested there was to expand the scalar to shape (1,):

if not scale_array.shape:
    scale_array = np.expand_dims(scale_array, axis=0)

I applied exactly that on top of main and re-ran the repro: it does not fix the export. It converts the IndexError into the (1,) path's ValueError: Scale shape (1,) does not match weight shape (16, 32) along axis 1, because the shape check at line 665 then rejects the broadcast scale. So the underlying problem — _convert_weight treating every scale as per-axis — is still present, which is why I am filing this rather than commenting on a closed issue.

System information

  • Model Optimizer version: main @ 23355ed
  • ONNX: 1.21.0
  • onnxruntime: 1.30.0
  • Python: 3.12, Windows 11 (CPU only; no GPU needed to reproduce)

Activity

  1. kvnloo commented on Sep 28, 2026

    @kvnloo

    The distinction here seems to be:

    • scalar / one-element scale -> per-tensor quantization;
    • scale matching one weight axis -> per-axis quantization.

    _convert_weight() should decide that contract before indexing scale.shape[0] or checking an axis length.

    I would keep the regression entirely through the real ONNX conversion path:

    1. scalar scale shape=();
    2. one-element scale shape=(1,);
    3. ordinary per-axis scale as a control.

    For both per-tensor spellings:

    • qdq_to_dq() succeeds;
    • the resulting graph passes onnx.checker;
    • ONNX Runtime output before/after conversion matches numerically.

    That seems better than special-casing only the scalar IndexError, because (1,) is the same semantic quantization mode and currently fails differently.

    If this is unowned, I can take the focused conversion + regression fix.

  2. SID-6921 commented on Sep 28, 2026

    @SID-6921
    ContributorAuthor

    Yes, it is unowned — please take it, and thanks for picking it up. Your framing of the contract matches what I saw: the decision is per-tensor vs per-axis, and it has to happen before anything indexes scale.shape[0] or compares an axis length. Agreed too that the three-case regression through the real conversion path is the right shape, and that fixing only the scalar IndexError would leave (1,) failing differently for the same semantic mode.

    Two things from my side that may save you some time.

    _convert_weight is being modified right now in #2554, which touches the same axis-selection block. That PR does not fix per-tensor, but you will probably want to base on it or rebase once it lands, or the two will conflict.

    A one-element scale is a trap worth knowing about before you pick the discriminator. I used scale_array.ndim == 1 for the per-axis branch in #2554 and it regressed graphs that convert on main today, because a per-tensor scale spelled as a one-element 1-D tensor also matches it:

    weight consumer main with ndim == 1
    (1, 3, 3, 3) Conv converts Scale shape (1,) does not match weight shape (1, 3, 3, 3) along axis 1
    (1, 64) Gemm transB=1 converts raises

    A single-output-channel Conv is not exotic. scale_array.size > 1 is the discriminator that held up for me — the axis is mathematically irrelevant for a broadcast scale, so it only affects the validation check. Might be worth a fourth case in your regression matrix.

    Happy to review whenever you open the PR.

  3. juhi10071998 commented on Oct 1, 2026

    @juhi10071998
    Contributor

    hi @ajrasane , could you please help review this in onnx? Thanks! #2613

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions