Skip to content

Commit c24e907

Browse files
xadupregithub-advanced-security[bot]Copilot
authored
Add ONNX Runtime GenAI generation comparison in OnnxDiscrepancyCheck (microsoft#2487)
## Describe your changes Extended `OnnxDiscrepancyCheck` to optionally run ONNX Runtime GenAI generation and compare it with the reference transformers generation using the longest common prefix of token IDs. Also incorporated follow-up fixes from review feedback: - Updated tests to correctly mock the function-local `onnxruntime_genai` import via `patch.dict(sys.modules, ...)`. - Flattened nested `with` usage in tests to satisfy linting. - Set `generate_max_new_tokens` default to `32` so GenAI generation comparison runs with a practical default. - Switched the GenAI generation path to the latest `onnxruntime-genai` API by using `generator.append_tokens(...)` and removing deprecated `params.input_ids` / `compute_logits()` usage. - Added a clear optional-dependency error message when `onnxruntime-genai` is not installed. - Updated tests to validate `append_tokens(...)` is invoked with the expected prompt token IDs. ## Checklist before requesting a review - [ ] Add unit tests for this change. - [ ] Make sure all tests can pass. - [ ] Update documents if necessary. - [ ] 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>
1 parent 0e32fd8 commit c24e907

2 files changed

Lines changed: 265 additions & 0 deletions

File tree

olive/passes/onnx/discrepancy_check.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,20 @@ def _infer_shape(dynamic_shape, known_values=None):
3838
return tuple(inferred_shape)
3939

4040

41+
def _longest_common_token_sequence(seq_a: list[int], seq_b: list[int]) -> int:
42+
"""Compute the length of the longest common token sequence starting from the beginning.
43+
44+
Counts how many tokens match consecutively from the start of both sequences
45+
before the first divergence.
46+
"""
47+
length = 0
48+
for a, b in zip(seq_a, seq_b):
49+
if a != b:
50+
break
51+
length += 1
52+
return length
53+
54+
4155
class OnnxDiscrepancyCheck(Pass):
4256
"""Validates ONNX model outputs against a reference PyTorch model.
4357
@@ -47,6 +61,8 @@ class OnnxDiscrepancyCheck(Pass):
4761
- Maximum absolute error (MaxAE)
4862
- Number of elements where the absolute difference exceeds 0.1
4963
- Number of elements where the absolute difference exceeds 0.01
64+
- Longest common token sequence from the beginning between transformers
65+
generate and ONNX Runtime GenAI generate (when enabled)
5066
5167
The pass fails if any configured threshold is exceeded.
5268
"""
@@ -82,6 +98,34 @@ def _default_config(cls, accelerator_spec: AcceleratorSpec) -> dict[str, PassCon
8298
"If exceeded, the pass fails."
8399
),
84100
),
101+
"genai_model_path": PassConfigParam(
102+
type_=Optional[str],
103+
default_value=None,
104+
description=(
105+
"Path to the ONNX Runtime GenAI model directory. When provided, the pass "
106+
"runs token generation using both transformers and ONNX Runtime GenAI, then "
107+
"computes the longest common token sequence from the beginning of their outputs."
108+
),
109+
),
110+
"generate_prompt": PassConfigParam(
111+
type_=str,
112+
default_value="The capital of France is",
113+
description="Text prompt used for generation comparison between transformers and GenAI.",
114+
),
115+
"generate_max_new_tokens": PassConfigParam(
116+
type_=int,
117+
default_value=32,
118+
description="Maximum number of new tokens to generate for the token sequence comparison.",
119+
),
120+
"min_longest_common_tokens": PassConfigParam(
121+
type_=Optional[int],
122+
default_value=None,
123+
description=(
124+
"Minimum acceptable length of the longest common token sequence from the "
125+
"beginning between transformers and GenAI outputs. If the actual value is "
126+
"below this threshold, the pass fails."
127+
),
128+
),
85129
}
86130

87131
def _run_for_config(
@@ -197,5 +241,65 @@ def _run_for_config(
197241
if failures:
198242
raise RuntimeError("ONNX model discrepancy check failed:\n" + "\n".join(f" - {f}" for f in failures))
199243

244+
# Generation token sequence comparison (transformers vs ONNX Runtime GenAI)
245+
if config.genai_model_path:
246+
longest_common = self.compare_generation(config, ref_model)
247+
if config.min_longest_common_tokens is not None and longest_common < config.min_longest_common_tokens:
248+
raise RuntimeError(
249+
f"ONNX model discrepancy check failed:\n"
250+
f" - Longest common token sequence length {longest_common} is below "
251+
f"threshold {config.min_longest_common_tokens}"
252+
)
253+
200254
# Return the model unchanged
201255
return model
256+
257+
def compare_generation(self, config: type[BasePassConfig], ref_model) -> int:
258+
"""Run generation on both transformers and GenAI, return longest common token sequence length."""
259+
try:
260+
import onnxruntime_genai as og
261+
except ImportError as exc:
262+
raise ImportError("Please install `onnxruntime-genai` to enable generation comparison.") from exc
263+
from transformers import AutoTokenizer
264+
265+
tokenizer = AutoTokenizer.from_pretrained(config.reference_model_path)
266+
267+
# Transformers generation
268+
input_ids = tokenizer(config.generate_prompt, return_tensors="pt").input_ids
269+
import torch
270+
271+
with torch.no_grad():
272+
transformers_output = ref_model.generate(
273+
input_ids,
274+
max_new_tokens=config.generate_max_new_tokens,
275+
do_sample=False,
276+
)
277+
transformers_tokens = transformers_output[0].tolist()
278+
279+
# ONNX Runtime GenAI generation
280+
genai_model = og.Model(config.genai_model_path)
281+
genai_tokenizer = og.Tokenizer(genai_model)
282+
genai_input_ids = genai_tokenizer.encode(config.generate_prompt)
283+
284+
params = og.GeneratorParams(genai_model)
285+
params.set_search_options(max_length=len(genai_input_ids) + config.generate_max_new_tokens, do_sample=False)
286+
287+
generator = og.Generator(genai_model, params)
288+
generator.append_tokens([genai_input_ids])
289+
genai_tokens = list(genai_input_ids)
290+
while not generator.is_done():
291+
generator.generate_next_token()
292+
genai_tokens.append(generator.get_next_tokens()[0])
293+
del generator
294+
295+
longest_common = _longest_common_token_sequence(transformers_tokens, genai_tokens)
296+
297+
logger.info(
298+
"OnnxDiscrepancyCheck generation comparison: "
299+
"transformers_len=%d, genai_len=%d, longest_common_token_sequence=%d",
300+
len(transformers_tokens),
301+
len(genai_tokens),
302+
longest_common,
303+
)
304+
305+
return longest_common
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# -------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License.
4+
# --------------------------------------------------------------------------
5+
import sys
6+
from unittest.mock import MagicMock, patch
7+
8+
from olive.passes.onnx.discrepancy_check import _longest_common_token_sequence
9+
10+
11+
class TestLongestCommonTokenSequence:
12+
"""Unit tests for _longest_common_token_sequence helper."""
13+
14+
def test_identical_sequences(self):
15+
assert _longest_common_token_sequence([1, 2, 3, 4], [1, 2, 3, 4]) == 4
16+
17+
def test_empty_sequences(self):
18+
assert _longest_common_token_sequence([], []) == 0
19+
assert _longest_common_token_sequence([1, 2], []) == 0
20+
assert _longest_common_token_sequence([], [1, 2]) == 0
21+
22+
def test_no_common_prefix(self):
23+
assert _longest_common_token_sequence([1, 2, 3], [4, 5, 6]) == 0
24+
25+
def test_partial_common_prefix(self):
26+
assert _longest_common_token_sequence([1, 2, 3, 4, 5], [1, 2, 3, 9, 10]) == 3
27+
28+
def test_one_is_prefix_of_other(self):
29+
assert _longest_common_token_sequence([1, 2, 3], [1, 2, 3, 4, 5]) == 3
30+
assert _longest_common_token_sequence([1, 2, 3, 4, 5], [1, 2, 3]) == 3
31+
32+
def test_single_element_match(self):
33+
assert _longest_common_token_sequence([7], [7]) == 1
34+
35+
def test_single_element_no_match(self):
36+
assert _longest_common_token_sequence([7], [8]) == 0
37+
38+
def test_diverges_after_first(self):
39+
assert _longest_common_token_sequence([1, 99, 99], [1, 2, 3]) == 1
40+
41+
def test_common_tokens_later_not_counted(self):
42+
# Tokens match later but not from the beginning
43+
assert _longest_common_token_sequence([10, 1, 2, 3], [20, 1, 2, 3]) == 0
44+
45+
46+
class TestCompareGeneration:
47+
"""Unit tests for OnnxDiscrepancyCheck.compare_generation."""
48+
49+
def test_compare_generation_returns_common_prefix_length(self):
50+
"""Test that compare_generation correctly computes the common prefix length."""
51+
import torch
52+
53+
from olive.passes.onnx.discrepancy_check import OnnxDiscrepancyCheck
54+
55+
# Mock config
56+
config = MagicMock()
57+
config.reference_model_path = "mock_model"
58+
config.genai_model_path = "mock_genai_model"
59+
config.generate_prompt = "Hello world"
60+
config.generate_max_new_tokens = 10
61+
62+
# Mock transformers tokenizer and model
63+
mock_tokenizer = MagicMock()
64+
mock_tokenizer.return_value = MagicMock(input_ids=torch.tensor([[1, 2, 3]]))
65+
66+
# Transformers generates [1, 2, 3, 10, 11, 12, 13]
67+
mock_ref_model = MagicMock()
68+
mock_ref_model.generate.return_value = torch.tensor([[1, 2, 3, 10, 11, 12, 13]])
69+
70+
# GenAI generates [1, 2, 3, 10, 11, 99, 99] (diverges at index 5)
71+
mock_og = MagicMock()
72+
mock_genai_model = MagicMock()
73+
mock_og.Model.return_value = mock_genai_model
74+
mock_genai_tokenizer = MagicMock()
75+
mock_og.Tokenizer.return_value = mock_genai_tokenizer
76+
mock_genai_tokenizer.encode.return_value = [1, 2, 3]
77+
78+
mock_params = MagicMock()
79+
mock_og.GeneratorParams.return_value = mock_params
80+
81+
# Simulate generator producing tokens: 10, 11, 99, 99 then done
82+
mock_generator = MagicMock()
83+
genai_new_tokens = [10, 11, 99, 99]
84+
call_count = [0]
85+
86+
def is_done_side_effect():
87+
return call_count[0] >= len(genai_new_tokens)
88+
89+
def get_next_tokens_side_effect():
90+
token = genai_new_tokens[call_count[0]]
91+
call_count[0] += 1
92+
return [token]
93+
94+
mock_generator.is_done = is_done_side_effect
95+
mock_generator.get_next_tokens = get_next_tokens_side_effect
96+
mock_og.Generator.return_value = mock_generator
97+
98+
with (
99+
patch.dict(sys.modules, {"onnxruntime_genai": mock_og}),
100+
patch("transformers.AutoTokenizer.from_pretrained", return_value=mock_tokenizer),
101+
):
102+
pass_instance = OnnxDiscrepancyCheck.__new__(OnnxDiscrepancyCheck)
103+
result = pass_instance.compare_generation(config, mock_ref_model)
104+
105+
mock_generator.append_tokens.assert_called_once_with([[1, 2, 3]])
106+
# Common prefix: [1, 2, 3, 10, 11] = 5 tokens before divergence
107+
assert result == 5
108+
109+
def test_compare_generation_fully_matching(self):
110+
"""Test when both outputs are identical."""
111+
import torch
112+
113+
from olive.passes.onnx.discrepancy_check import OnnxDiscrepancyCheck
114+
115+
config = MagicMock()
116+
config.reference_model_path = "mock_model"
117+
config.genai_model_path = "mock_genai_model"
118+
config.generate_prompt = "Test"
119+
config.generate_max_new_tokens = 5
120+
121+
mock_tokenizer = MagicMock()
122+
mock_tokenizer.return_value = MagicMock(input_ids=torch.tensor([[10, 20]]))
123+
124+
mock_ref_model = MagicMock()
125+
mock_ref_model.generate.return_value = torch.tensor([[10, 20, 30, 40, 50]])
126+
127+
mock_og = MagicMock()
128+
mock_og.Model.return_value = MagicMock()
129+
mock_genai_tokenizer = MagicMock()
130+
mock_og.Tokenizer.return_value = mock_genai_tokenizer
131+
mock_genai_tokenizer.encode.return_value = [10, 20]
132+
133+
mock_params = MagicMock()
134+
mock_og.GeneratorParams.return_value = mock_params
135+
136+
mock_generator = MagicMock()
137+
genai_new_tokens = [30, 40, 50]
138+
call_count = [0]
139+
140+
def is_done_side_effect():
141+
return call_count[0] >= len(genai_new_tokens)
142+
143+
def get_next_tokens_side_effect():
144+
token = genai_new_tokens[call_count[0]]
145+
call_count[0] += 1
146+
return [token]
147+
148+
mock_generator.is_done = is_done_side_effect
149+
mock_generator.get_next_tokens = get_next_tokens_side_effect
150+
mock_og.Generator.return_value = mock_generator
151+
152+
with (
153+
patch.dict(sys.modules, {"onnxruntime_genai": mock_og}),
154+
patch("transformers.AutoTokenizer.from_pretrained", return_value=mock_tokenizer),
155+
):
156+
pass_instance = OnnxDiscrepancyCheck.__new__(OnnxDiscrepancyCheck)
157+
result = pass_instance.compare_generation(config, mock_ref_model)
158+
159+
mock_generator.append_tokens.assert_called_once_with([[10, 20]])
160+
# All 5 tokens match
161+
assert result == 5

0 commit comments

Comments
 (0)