Skip to content

[ONNX] Add quantization sensitivity ranking + exclusion picker - #2240

Draft
gcunhase wants to merge 8 commits into
NVIDIA:mainfrom
gcunhase:dev/gcunhasergio/onnx_sensitivity_scan_verified
Draft

[ONNX] Add quantization sensitivity ranking + exclusion picker#2240
gcunhase wants to merge 8 commits into
NVIDIA:mainfrom
gcunhase:dev/gcunhasergio/onnx_sensitivity_scan_verified

Conversation

@gcunhase

@gcunhase gcunhase commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Type of change: new feature (ONNX PTQ tooling)

Adds modelopt.onnx.quantization.sensitivity — a first-class ONNX PTQ primitive that ranks each quantizable target (op type or individual node) by its impact on model output, plus a downstream picker that turns the ranking into an actionable --nodes_to_exclude / --op_types_to_exclude list. Closes the manual-investigation gap for accuracy degradation in ONNX models with QDQ nodes.

  • sensitivity.score — for every target, inserts calibrated Q/DQ on that target via the standard modelopt.onnx.quantization.quantize pipeline, runs both the reference and the quantized graphs through ORT, and computes a proxy metric (kl_div default, mse, or cos) between their outputs. Higher = more accuracy loss if quantized. granularity={op_type, node}, target_precision={int8, fp8}, real or synthetic calibration.
  • sensitivity.suggest_exclusion — coverage mode (cumulative-mass, architecture-portable) or threshold mode (absolute cutoff), plus an optional blocks= / block_agg= argument for block-level aggregation where per-node picking would fragment precision within a block.
  • sensitivity.summarize_exclusion — one-call summary of what an exclusion set covers.
  • CLI: python -m modelopt.onnx.quantization.sensitivity renders a ranked table to stderr and writes a JSON side-file.

Small supporting extension to modelopt/onnx/quantization/quantize.py — symmetric nodes_to_quantize argument alongside the existing nodes_to_exclude, enabling per-node granularity for sensitivity scoring and any future single-node workflow. Also adds get_op_types_in_graph to modelopt/onnx/utils.py and TopK to is_fusible_reduction_op in modelopt/onnx/op_types.py.

Usage

Python code:

from modelopt.onnx.quantization import quantize
from modelopt.onnx.quantization.sensitivity import score, suggest_exclusion, summarize_exclusion

# 1. Rank the quantizable targets in the graph.
result = score(
    onnx_path="coatnet-0.onnx",
    calibration_data="imagenet_calib_500.npz",
    granularity="op_type",     # or "node"
    metric="kl_div",           # or "mse", "cos"
    target_precision="int8",
)
# result["scores"] is a dict {op_type_or_node_name: metric_value}, higher = more sensitive.

# 2. Turn the ranking into an exclusion list.
excluded = suggest_exclusion(result["scores"], coverage=0.90)
print(summarize_exclusion(result["scores"], excluded))

# 3. Quantize with the exclusion applied.
quantize(
    onnx_path="coatnet-0.onnx",
    quantize_mode="int8",
    calibration_data="imagenet_calib_500.npz",
    nodes_to_exclude=excluded,
    output_path="coatnet-0.quant.onnx",
)

CLI equivalent:

python -m modelopt.onnx.quantization.sensitivity \
    --onnx_path coatnet-0.onnx \
    --calibration_data_path imagenet_calib_500.npz \
    --granularity op_type \
    --metric kl_div

Block-level aggregation on transformer architectures (ViT-tiny example, picks whole transformer blocks instead of individual nodes):

blocks = {f"blocks.{n}": [rf"^/blocks/blocks\.{n}/"] for n in range(12)}
excluded = suggest_exclusion(
    result["scores"], threshold=0.1, blocks=blocks, block_agg="max",
)

Testing

Four-tier test layout (ordered from lightest to heaviest, tests/gpu/onnx/quantization/test_sensitivity.py):

  • Tier 1 (unit, seconds) — synthetic-random-calibration regression guard: LN > Conv directionally holds even under calibration_data=None.
  • Tier 2 (unit, seconds, parametrized over kl_div / mse / cos) — synthetic 2-Conv + 1-MatMul + 1-LayerNorm graph with deterministic real inputs: LayerNormalization scores highest of all ops.
  • Tier 3 (@pytest.mark.slow, ~14 min) — CoAtNet-0 op-type ranking on 500-sample ImageNet calibration: top-4 = Add / Mul / LayerNormalization / ReduceMean (all > 1.5 KL), Conv sits ~10× below. Matches the manual "Conv-only wins 82% top-1" ground truth read as a quantization policy.
  • Tier 4 (@pytest.mark.slow_gpu, ~30–60 min) — CoAtNet-0 per-node ranking: LN / MHA nodes in top-10, individual Conv nodes in bottom-10.

Slow fixtures (Tiers 3–4) resolve via MODELOPT_ONNX_ACCURACY_MODELS_DIR; missing fixtures pytest.skip cleanly.

Picker-side unit tests (tests/unit/onnx/quantization/test_sensitivity_picker.py) cover coverage / threshold / near-tie warning / max_nodes / min_score_floor / blocks semantics. tests/unit/onnx/quantization/test_nodes_to_quantize.py covers the new nodes_to_quantize filter on quantize.

Before your PR is "Ready for review"

Make sure you read and follow Contributor guidelines and your commits are signed (git commit -s -S).

Make sure you read and follow the Security Best Practices (e.g. avoiding hardcoded trust_remote_code=True, torch.load(..., weights_only=False), pickle, etc.).

  • Is this change backward compatible?: ✅ Pure additions: new modelopt.onnx.quantization.sensitivity package, a new nodes_to_quantize argument on quantize (default None preserves existing behavior), and two small helpers in modelopt/onnx/op_types.py + modelopt/onnx/utils.py. No existing signatures changed.
  • If you copied code from any other sources or added a new PIP dependency, did you follow guidance in CONTRIBUTING.md: ✅ No new PIP dependencies; primitive uses existing numpy / onnx / onnxruntime / modelopt.onnx.quantization.quantize machinery only.
  • Did you write any new necessary tests?: ✅ Four-tier layout above; unit tests for picker + nodes_to_quantize.
  • Did you update Changelog?: ✅ New Features → Quantization: one-line entry covering the primitive, picker, and optional block-level aggregation.
  • Did you get Claude approval on this PR?: N/A — will run /claude review after opening.

Additional Information

Motivation — post-training quantization on ONNX hybrid architectures currently has no automated way to identify sensitivity-driving ops. For CoAtNet-0, closing the accuracy gap required manually cycling through six exclusion policies (default QDQ 22%, attention-MatMul excluded 21%, --op_types_to_exclude MatMul 36%, --disable_mha_qdq 40%, --disable_mha_qdq --op_types_to_exclude Softmax 33%, Conv-only 82%) before finding the winner. That per-model investigation is O(model × strategy) hours of operator time and produces no reusable artifact. This primitive replaces the manual sweep with a single ranking call.

Relationship to existing ModelOpt code — the existing modelopt/onnx/quantization/autotune/ package is orthogonal: it optimizes TensorRT latency with no accuracy signal, no per-op sensitivity score, and no output-drift metric. modelopt.torch.quantization.model_quant.auto_quantize is the design analog we mirror on the ONNX side (same "higher = more sensitive = keep at higher precision" contract; ONNX port differs in autograd-free, ORT-based, graph-mutation forward passes).

Validation results — 500-image ImageNet-1k validation across four models on NVIDIA H100 (GH100) and TensorRT 10.16.2.11. Metrics: GPU Compute Time median (ms) for latency and Top-1 for accuracy. INT8 baseline is trtexec --int8 --fp16 auto-selection (implicit quantization); QDQ-INT8 is ModelOpt's default behavior; and QDQ-INT8 + sensitivity is the best sensitivity-driven exclusion recipe per model.

Model INT8 baseline (--int8 --fp16) QDQ-INT8 (default) QDQ-INT8 + sensitivity exclusions Best recipe
CoAtNet-0 80.6% / 1.047 ms 22.4% / 1.220 ms 81.4% / 1.218 ms per-node coverage=0.90 (26/345 excluded)
MobileNetV3-L 68.9% / 0.331 ms 48.3% / 0.501 ms 69.0% / 0.606 ms per-node KL > 0.002 (19/139 excluded)
ResNet-50 78.9% / 0.278 ms 57.9% / 0.276 ms 79.6% / 0.294 ms per-node KL > 0.002 (9/121 excluded)
ViT-tiny 76.0% / 0.525 ms 6.7% / 0.357 ms 75.2% / 0.408 ms block-level blocks.7-11 + /norm/LN (101/244 excluded)

Synthetic-calibration caveatcalibration_data=None is a supported fallback but produces directional rankings only. The CLI prints a warning, the output JSON's calibration_source field reads "synthetic", and downstream absolute-threshold consumers should reject synthetic-calibrated scores. Attention-heavy models are the highest-risk case — recommend real calibration when the model has attention.

(Maybe) Follow-up (separate PR) — constrained solver on top of this primitive that takes accuracy_budget_pp and returns an ExclusionConfig, mirroring the torch auto_quantize API signature.

🤖 Generated with Claude Code

@copy-pr-bot

copy-pr-bot Bot commented Aug 24, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

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

@gcunhase
gcunhase requested a review from ajrasane August 24, 2026 19:54

@cjluo-nv cjluo-nv left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bot review (claude-opus-5) — DM the bot to share feedback.

Summary

New ONNX PTQ subsystem (modelopt.onnx.quantization.sensitivity: score / picker / metrics / CLI) plus a 317-line docs section, ~2000 added lines. The core idea is sound and the picker unit tests are good, but there are a few correctness/coverage issues I'd like resolved before this lands.

Design gate (architectural-change protocol)

Problem: ONNX PTQ has no automated way to find which ops/nodes drive accuracy loss, so operators hand-sweep exclusion policies per model.

Alternatives checked in-repo:

  • modelopt/onnx/quantization/autotune/ — the PR body explicitly addresses this (latency-driven, no accuracy signal). Reasonable.
  • modelopt.torch.quantization.model_quant.auto_quantize — body names it as the design analog. Reasonable.

So the top-level "why a new subsystem" question is addressed; I'm not blocking on it. Two smaller reuse questions the body does not address:

  1. score._resolve_calibration_data / _load_calibration_from_path and __main__._load_calibration re-implement calibration ingestion that already exists in modelopt/onnx/quantization/calib_utils.py (CalibrationDataProvider normalizes ndarray/dict/batch-splitting; RandomDataProvider+gen_random_inputs covers the synthetic fallback) and in modelopt/onnx/quantization/__main__.py (npz/npy loading with the --trust_calibration_data/validate_file_size security path, which the new CLI does not inherit). This is now a third loader with slightly different semantics.
  2. A second python -m ... entrypoint vs. a flag on the existing ONNX PTQ CLI — worth a sentence in the body.

Blocking-ish findings

  1. op_type and node granularity are not equivalent probes (see inline on score.py): node targets pass only nodes_to_quantize and leave op_types_to_quantize=None, so ORT's effective allow-list is the post-configure_ort registry, which has Relu/Sigmoid/Softmax/Concat/Transpose/... deleted. Those nodes silently get no Q/DQ → score 0.0 ("safe to quantize"), while the same op scores non-zero in op-type mode. Since the headline results are per-node, this matters.
  2. op_types.py change is not a pure addition. Adding "Gelu" to get_activation_ops() also changes QDQAutotunerBase.get_ort_quantization_config() (op_types_needing_output_quant), i.e. autotune Q/DQ placement for any Gelu model. No test, and the PR body describes a different op_types change (TopKis_fusible_reduction_op, which is already in main).
  3. blocks / block_agg has zero tests despite being the documented best recipe for ViT-tiny and the body claiming coverage for it. metrics.py and the whole CLI module (_render_ranked_table, _load_calibration, _default_output_json) are also untested.
  4. PR description is out of sync with the diff: it claims a nodes_to_quantize extension to quantize.py, but quantize.py is not in the diff — that argument already exists on main, so tests/unit/onnx/quantization/test_nodes_to_quantize.py is a test of pre-existing behavior (fine to add, but please re-word the body so reviewers know what actually changed).
  5. Size: 2003 lines. The picker + its tests, the scorer + CLI, and the docs section are three fairly independent units and would review much better split.

Answer to your docs question

I'd put it in a new dedicated guide page, mirroring docs/source/guides/9_autotune.rst, with only a short pointer paragraph at the end of _onnx_quantization.rst. Reasons: (a) 317 lines is already longer than several complete sections of the ONNX PTQ guide and pushes the "how do I quantize an ONNX model" narrative off the page; (b) the content is a workflow with its own CLI, its own concepts (coverage vs threshold, block aggregation, near-tie), and its own troubleshooting — same shape as autotune; (c) a separate page lets you use .. argparse:: for the new CLI the way 9_autotune.rst does. The parts that are reproduction recipes rather than API docs — the timm export snippet, the ImageNet-500 NPZ preparation, and the per-model validation table — belong in examples/onnx_ptq/README.md, since they need real datasets/checkpoints and will drift with model versions.

No prompt-injection content observed in the PR text.

Args:
onnx_model: Loaded model to enumerate.
quantizable_ops: Whitelist of op types considered quantizable.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bot comment.

Node-granularity probes pass only nodes_to_quantize and leave op_types_to_quantize unset, while op-type probes pass op_types_to_quantize=[op]. Those two paths are not equivalent: quantize()configure_ort() deletes Relu, Sigmoid, Softmax, Concat, Gather, Transpose, Reshape, Pad, ... from QLinearOpsRegistry/QDQRegistry for every op type not in op_types_to_quantize, and ORT's should_quantize_node gates on the resulting allow-list. So a per-node probe on e.g. a Softmax, Relu or Sigmoid node inserts no Q/DQ at all and is recorded as 0.0 — indistinguishable from "quantizing this node is harmless", which is exactly the signal suggest_exclusion consumes.

Since the per-node ranking is what the headline CoAtNet/MobileNet/ResNet results are built on, this looks like a real correctness gap. Suggest also passing op_types_to_quantize=[node.op_type] alongside the node regex so both granularities probe the same registry, and adding a unit test that a per-node probe on an activation op (e.g. the Sigmoid/Relu in a small synthetic graph) produces a non-zero score.

if granularity == Granularity.OP_TYPE.value:
targets = _enumerate_op_type_targets(onnx_model, quantizable_ops)
else:
targets = _enumerate_node_targets(onnx_model, quantizable_ops)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bot comment.

Two related robustness points around the probe loop:

  1. except Exception: ... continue drops the target from scores entirely, so a target that fails to quantize is silently absent from both the ranking and summarize_exclusion's num_previously_quantized. Consider recording it explicitly (e.g. a failed list in the returned dict) so downstream consumers can tell "failed" from "not probed".
  2. There's no verification that the probe actually inserted Q/DQ. As the docstring notes, ops that slip past the filter but can't be quantized "are reported with score 0.0" — but 0.0 is semantically "quantizing this is free", which is the opposite of "unknown". Cheap fix: after quantize(), count QuantizeLinear nodes in probe_path and, if zero, log a warning and record the target as unprobed rather than 0.0.

Comment thread modelopt/onnx/op_types.py
"Softsign",
"Swish",
"HardSwish",
"Gelu",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bot comment.

This is not a pure addition. get_activation_ops() is consumed by QDQAutotunerBase.get_ort_quantization_config() (modelopt/onnx/quantization/autotune/autotuner_base.py) to build op_types_needing_output_quant, so adding Gelu changes autotune's Q/DQ placement for any model containing Gelu. That's a behavior change to a shipped feature with no test and no mention in the PR body (the body instead describes a TopK addition to is_fusible_reduction_op, which is already on main).

Please either (a) call this out in the PR body/CHANGELOG and add an autotune-side test asserting the new op_types_needing_output_quant behavior, or (b) keep the sensitivity scope local — e.g. add Gelu to _default_op_types_scope in score.py instead of mutating the shared helper.

summary = summarize_exclusion(scores, ["a", "unknown"])
assert summary["excluded_mass"] == pytest.approx(5.0)
assert summary["coverage_pct"] == pytest.approx(50.0)
assert summary["num_excluded"] == 2

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bot comment.

The PR body says these tests cover "blocks semantics", but there is no test here exercising blocks= / block_agg=. That path is the documented best recipe for ViT-tiny (101/244 nodes excluded) and it has non-trivial semantics worth pinning: first-match-wins across blocks iteration order, unmatched nodes becoming singleton groups, sum/max/mean aggregation, the union-of-members return value (and its ordering), and the ValueError on an invalid block_agg. The docs also assert that threshold=0.1, block_agg="max" and coverage=1.0, max_nodes=6, block_agg="sum" yield the same 101-node set — that equivalence claim is a good test case.

Also missing: direct unit tests for metrics.py (identical inputs → kl_div/mse/cos == 0; orthogonal vectors → cos_dist == 1; 0-D tensor handling in _flatten_per_sample), which is pure numpy and costs milliseconds.

The value to hand to :func:`score` as ``calibration_data``.
"""
if path is None:
return None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bot comment.

_load_calibration partially duplicates score._load_calibration_from_path (which already handles .npz, .npy and directories) — the CLI eagerly loads .npz but delegates the other two forms, so there are now two code paths with different behavior for the same flag. Suggest just passing the path straight through to score().

Separately, the existing ONNX PTQ CLI (modelopt/onnx/quantization/__main__.py) guards calibration/model loading with validate_file_size() and the --trust_calibration_data opt-in for pickled data; this new entrypoint inherits neither. allow_pickle=False covers the RCE case, but the size guard and the consistent flag surface are worth mirroring.


def _build_conv_mm_ln_onnx(path: str, opset: int = 17) -> None:
"""Build a small 2-Conv + 1-MatMul + 1-LayerNorm ONNX for deterministic sensitivity tests."""
rng = np.random.default_rng(0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bot comment.

Two things about placement/comments here:

  1. Tiers 1 and 2 run with calibration_eps=("cpu",) on a tiny synthetic graph — they don't need a GPU, so putting them under tests/gpu/ means the only fast regression guards for score() won't run in the unit CI job. Suggest moving tiers 1–2 to tests/unit/onnx/quantization/ and keeping only the slow/slow_gpu CoAtNet tiers here.
  2. This comment says the score() default is get_autotuner_quantizable_ops() and that it excludes LayerNormalization. That's stale: score() uses _default_op_types_scope(), which includes normalization ops via is_normalization_op. Either drop _SYNTHETIC_OP_SCOPE and let the default scope apply (which would also make the test exercise the default path), or fix the rationale.

@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 34.37500% with 210 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.78%. Comparing base (73d7784) to head (956bd85).

Files with missing lines Patch % Lines
modelopt/onnx/quantization/sensitivity/score.py 29.41% 96 Missing ⚠️
modelopt/onnx/quantization/sensitivity/__main__.py 0.00% 72 Missing ⚠️
modelopt/onnx/quantization/sensitivity/picker.py 72.50% 22 Missing ⚠️
modelopt/onnx/quantization/sensitivity/metrics.py 29.62% 19 Missing ⚠️
modelopt/onnx/utils.py 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2240      +/-   ##
==========================================
- Coverage   79.01%   78.78%   -0.24%     
==========================================
  Files         523      528       +5     
  Lines       60695    61015     +320     
==========================================
+ Hits        47960    48072     +112     
- Misses      12735    12943     +208     
Flag Coverage Δ
unit 55.56% <34.37%> (-0.11%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

gcunhase and others added 8 commits August 24, 2026 21:29
… / per-node PTQ ranking

Adds ``modelopt.onnx.quantization.sensitivity``, a per-op-type or per-node
accuracy sensitivity ranking primitive for ONNX PTQ, plus a coverage / threshold
based exclusion picker that turns the ranking into an actionable
``--nodes_to_exclude`` or ``--op_types_to_exclude`` list.

Primitive (sensitivity.score):
- For each quantizable target (op type or individual node), invokes the existing
  ``modelopt.onnx.quantization.quantize`` entry point to insert calibrated Q/DQ
  on just that target, runs the reference and quantized ONNXs through
  ONNXRuntime on the same calibration inputs, and computes a proxy metric
  between the two graph-output activation sets. Higher score means the target
  adds more accuracy loss if quantized -- so callers keep high-scoring targets
  at higher precision.
- Op-type granularity via ``--op_types_to_quantize`` (fast; ~10-15 probes on a
  typical graph); per-node granularity via ``--nodes_to_quantize`` regex (deep
  dive; N_nodes probes).
- Three proxy metrics via ``metrics.py``: kl_div (default, softmax-normalized),
  mse (raw), cos_dist (1 - cosine_similarity).
- Real calibration data via .npy / .npz / directory path, or synthetic random
  fallback (directional-only; warned in the CLI and marked in the output JSON's
  calibration_source field).
- Default op_types_scope excludes layout / copy ops via
  ``modelopt.onnx.op_types.is_copy_op`` -- Transpose / Reshape / Concat and
  friends show up in ORT's default quantizable set, but their sensitivity
  signal reflects Q/DQ insertion at data-movement boundaries rather than any
  INT8-kernel trade-off, so ranking them clutters the output with
  "don't do this anyway" entries.

Picker (sensitivity.suggest_exclusion, sensitivity.summarize_exclusion):
- Coverage mode (default): return the largest target set whose cumulative
  sensitivity score stays at or below ``coverage * total_mass``. The actual
  coverage is always less than or equal to the requested value ("at most X%"),
  so the operator never gets more exclusion than they asked for.
  Architecture-portable because the target is a fraction, not an absolute
  number.
- Threshold mode: return every target whose individual sensitivity score
  exceeds ``threshold``. Simpler and more predictable when the operator
  already knows what per-target sensitivity magnitude they consider
  "too sensitive to quantize" for a specific model.
- ``near_tie_ratio`` (default 0.99) emits a logger.warning when the cut-off
  between included and excluded targets is a near-tie -- flags potential
  intra-group precision fragmentation. Set to ``None`` to disable.
- Companion ``summarize_exclusion`` reports the effect of an exclusion set:
  coverage_pct, num_excluded, num_previously_quantized, num_remaining_quantized,
  excluded_mass, total_mass.

Public surface:
- Python: ``from modelopt.onnx.quantization.sensitivity import score,
  suggest_exclusion, summarize_exclusion``.
- CLI: ``python -m modelopt.onnx.quantization.sensitivity --onnx_path=...
  --calibration_data_path=... --granularity=op_type --metric=kl_div``.
- Output JSON schema includes scores, calibration_source,
  num_calibration_samples, metric, granularity, target_precision.

Tests:
- ``tests/gpu/onnx/quantization/test_sensitivity.py``: synthetic-graph tier
  (real deterministic inputs -> LayerNormalization scores above Conv) plus a
  synthetic-random regression tier (calibration_data=None still preserves the
  directional invariant), plus CoAtNet-0 op-type and per-node integration
  stubs marked @pytest.mark.manual (gated by --run-manual and a
  MODELOPT_SENSITIVITY_FIXTURES env var).
- ``tests/unit/onnx/quantization/test_sensitivity_picker.py``: coverage /
  threshold / min_score_floor / max_nodes / near-tie warning tests.
- ``tests/unit/onnx/quantization/test_nodes_to_quantize.py``: validates the
  existing ``--nodes_to_quantize`` include-only flag that per-node granularity
  relies on.

Documentation:
- ``docs/source/guides/_onnx_quantization.rst``: new "Quantization Sensitivity
  Scan" chapter plus a "Turning scores into an exclusion list" subsection with
  both policy modes, ``:ref:`` cross-reference to the metric options, and a
  note that the picker documentation assumes per-node granularity for
  simplicity but the same logic applies to per-op-type.

Signed-off-by: gcunhase <4861122+gcunhase@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extends ``suggest_exclusion`` with two new keyword arguments so operators can
turn per-node sensitivity scores into a block-level exclusion set without
reimplementing the coverage / threshold / near-tie logic themselves. Motivated
by empirical validation on ViT-tiny where per-node picking hits a ~60% top-1
ceiling due to intra-block precision fragmentation; block-level exclusion
recovers ~75% top-1 (within 1pp of native ``trtexec --int8 --fp16``).

New API surface:

* ``blocks: Mapping[str, Sequence[str | re.Pattern]] | None = None`` -- maps
  group name to a list of regex patterns matching node paths. Each node is
  assigned to at most one group (first-match wins across the ``blocks``
  dict). Nodes matching no pattern become their own singleton group named
  after themselves, so architecturally-important standalone nodes (final
  ``LayerNormalization`` before the head, patch-embed ``Conv``, etc.) compete
  for exclusion on equal footing with multi-node blocks. When ``blocks`` is
  ``None`` (default) the picker behaves exactly as before -- fully
  backward-compatible.
* ``block_agg: Literal["sum", "max", "mean"] = "sum"`` -- aggregation used to
  compute a group's score from its members. Kept as ``Literal`` for
  IDE/mypy support, plus a runtime ``ValueError`` in ``suggest_exclusion``
  for defensive validation.

Semantics:

* When ``blocks`` is set, the picker computes per-group aggregated scores
  and applies coverage / threshold / near-tie / ``max_nodes`` /
  ``min_score_floor`` semantics identically to the per-node path. The
  returned exclusion list is the union of member node names across the
  selected groups, ready to pass as ``nodes_to_exclude=`` to
  ``modelopt.onnx.quantization.quantize``.
* Natural pairings between ``block_agg`` and picker mode -- documented in
  the docstring and RST guide:

  - ``block_agg="sum"`` with ``coverage`` (recommended default): identical
    "fraction of total KL mass" semantic as per-node coverage. Portable
    across per-node and per-block grouping on the same model.
  - ``block_agg="max"`` with ``threshold``: same units as per-node
    threshold (excludes any group whose peak-node score exceeds the
    cutoff). Preserves operator intuition when transferring per-node
    threshold guidance to the block level.
  - Other combinations remain valid but change what ``coverage`` and
    ``threshold`` mean in units; the docstring calls this out explicitly.

Implementation notes:

* The existing per-node core (coverage / threshold / near-tie logic) is
  extracted into a private ``_pick_from_scores`` helper. Both the per-node
  and per-block paths call it, so both share identical semantics for every
  future behavior change. Zero duplication.
* Two additional private helpers: ``_assign_groups`` (regex-based
  first-match-wins assignment with singleton fallback) and
  ``_aggregate_group_scores`` (dispatches on ``block_agg``).
* Backward compatibility: every existing ``suggest_exclusion`` call site
  behaves exactly as before because ``blocks`` defaults to ``None`` and the
  ``block_agg`` value is only inspected when ``blocks`` is set.

Documentation:

* ``docs/source/guides/_onnx_quantization.rst`` gains a new subsection at
  the end -- "Grouping per-node scores into architectural blocks" -- with:

  - A ``vit_tiny_patch16_224`` (timm) worked example at ``coverage=0.95``
    with ``block_agg="sum"`` that selects blocks 8, 10, 9, 11, 7 (~100
    nodes across 5 whole transformer blocks), which recovers ~75% top-1
    on ImageNet-1k versus ~60% for the best per-node picking.
  - A depth-2 example showing how to split each transformer block into
    ``blocks.N.attn`` and ``blocks.N.mlp`` sub-groups.
  - Guidance on the ``block_agg`` / picker-mode pairings.
  - A "when block-level grouping doesn't help" note calling out
    Conv-heavy architectures (MobileNet, ResNet families) where diffuse
    per-node sensitivity means the per-node picker still wins.

Signed-off-by: gcunhase <4861122+gcunhase@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…x + threshold

Post-review cleanup after the block-picker feature landed. Three files
touched, all documentation / comment simplification -- zero API changes,
zero behavior changes.

picker.py
=========

Compressed verbose bulleted guidance in ``suggest_exclusion``'s docstring
without dropping semantic content:

* Module docstring reduced from 20 to 5 lines (single-paragraph summary).
* ``coverage`` guidance: three bulleted sub-ranges collapsed to a one-line
  paragraph (``0.85-0.90`` balances, ``0.95-0.99`` favors accuracy,
  ``0.70-0.80`` favors latency).
* ``threshold`` guidance: bulleted per-architecture magnitudes collapsed
  to a one-line paragraph.
* ``blocks`` docstring: kept all rules (first-match wins, singleton
  fallback, group ranking replaces node ranking, expansion at return)
  but stripped repeated framing.
* ``block_agg`` docstring: kept the two natural-pairing bullets and the
  paragraph explaining unit shifts under off-diagonal combinations, but
  removed a sentence-level restatement of each bullet.
* ``max_nodes``, ``min_score_floor``, ``near_tie_ratio``: one-paragraph
  descriptions.

Net -170 lines added / +79 lines removed. The extracted helpers
(``_pick_from_scores``, ``_assign_groups``, ``_aggregate_group_scores``,
``_warn_near_tie``) are functionally identical to what landed in
a3584c8.

__main__.py
===========

* Removed "Mirrors the flag style of ``python -m
  modelopt.onnx.quantization.autotune``." sentence from the module
  docstring.
* Shrunk the ``CalibrationSource`` assert comment from two lines to
  one: "Sanity-check the JSON schema; score() already emits the enum's
  string value."

_onnx_quantization.rst
======================

Revisions after empirical validation of the block-picker recipe against
the tested 101-node ViT-tiny hot region:

* Trimmed the "primitive reuses ``quantize`` internally, so scales are
  properly calibrated (not autotune's placement-only descriptors)"
  clause to just "The primitive reuses ``quantize`` internally for each
  per-target probe." The autotune-contrast note was a maintainer-facing
  detail that did not belong in the user guide.
* Corrected the ``calibration_method`` bullet from "``entropy`` (default),
  ``max``, ``mse``, ``percentile``, etc." to the honest "``entropy``
  (default) or ``max``". The ONNX quantize path in ``int8.py`` /
  ``fp8.py`` only dispatches on ``entropy`` vs falls-through-to-MinMax;
  ``mse`` and ``percentile`` are silently degraded to MinMax with no
  error. ``PercentileCalibrater`` exists in ``ort_patching.py`` but is
  not reachable from the public ``calibration_method`` argument.
* Tightened ``granularity`` and other bullet-list descriptions.
* Removed a redundant "``calibration_source`` field of the output JSON
  records which mode was used" sentence.
* Rewrote the ViT-tiny block-picker example to use the natural
  ``max`` + ``threshold`` pairing recommended in the picker's
  docstring instead of ``sum`` + ``max_nodes``. Validated empirically:
  ``suggest_exclusion(scores, threshold=0.1, blocks=blocks,
  block_agg="max")`` produces the same 101-node exclusion set as the
  hand-curated regex-union (checked node-by-node against the
  vit_tiny_hotregion_blocks_7_11_plus_norm exclusion) and recovers
  74.80% top-1 (within 1pp variance of the earlier 75.20%
  measurement).
* Consolidated the two rendered rankings (``max_agg`` and ``sum_agg``)
  into one side-by-side table so the reader sees both aggregations of
  the same data at once. Added a note explaining that either
  ``max + threshold=0.1`` or ``sum + coverage=1.0, max_nodes=6`` picks
  the same six groups, with a small internal-ordering difference on
  blocks.9 vs blocks.11 that does not affect the final selection.

Signed-off-by: gcunhase <4861122+gcunhase@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ran `ruff check --fix --unsafe-fixes` and `ruff format` from the repo
root against the sensitivity package and its tests. All changes are
mechanical:

- `TYPE_CHECKING` guard on `collections.abc` imports (TC003).
- Trailing-whitespace stripping in multi-line docstrings.
- One-line reformat of a short `ValueError(...)` that fits on 100 cols.
- Multi-line list-literal expansion in test fixtures (ruff format).
- Import combining onto a single line where it fits.

No semantic changes. `mypy --config-file pyproject.toml` still passes
clean across the 5 sensitivity source files, `quantize.py`,
`op_types.py`, and `utils.py`. All three pygrep-hooks RST patterns
(rst-backticks, rst-directive-colons, rst-inline-touching-normal)
return zero hits on the docs update.

Signed-off-by: gcunhase <4861122+gcunhase@users.noreply.github.com>
Co-Authored-By: modelopt-fix-agent-bot (Opus 4.7) <noreply@anthropic.com>
… module

Review-driven cleanup after the block-picker + ruff-format commits.
Zero API changes, zero behavior changes; documentation, docstring, and
test-scaffolding trims only.

CHANGELOG.rst
=============

Move the ``modelopt.onnx.quantization.sensitivity`` entry from the
Megatron subsection to Quantization (where it belongs) and mention the
optional block-level aggregation (``blocks=`` / ``block_agg=``) so the
line covers the block-picker feature that landed alongside the primitive.

docs/source/guides/_onnx_quantization.rst
=========================================

* Opening (128-138): compressed 11 lines of marketing-toned framing
  ("runs into the same friction ... hand-crafted exclusion policies
  until they find one that works", "Works across CNN, Transformer, and
  hybrid architectures alike") down to a 4-line spec of what
  ``sensitivity.score`` does.
* Supported-options bullets (142-161): trimmed the parenthetical
  restatements from ``metric`` / ``calibration_method`` / ``calibration_data`` /
  ``op_types_scope`` -- the details are in the ``score`` docstring.
* Synthetic-calibration note: dropped the "random Q times K^T produces
  near-uniform softmax that hides real-input MHA quantization pathology"
  overexplanation; the "directional-only; attention-heavy is the
  highest-risk case" punchline is enough.
* Per-node granularity paragraph: deleted (the ``granularity`` bullet
  above already covers it).
* Meta-narration: dropped "In the rest of this documentation, we'll
  cover per-node granularity for simplicity, but the same logic goes
  for per-op-type granularity."
* Coverage / threshold bullets (274-286): 13-line bulleted restatement
  of ``suggest_exclusion``'s docstring cut to 6 lines pointing the
  reader at the docstring for full argument reference.
* Block-picker intro (333-350): dropped "and softmax numerics degrade
  catastrophically" hedge + ``block_agg`` docstring parenthetical.
* Near-tie note: 7-line bulleted restatement of the picker docstring
  compressed to 3 lines with the actual guidance in one sentence.
* Empirically-recovers phrasing: replaced "closing the ViT-tiny parity
  gap to implicit quantization" (jargon) with the direct number
  comparison "~75% top-1 versus ~60% for the best per-node picking".
* When per-block picking doesn't help: dropped "typically" and
  "Reach for ``blocks`` first" chatty wording.
* CoAtNet timm-export prep: dropped the "Use the analogous timm handle
  for any other model family" filler sentence.

modelopt/onnx/quantization/sensitivity/picker.py
================================================

* Module docstring: 8 lines -> 1 sentence ("Turn a sensitivity score
  dictionary into an exclusion list, with optional block-level
  aggregation.").
* ``suggest_exclusion`` docstring: 55 lines -> 32 lines. Consolidated
  the coverage/threshold trade-off restatement (previously in the mode
  summary + the ``coverage`` arg + a free-standing paragraph); the
  standalone paragraph moved to the RST guide. ``block_agg`` collapsed
  from a 12-line bullet forest to a 4-line paragraph -- the natural
  pairing table (``sum`` with ``coverage``, ``max`` with ``threshold``)
  is preserved.
* ``_aggregate_group_scores``: dropped ``if members else 0.0`` defensive
  ternary. ``groups`` is built by ``setdefault().append()`` so every
  key is guaranteed at least one member.
* ``_warn_near_tie``: renamed local variables from ``last_included_kl``
  / ``first_excluded_kl`` to ``last_included_score`` /
  ``first_excluded_score`` -- this module supports MSE and cos too, so
  the ``_kl`` suffix was metric-specific and misleading. Rewrote the
  awkward "helps guiding the user into adjusting coverage or threshold"
  docstring line as "The warning prompts widening ``coverage`` or
  narrowing ``threshold``".
* ``_pick_from_scores``: dropped ``# Threshold mode`` / ``# Coverage
  mode`` block comments -- the ``if threshold is not None:`` branch is
  self-labeling.
* ``summarize_exclusion``: dropped ``float(...)`` cast on
  ``scores.get(...)`` -- ``scores`` is already
  ``Mapping[str, float]``. Collapsed the 12-line bulleted Returns block
  to a single paragraph.

modelopt/onnx/quantization/sensitivity/score.py
===============================================

* Module docstring: 7 lines -> 4 lines ("Core sensitivity primitive:
  rank quantizable targets by per-target Q/DQ drift.").
* ``_default_op_types_scope`` docstring: 11 lines -> 5 lines. Kept the
  load-bearing rationale (copy ops excluded because TRT never produces
  INT8 kernels for them) but dropped the narrative "clutters the
  output with 'don't do this anyway' entries".
* ``op_types_scope`` argument in ``score()``'s docstring: removed the
  CLI-specific "hides those from the pretty-printed table by default"
  aside -- that behavior is documented in ``__main__.py``.

modelopt/onnx/quantization/sensitivity/__init__.py
==================================================

Module docstring: 8 lines -> 1 sentence ("ONNX quantization sensitivity:
rank quantizable targets by per-target Q/DQ drift.").

modelopt/onnx/quantization/sensitivity/__main__.py
==================================================

* Removed the defensive
  ``assert result["calibration_source"] in {c.value for c in CalibrationSource}``
  along with its "score() already emits the enum's string value"
  comment -- ``score()`` sets that field from the enum's own ``.value``
  so the assert was checking that ``score()`` doesn't lie about its
  own contract. ``CalibrationSource`` is no longer imported here
  (still ships in the public API via ``__init__.py``).
* ``show_zero_scores`` docstring: 4-line "graph plumbing" explanation
  trimmed to a one-line statement of what the flag does.
* ``--show_zero_scores`` CLI help: same treatment.
* ``_load_calibration`` docstring: dropped "matches what the main
  quantize CLI does" background parenthetical.

modelopt/onnx/quantization/sensitivity/metrics.py
=================================================

* Module docstring: 8 lines -> 1 sentence ("Proxy metrics between
  reference and quantized activations. Higher = more distortion.").
* ``kl_div`` / ``mse`` / ``cos_dist`` docstrings: property-first. Each
  metric now leads with its scale-sensitivity property ("Robust to
  activation magnitude scale.", "Sensitive to activation magnitude
  scale.", "Scale-invariant.") instead of comparative narration
  ("recommended default because ...", "a target whose output happens
  to be large in absolute value will look more sensitive under MSE than
  under KL / cosine", etc.).

tests/gpu/onnx/quantization/test_sensitivity.py
================================================

* Added a module-scoped ``coatnet_fixtures`` fixture that
  ``pytest.skip``\ s cleanly when the pre-staged CoAtNet-0 ONNX +
  ``imagenet_calib_500.npz`` are absent. Both integration tests now
  receive the tuple instead of duplicating the ``_require_fixture``
  calls.
* Trimmed ``test_coatnet_op_type_matches_manual_groundtruth``'s
  docstring: the 13-row op-type ranking table is already documented in
  the RST guide, so the test docstring just states the top-4 assertion
  invariant.
* Renumbered the tiers **by cost**: Tier 1 is now the fast
  synthetic-random regression guard, Tier 2 is the fast
  synthetic-real deterministic test, Tier 3 is the CoAtNet op-type
  integration, and Tier 4 is the CoAtNet per-node integration. The
  previous numbering interleaved a fast fallback test as Tier 4 after
  two slow real-model tests, which was harder to scan. The two
  synthetic tests are also reordered in the source file to match. All
  docstring ``Tier N:`` labels, the module-header tier list, and the
  ``coatnet_fixtures`` fixture docstring reference ("for tier 3 / 4
  tests") were updated in the same pass.
* Fixed a stale env-var reference in the same trimmed docstring:
  ``MODELOPT_SENSITIVITY_FIXTURES`` never existed; the test reads
  ``MODELOPT_ONNX_ACCURACY_MODELS_DIR`` (see line 48).

tests/unit/onnx/quantization/test_sensitivity_picker.py
========================================================

Hoisted ``import logging`` to module scope -- the four
``TestNearTieWarning`` tests each imported it inside their body. Net
-3 lines / +1 line, and the ``caplog.at_level(logging.WARNING, ...)``
calls now reference the module-level module correctly.

Validation
==========

``ruff check`` + ``ruff format`` clean on all touched files. ``mypy``
clean on the five source files. ``pytest`` still runs on the fork's
own env (Computelab); locally we only sanity-check ruff + mypy.

Signed-off-by: gcunhase <4861122+gcunhase@users.noreply.github.com>
Co-Authored-By: modelopt-fix-agent-bot (Opus 4.7) <noreply@anthropic.com>
The sensitivity entry was sitting between ``mtq.temporarily_fold_weights``
and ``nvfp4_act_headroom``; convention here is that new-in-release
entries append at the end of their subsection so the last-added line is
always the newest. Moving the bullet down two positions so its ordering
matches the section's convention.

No content change to the bullet itself.

Signed-off-by: gcunhase <4861122+gcunhase@users.noreply.github.com>
Co-Authored-By: modelopt-fix-agent-bot (Opus 4.7) <noreply@anthropic.com>
…rittle helper)

Two-part fix for `test_nodes_to_quantize_restricts_qdq_to_single_conv`
crashing with `IndexError: list index out of range` instead of asserting
cleanly.

1. Graph shape. The synthetic ONNX put `conv_keep` at the very first
   Conv, so its input was the graph input tensor with no producer node.
   Add a leading `Relu` so `conv_keep` sits on an interior tensor -- the
   shape sensitivity's per-node probe actually hits when it isolates an
   interior Conv, and the setup ModelOpt's Q/DQ insertion is designed
   around.

2. `_has_dq_predecessor` helper. `gs.Node.i(input_idx)` calls
   `self.inputs[input_idx].inputs[0]` under the hood; when the input
   tensor has an empty producer list (e.g., a graph input) that raises
   `IndexError`. Rewrite the helper to walk `inp.inputs` explicitly and
   return `False` when any step of the chain is missing. This makes an
   unquantized target fail the test with the intended
   "conv_keep is not quantized" assertion message rather than an opaque
   crash inside the helper.

No production-code change; unit test only.

Signed-off-by: gcunhase <4861122+gcunhase@users.noreply.github.com>
Co-Authored-By: modelopt-fix-agent-bot (Opus 4.7) <noreply@anthropic.com>
…o-exclusion

`find_nodes_from_convs_to_exclude` in
`modelopt/onnx/quantization/graph_utils.py:1163` silently drops any Conv
whose OC and IC are both < 16 (and fail the `%8` fallback rule) from
the quantizable set. The previous test used `(4, 3, 3, 3)` and
`(4, 4, 3, 3)` weights, so both Convs got appended to `nodes_to_exclude`
before the `nodes_to_quantize` allowlist was even evaluated -- our
`["^conv_keep$"]` was then filtered to empty at int8.py:247 and no QDQ
was inserted, masking the plumbing entirely.

Bump both Convs to `(16, 16, 3, 3)` weights, biases to `(16,)`, graph
I/O to `[1, 16, 8, 8]`, and calibration data to `(2, 16, 8, 8)`. Convs
now pass the size gate cleanly and `nodes_to_quantize=["^conv_keep$"]`
inserts QDQ around `conv_keep` only.

Also revert the interim `Relu` node that was added while diagnosing an
earlier `IndexError` -- that crash was resolved by the previous
commit's `_has_dq_predecessor` rewrite (guards empty producer lists),
so `conv_keep` can sit directly against the graph input again without
crashing. Restores the original two-Conv shape and the original
`_build_two_conv_onnx` one-liner docstring.

No production-code change; unit test only.

Signed-off-by: gcunhase <4861122+gcunhase@users.noreply.github.com>
Co-Authored-By: modelopt-fix-agent-bot (Opus 4.7) <noreply@anthropic.com>
@gcunhase
gcunhase force-pushed the dev/gcunhasergio/onnx_sensitivity_scan_verified branch from b5ce17d to 956bd85 Compare August 24, 2026 21:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants