e904b667c6
Build/Publish Develop Docs / deploy (push) Failing after 1s
PaddleOCR Code Style Check / check-code-style (push) Failing after 1s
PaddleOCR PR Tests GPU / detect-changes (push) Failing after 1s
PaddleOCR PR Tests / detect-changes (push) Failing after 1s
PaddleOCR PR Tests GPU / test-pr-gpu (push) Has been cancelled
PaddleOCR PR Tests / test-pr (push) Has been cancelled
PaddleOCR PR Tests GPU / test-pr-gpu-impl (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.13) (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.8) (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.9) (push) Has been cancelled
322 lines
11 KiB
Python
Executable File
322 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""QDQ quantization debug: align with ONNX Runtime ``qdq_loss_debug`` (official example flow).
|
|
|
|
This mirrors the *Debugging* section of:
|
|
https://github.com/microsoft/onnxruntime-inference-examples/blob/main/quantization/image_classification/cpu/ReadMe.md
|
|
|
|
Workflow:
|
|
1. Augment float32 and QDQ models to expose intermediate tensors
|
|
(``modify_model_output_intermediate_tensors``).
|
|
2. Run both with the same calibration ``.npy`` inputs
|
|
(``collect_activations``), matching ``quantize_onnx_model`` / ``build_onnx_calib_npy`` data.
|
|
3. ``create_activation_matching`` + SQNR; ``create_weight_matching`` + per-weight SQNR.
|
|
4. Print a report and optionally write JSON (scalar metrics only).
|
|
|
|
*float* should be the **same** float graph you quantized (ideally the ORT
|
|
``quant_pre_process`` output if you used ``--ort-preprocess`` in ``quantize_onnx_model.py``),
|
|
or the graph is unlikely to line-tensor match the QDQ model.
|
|
|
|
Example:
|
|
python3 scripts/debug_onnx_qdq.py \\
|
|
--float-model /path/to/float_infer.onnx \\
|
|
--qdq-model /path/to/inference_int8.onnx \\
|
|
--calib-data-dir /path/to/calib_npy \\
|
|
--max-samples 3 \\
|
|
--json-report qdq_debug.json
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
_script_dir = Path(__file__).resolve().parent
|
|
if str(_script_dir) not in sys.path:
|
|
sys.path.insert(0, str(_script_dir))
|
|
|
|
from utils import build_npy_dir_reader, die
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _parse_op_types(s: str | None) -> list[str] | None:
|
|
if not s or not s.strip():
|
|
return None
|
|
return [t.strip() for t in s.split(",") if t.strip()]
|
|
|
|
|
|
def _safe_compute_weight_error(
|
|
wmatch: dict[str, dict[str, Any]],
|
|
err_func: Any,
|
|
) -> dict[str, float]:
|
|
"""Like ORT ``compute_weight_error``, but coerce scalars to 0-d arrays.
|
|
|
|
``create_weight_matching`` can set ``dequantized`` to a bare ``numpy.float32``; ORT
|
|
``compute_signal_to_quantization_noice_ratio`` then does ``len(ylist)`` on that scalar
|
|
and raises ``TypeError``.
|
|
"""
|
|
import numpy as np
|
|
|
|
out: dict[str, float] = {}
|
|
for name, m in wmatch.items():
|
|
try:
|
|
xf = np.asarray(m["float"])
|
|
yd = np.asarray(m["dequantized"])
|
|
except (KeyError, TypeError, ValueError) as e:
|
|
logger.warning("weight SQNR skip %r: %s", name, e)
|
|
continue
|
|
if xf.size == 0 and yd.size == 0:
|
|
continue
|
|
if xf.size != yd.size:
|
|
logger.warning(
|
|
"weight SQNR skip %r: size mismatch %s vs %s",
|
|
name,
|
|
getattr(xf, "shape", xf),
|
|
getattr(yd, "shape", yd),
|
|
)
|
|
continue
|
|
try:
|
|
out[name] = float(err_func(xf, yd))
|
|
except (TypeError, ValueError, RuntimeError) as e:
|
|
logger.warning("weight SQNR skip %r: %s", name, e)
|
|
return out
|
|
|
|
|
|
def _safe_compute_activation_error(
|
|
match: dict[str, dict[str, Any]],
|
|
err_func: Any,
|
|
) -> dict[str, dict[str, float]]:
|
|
"""Like ORT ``compute_activation_error``, but skip missing *float* entries (avoids KeyError)."""
|
|
result: dict[str, dict[str, float]] = {}
|
|
for name, m in match.items():
|
|
e: dict[str, float] = {
|
|
"qdq_err": float(err_func(m["pre_qdq"], m["post_qdq"])),
|
|
}
|
|
if m.get("float"):
|
|
e["xmodel_err"] = float(err_func(m["float"], m["post_qdq"]))
|
|
result[name] = e
|
|
return result
|
|
|
|
|
|
def _sqnr_key(entry: dict[str, float], prefer_xmodel: bool) -> float:
|
|
if prefer_xmodel and "xmodel_err" in entry:
|
|
return entry["xmodel_err"]
|
|
return entry.get("qdq_err", float("nan"))
|
|
|
|
|
|
def _run(
|
|
float_model: Path,
|
|
qdq_model: Path,
|
|
calib: Path,
|
|
max_samples: int,
|
|
op_types: list[str] | None,
|
|
json_report: Path | None,
|
|
top_n: int,
|
|
skip_weights: bool,
|
|
skip_activations: bool,
|
|
) -> None:
|
|
from onnxruntime.quantization.qdq_loss_debug import (
|
|
collect_activations,
|
|
compute_signal_to_quantization_noice_ratio,
|
|
create_activation_matching,
|
|
create_weight_matching,
|
|
modify_model_output_intermediate_tensors,
|
|
)
|
|
|
|
if not float_model.is_file():
|
|
die(f"not a file: {float_model}")
|
|
if not qdq_model.is_file():
|
|
die(f"not a file: {qdq_model}")
|
|
if not skip_activations:
|
|
if not calib.is_dir():
|
|
die(f"not a directory: {calib}")
|
|
|
|
out_weights: dict[str, float] = {}
|
|
out_act: dict[str, dict[str, float]] = {}
|
|
|
|
if not skip_weights:
|
|
logger.info("weight matching (no inference run)…")
|
|
try:
|
|
wmatch = create_weight_matching(str(float_model), str(qdq_model))
|
|
except Exception as e:
|
|
die(f"create_weight_matching failed: {e}")
|
|
out_weights = _safe_compute_weight_error(
|
|
wmatch, compute_signal_to_quantization_noice_ratio
|
|
)
|
|
print("\n--- Weight SQNR (dB, higher is better) ---\n")
|
|
for name in sorted(out_weights, key=lambda x: out_weights[x]):
|
|
print(f" {name}: {out_weights[name]:.4f}")
|
|
|
|
if not skip_activations:
|
|
with tempfile.TemporaryDirectory(prefix="ort.qdq.debug.") as td:
|
|
tmp = Path(td)
|
|
aug_f = tmp / "augmented_float.onnx"
|
|
aug_q = tmp / "augmented_qdq.onnx"
|
|
op_arg = op_types if op_types is not None else []
|
|
logger.info("augmenting float model → %s", aug_f)
|
|
try:
|
|
modify_model_output_intermediate_tensors(
|
|
str(float_model), str(aug_f), op_types_for_saving=op_arg
|
|
)
|
|
except Exception as e:
|
|
die(f"modify_model_output_intermediate_tensors (float) failed: {e}")
|
|
logger.info("augmenting QDQ model → %s", aug_q)
|
|
try:
|
|
modify_model_output_intermediate_tensors(
|
|
str(qdq_model), str(aug_q), op_types_for_saving=op_arg
|
|
)
|
|
except Exception as e:
|
|
die(f"modify_model_output_intermediate_tensors (qdq) failed: {e}")
|
|
|
|
r_float = build_npy_dir_reader(float_model, calib, max_samples=max_samples)
|
|
r_qdq = build_npy_dir_reader(qdq_model, calib, max_samples=max_samples)
|
|
try:
|
|
logger.info("collect_activations (float)…")
|
|
act_f = collect_activations(str(aug_f), r_float)
|
|
logger.info("collect_activations (QDQ)…")
|
|
act_q = collect_activations(str(aug_q), r_qdq)
|
|
except Exception as e:
|
|
die(f"collect_activations failed: {e}")
|
|
|
|
match = create_activation_matching(act_q, act_f)
|
|
if not match:
|
|
print(
|
|
"\nwarning: empty activation match — is the quantized model QDQ (not QOperator)?",
|
|
file=sys.stderr,
|
|
)
|
|
out_act = _safe_compute_activation_error(
|
|
match, compute_signal_to_quantization_noice_ratio
|
|
)
|
|
has_xm = any("xmodel_err" in v for v in out_act.values())
|
|
scored = sorted(
|
|
out_act.items(),
|
|
key=lambda kv: _sqnr_key(kv[1], prefer_xmodel=has_xm),
|
|
)[: top_n if top_n > 0 else len(out_act)]
|
|
which = "xmodel_err (float vs post_qdq)" if has_xm else "qdq_err (q dq pair)"
|
|
print(
|
|
f"\n--- Top activation SQNR (dB, {which}; lower = worse) — showing "
|
|
f"{len(scored)} of {len(out_act)} —\n"
|
|
)
|
|
for name, m in scored:
|
|
if has_xm and "xmodel_err" in m:
|
|
print(
|
|
f" {name}: pre/post_qdq {m['qdq_err']:.4f} "
|
|
f"float vs post_qdq {m['xmodel_err']:.4f}"
|
|
)
|
|
else:
|
|
print(f" {name}: pre/post_qdq {m['qdq_err']:.4f}")
|
|
|
|
if json_report is not None:
|
|
payload: dict[str, Any] = {
|
|
"weight_sqnr_db": out_weights,
|
|
"activation_sqnr_db": out_act,
|
|
}
|
|
json_report = json_report.resolve()
|
|
json_report.parent.mkdir(parents=True, exist_ok=True)
|
|
with json_report.open("w", encoding="utf-8") as f:
|
|
json.dump(payload, f, indent=2, sort_keys=True)
|
|
print(f"\nwrote {json_report}")
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser(
|
|
description="ORT-style QDQ debug: float vs QDQ activations/weights (same .npy calib as quantize).",
|
|
)
|
|
ap.add_argument(
|
|
"--float-model",
|
|
type=Path,
|
|
required=True,
|
|
help="Float32 ONNX (same topology as the graph you quantized; use ORT preprocessed model if you used it before quantize).",
|
|
)
|
|
ap.add_argument(
|
|
"--qdq-model",
|
|
type=Path,
|
|
required=True,
|
|
help="Statically quantized (QDQ) ONNX model, e.g. from quantize_onnx_model.py.",
|
|
)
|
|
ap.add_argument(
|
|
"--calib-data-dir",
|
|
type=Path,
|
|
default=None,
|
|
help="Directory of float32 .npy used for calibration (required unless --skip-activations).",
|
|
)
|
|
ap.add_argument(
|
|
"--max-samples",
|
|
type=int,
|
|
default=5,
|
|
help="Max number of .npy files to use (default 5; full runs can be slow and memory-heavy).",
|
|
)
|
|
ap.add_argument(
|
|
"--op-types",
|
|
type=str,
|
|
default="",
|
|
help="Comma-separated op types to save intermediates for (e.g. Conv,Add). Empty = all float tensors (very slow and large).",
|
|
)
|
|
ap.add_argument(
|
|
"--json-report",
|
|
type=Path,
|
|
default=None,
|
|
help="Write metrics-only JSON to this path.",
|
|
)
|
|
ap.add_argument(
|
|
"--top",
|
|
type=int,
|
|
default=40,
|
|
help="How many activations to print (sorted worst-first by SQNR). 0 = print all.",
|
|
)
|
|
ap.add_argument(
|
|
"--skip-weights",
|
|
action="store_true",
|
|
help="Only run activation path (faster if you only care about activations).",
|
|
)
|
|
ap.add_argument(
|
|
"--skip-activations",
|
|
action="store_true",
|
|
help="Only run weight matching (no collect_activations; ignores calib).",
|
|
)
|
|
args = ap.parse_args()
|
|
|
|
if args.skip_weights and args.skip_activations:
|
|
die("cannot set both --skip-weights and --skip-activations")
|
|
if not args.skip_activations:
|
|
if args.calib_data_dir is None:
|
|
die("activation path requires --calib-data-dir")
|
|
if args.max_samples < 1:
|
|
die("--max-samples must be >= 1 for activation path")
|
|
|
|
_run(
|
|
float_model=args.float_model,
|
|
qdq_model=args.qdq_model,
|
|
calib=args.calib_data_dir or Path(),
|
|
max_samples=args.max_samples,
|
|
op_types=_parse_op_types(args.op_types),
|
|
json_report=args.json_report,
|
|
top_n=args.top,
|
|
skip_weights=args.skip_weights,
|
|
skip_activations=args.skip_activations,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|