Skip to content

Commit 1db66b8

Browse files
xadupregithub-advanced-security[bot]CopilotCopilot
authored
add a pass to measure the discrepancies on the test model (microsoft#2478)
## Describe your changes Add a pass (`OnnxDiscrepancyCheck`) to measure the discrepancies between the torch model and the ONNX model. It measures the max absolute error and other metrics. It can fail if it is too high. When `--test` is used with `olive run`, the `OnnxDiscrepancyCheck` pass is automatically injected into the run config using the test model path as the reference model. Also refined the discrepancy pass behavior for dynamic input shapes: unsupported symbolic dimensions still fail fast by design, but now with a more explicit error message that includes the unknown symbol, the full shape, and known symbols to simplify debugging. ## Checklist before requesting a review - [ ] Add unit tests for this change. - [x] Make sure all tests can pass. - [ ] Update documents if necessary. - [x] Lint and apply fixes to your code by running `lintrunner -a` - [ ] Is this a user-facing change? If yes, give a description of this change to be included in the release notes. ## (Optional) Issue link --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent a0e8805 commit 1db66b8

6 files changed

Lines changed: 300 additions & 1 deletion

File tree

olive/cli/base.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,28 @@ def mark_test_output_path(output_path: Optional[str]) -> None:
6464
_get_test_output_marker_path(output_path).write_text(json.dumps({"type": "olive_hf_test_output"}, indent=2))
6565

6666

67+
def add_discrepancy_check_pass(run_config: dict) -> dict:
68+
"""Inject OnnxDiscrepancyCheck pass when --test is active and not already configured."""
69+
passes = run_config.get("passes", {})
70+
# Skip if already configured
71+
for pass_config in passes.values():
72+
if isinstance(pass_config, dict) and pass_config.get("type", "").lower() == "onnxdiscrepancycheck":
73+
return run_config
74+
75+
# Get the reference model path from the input_model test_model_path
76+
input_model = run_config.get("input_model", {})
77+
reference_model_path = input_model.get("test_model_path")
78+
if not reference_model_path:
79+
return run_config
80+
81+
passes["discrepancy_check"] = {
82+
"type": "OnnxDiscrepancyCheck",
83+
"reference_model_path": reference_model_path,
84+
}
85+
run_config["passes"] = passes
86+
return run_config
87+
88+
6789
class BaseOliveCLICommand(ABC):
6890
allow_unknown_args: ClassVar[bool] = False
6991

@@ -84,6 +106,8 @@ def _run_workflow(self):
84106

85107
with tempfile.TemporaryDirectory(prefix="olive-cli-tmp-", dir=self.args.output_path) as tempdir:
86108
run_config = self._get_run_config(tempdir)
109+
if getattr(self.args, "test", None) not in (None, False):
110+
run_config = add_discrepancy_check_pass(run_config)
87111
if self.args.save_config_file or self.args.dry_run:
88112
self._save_config_file(run_config)
89113
if self.args.dry_run:

olive/cli/run.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from olive.cli.base import (
88
BaseOliveCLICommand,
9+
add_discrepancy_check_pass,
910
add_hf_test_model_config,
1011
add_input_model_options,
1112
add_logging_options,
@@ -81,6 +82,8 @@ def run(self):
8182

8283
output_path = run_config.get("output_dir") or run_config.get("engine", {}).get("output_dir")
8384
validate_test_output_path(output_path, self.args.test)
85+
if self.args.test not in (None, False):
86+
run_config = add_discrepancy_check_pass(run_config)
8487
workflow_output = olive_run(
8588
run_config,
8689
list_required_packages=self.args.list_required_packages,

olive/olive_config.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,14 @@
301301
"supported_quantization_encodings": [ ],
302302
"dataset": "dataset_optional"
303303
},
304+
"OnnxDiscrepancyCheck": {
305+
"module_path": "olive.passes.onnx.discrepancy_check.OnnxDiscrepancyCheck",
306+
"supported_providers": [ "*" ],
307+
"supported_accelerators": [ "*" ],
308+
"supported_precisions": [ "*" ],
309+
"supported_algorithms": [ ],
310+
"supported_quantization_encodings": [ ]
311+
},
304312
"OnnxDynamicQuantization": {
305313
"module_path": "olive.passes.onnx.quantization.OnnxDynamicQuantization",
306314
"supported_providers": [ "CPUExecutionProvider" ],
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
# -------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License.
4+
# --------------------------------------------------------------------------
5+
import logging
6+
from typing import Optional
7+
8+
from olive.data.config import DataConfig
9+
from olive.hardware import AcceleratorSpec
10+
from olive.model import ONNXModelHandler
11+
from olive.passes import Pass
12+
from olive.passes.pass_config import BasePassConfig, PassConfigParam
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
def _infer_shape(dynamic_shape, known_values=None):
18+
default_values = {
19+
"batch_size": 1,
20+
"past_sequence_length": 2,
21+
"total_sequence_length": 3,
22+
"sequence_length": 1,
23+
}
24+
if known_values:
25+
default_values.update(known_values)
26+
inferred_shape = []
27+
for dim in dynamic_shape:
28+
if isinstance(dim, int):
29+
inferred_shape.append(dim)
30+
continue
31+
if dim not in default_values:
32+
raise KeyError(
33+
f"Unsupported symbolic dimension '{dim}' in shape {dynamic_shape}. "
34+
f"Known symbols are: {sorted(default_values)}. "
35+
"Update OnnxDiscrepancyCheck to handle this new case."
36+
)
37+
inferred_shape.append(default_values[dim])
38+
return tuple(inferred_shape)
39+
40+
41+
class OnnxDiscrepancyCheck(Pass):
42+
"""Validates ONNX model outputs against a reference PyTorch model.
43+
44+
This pass does not transform the model. It runs inference on both the
45+
ONNX model and a reference PyTorch/HuggingFace model with the same inputs,
46+
then compares outputs element-wise. It reports:
47+
- Maximum absolute error (MaxAE)
48+
- Number of elements where the absolute difference exceeds 0.1
49+
- Number of elements where the absolute difference exceeds 0.01
50+
51+
The pass fails if any configured threshold is exceeded.
52+
"""
53+
54+
@classmethod
55+
def _default_config(cls, accelerator_spec: AcceleratorSpec) -> dict[str, PassConfigParam]:
56+
return {
57+
"reference_model_path": PassConfigParam(
58+
type_=str,
59+
required=True,
60+
description="Path to the reference PyTorch/HuggingFace model to compare against.",
61+
),
62+
"max_mae": PassConfigParam(
63+
type_=Optional[float],
64+
default_value=None,
65+
description=(
66+
"Maximum acceptable absolute error. "
67+
"If the max absolute difference exceeds this value, the pass fails."
68+
),
69+
),
70+
"max_elements_above_0_1": PassConfigParam(
71+
type_=Optional[int],
72+
default_value=None,
73+
description=(
74+
"Maximum acceptable number of elements with absolute difference > 0.1. If exceeded, the pass fails."
75+
),
76+
),
77+
"max_elements_above_0_01": PassConfigParam(
78+
type_=Optional[int],
79+
default_value=None,
80+
description=(
81+
"Maximum acceptable number of elements with absolute difference > 0.01. "
82+
"If exceeded, the pass fails."
83+
),
84+
),
85+
}
86+
87+
def _run_for_config(
88+
self, model: ONNXModelHandler, config: type[BasePassConfig], output_model_path: str
89+
) -> ONNXModelHandler:
90+
import numpy as np
91+
import torch
92+
93+
from olive.common.config_utils import validate_config
94+
from olive.common.utils import format_data
95+
from olive.data.template import dummy_data_config_template
96+
from olive.model.config.io_config import is_io_config_static
97+
98+
io_config = model.io_config
99+
if io_config:
100+
if is_io_config_static(io_config):
101+
input_shapes = io_config.get("input_shapes")
102+
else:
103+
input_shapes = []
104+
known = {}
105+
for shape in io_config.get("input_shapes"):
106+
new_shape = _infer_shape(shape, known)
107+
input_shapes.append(new_shape)
108+
known.update(dict(zip(shape, new_shape)))
109+
data_config = dummy_data_config_template(
110+
input_shapes, io_config.get("input_names"), io_config.get("input_types")
111+
)
112+
data_config = validate_config(data_config, DataConfig)
113+
data_config.load_dataset_config.params["max_samples"] = 1
114+
else:
115+
raise RuntimeError(
116+
f"Model IO config is missing for {model.model_path}; cannot generate dummy inputs for discrepancy check."
117+
)
118+
# Create dataloader
119+
dc = data_config.to_data_container()
120+
dataloader = dc.create_dataloader()
121+
122+
# Load reference PyTorch model
123+
from transformers import AutoConfig, AutoModelForCausalLM
124+
125+
ref_cfg = AutoConfig.from_pretrained(config.reference_model_path)
126+
architectures = getattr(ref_cfg, "architectures", None) or []
127+
if not any("ForCausalLM" in arch for arch in architectures):
128+
raise ValueError(
129+
"OnnxDiscrepancyCheck currently supports only HuggingFace causal language models (ForCausalLM). "
130+
f"Got architectures={architectures}"
131+
)
132+
133+
ref_model = AutoModelForCausalLM.from_pretrained(config.reference_model_path)
134+
ref_model.eval()
135+
136+
# Prepare ONNX session
137+
session = model.prepare_session()
138+
io_config = model.io_config
139+
140+
# Run inference on both and compare
141+
all_max_abs_diff = []
142+
all_count_above_0_1 = []
143+
all_count_above_0_01 = []
144+
total_elements = 0
145+
146+
with torch.no_grad():
147+
for batch in dataloader:
148+
# Extract input data (batch may be (data, label) or just data)
149+
input_data = batch[0] if isinstance(batch, (tuple, list)) else batch
150+
151+
# Run PyTorch inference
152+
if isinstance(input_data, dict):
153+
torch_inputs = {k: v.clone() for k, v in input_data.items()}
154+
else:
155+
torch_inputs = input_data
156+
157+
torch_output = ref_model(**torch_inputs)
158+
torch_logits = torch_output.logits.detach().cpu().numpy()
159+
# Run ONNX inference
160+
onnx_input_feed = format_data(input_data, io_config)
161+
onnx_outputs = session.run(None, onnx_input_feed)
162+
onnx_logits = onnx_outputs[0]
163+
164+
# Compute element-wise differences
165+
abs_diff = np.abs(torch_logits.astype(np.float64) - onnx_logits.astype(np.float64))
166+
all_max_abs_diff.append(float(np.max(abs_diff)))
167+
all_count_above_0_1.append(int(np.sum(abs_diff > 0.1)))
168+
all_count_above_0_01.append(int(np.sum(abs_diff > 0.01)))
169+
total_elements += abs_diff.size
170+
171+
max_abs_error = max(all_max_abs_diff)
172+
count_above_0_1 = sum(all_count_above_0_1)
173+
count_above_0_01 = sum(all_count_above_0_01)
174+
175+
logger.info(
176+
"OnnxDiscrepancyCheck: max_abs_error=%.6f, elements_above_0.1=%d/%d, elements_above_0.01=%d/%d",
177+
max_abs_error,
178+
count_above_0_1,
179+
total_elements,
180+
count_above_0_01,
181+
total_elements,
182+
)
183+
184+
# Check thresholds
185+
failures = []
186+
if config.max_mae is not None and max_abs_error > config.max_mae:
187+
failures.append(f"Max absolute error {max_abs_error:.6f} exceeds threshold {config.max_mae:.6f}")
188+
if config.max_elements_above_0_1 is not None and count_above_0_1 > config.max_elements_above_0_1:
189+
failures.append(
190+
f"Elements with diff > 0.1: {count_above_0_1} exceeds threshold {config.max_elements_above_0_1}"
191+
)
192+
if config.max_elements_above_0_01 is not None and count_above_0_01 > config.max_elements_above_0_01:
193+
failures.append(
194+
f"Elements with diff > 0.01: {count_above_0_01} exceeds threshold {config.max_elements_above_0_01}"
195+
)
196+
197+
if failures:
198+
raise RuntimeError("ONNX model discrepancy check failed:\n" + "\n".join(f" - {f}" for f in failures))
199+
200+
# Return the model unchanged
201+
return model

test/cli/test_cli.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,16 +172,23 @@ def test_workflow_run_command_with_test_override(mock_run, tmp_path):
172172

173173
cli_main(command_args)
174174

175+
test_model_path = str(tmp_path / "output" / "test_model")
175176
mock_run.assert_called_once_with(
176177
{
177178
"input_model": {
178179
"type": "HfModel",
179180
"model_path": "hf-internal-testing/tiny-random-LlamaForCausalLM",
180181
"load_kwargs": {"attn_implementation": "eager", "trust_remote_code": False},
181182
"test_model_config": {"hidden_layers": 2},
182-
"test_model_path": str(tmp_path / "output" / "test_model"),
183+
"test_model_path": test_model_path,
183184
},
184185
"output_dir": str(tmp_path / "output"),
186+
"passes": {
187+
"discrepancy_check": {
188+
"type": "OnnxDiscrepancyCheck",
189+
"reference_model_path": test_model_path,
190+
}
191+
},
185192
},
186193
list_required_packages=False,
187194
package_config=None,

test/cli/test_cli_test_model_smoke.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ def _run_documented_test_model_smoke_flow(tmp_path: Path, model_id: str):
9494
run_output_dir = tmp_path / f"{model_name}-test-run"
9595

9696
_save_local_tiny_llama(model_path)
97+
# optimize -m arnir0/Tiny-LLM --device cpu --provider CPUExecutionProvider --precision int4 --output_path dump --dry_run
9798
_run_cli_main(
9899
[
99100
"optimize",
@@ -114,6 +115,7 @@ def _run_documented_test_model_smoke_flow(tmp_path: Path, model_id: str):
114115
config_path = config_output_dir / "config.json"
115116
assert config_path.exists()
116117
_set_offline_gptq_data_config(config_path)
118+
# run --config dump/config.json --test dump/test --output_path dump/run
117119
_run_cli_main(
118120
[
119121
"run",
@@ -170,6 +172,60 @@ def _assert_smoke_flows(self, tmp_path: Path):
170172
if "model.onnx.data" in run_output_files:
171173
self._assert_file_size_below_limit(run_output_dir / "model.onnx.data")
172174

175+
def test_model_discrepancy(self):
176+
"""Verify that OnnxDiscrepancyCheck runs successfully when auto-injected via --test."""
177+
if self.workdir is None:
178+
with tempfile.TemporaryDirectory() as temp_dir:
179+
self._assert_discrepancy(Path(temp_dir))
180+
else:
181+
workdir = Path(self.workdir)
182+
workdir.mkdir(parents=True, exist_ok=True)
183+
self._assert_discrepancy(workdir)
184+
185+
def _assert_discrepancy(self, tmp_path: Path):
186+
for model_id in self.model_ids:
187+
with self.subTest(model_id=model_id):
188+
model_name = model_id.replace("/", "--")
189+
model_path = tmp_path / "models" / f"{model_name}-disc"
190+
config_output_dir = tmp_path / f"{model_name}-disc-cfg"
191+
test_model_dir = tmp_path / f"{model_name}-disc-test-model"
192+
run_output_dir = tmp_path / f"{model_name}-disc-run"
193+
194+
_save_local_tiny_llama(model_path)
195+
_run_cli_main(
196+
[
197+
"optimize",
198+
"-m",
199+
str(model_path),
200+
"--device",
201+
"cpu",
202+
"--provider",
203+
"CPUExecutionProvider",
204+
"--precision",
205+
"int4",
206+
"--output_path",
207+
str(config_output_dir),
208+
"--dry_run",
209+
]
210+
)
211+
212+
config_path = config_output_dir / "config.json"
213+
assert config_path.exists()
214+
_set_offline_gptq_data_config(config_path)
215+
216+
# Run with --test; OnnxDiscrepancyCheck is auto-injected and reports discrepancy metrics (fails only if thresholds are configured)
217+
_run_cli_main(
218+
[
219+
"run",
220+
"--config",
221+
str(config_path),
222+
"--test",
223+
str(test_model_dir),
224+
"--output_path",
225+
str(run_output_dir),
226+
]
227+
)
228+
173229
def _assert_file_size_below_limit(self, path: Path):
174230
assert path.exists()
175231
assert path.stat().st_size < MAX_ARTIFACT_SIZE_BYTES

0 commit comments

Comments
 (0)