Skip to content

Commit d4582d9

Browse files
xadupreCopilot
andauthored
add fast test for whisper (microsoft#2484)
## Describe your changes Add fast test for whisper encoder and decoder in a dedicated `test/cli/test_cli_whisper_smoke.py` file. Refactored `transformers` imports to be local imports inside the functions where they are needed (`_save_local_tiny_llama` and `_save_local_tiny_whisper`) instead of at the module level. Switched `WhisperConfig(...)` keyword-argument style to `WhisperConfig.from_dict({...})` to fix PYLINT `unexpected-keyword-arg` errors, matching the existing `LlamaConfig.from_dict` pattern. Also fixed a bug in `olive/passes/onnx/model_builder.py` where `onnxruntime-genai`'s `save_model` for multi-component models like Whisper (encoder + decoder) prematurely deletes the shared `cache_dir` between component saves. The encoder's `save_model` cleanup removes the `cache_dir` when it is empty (no HF downloads for local models), causing the decoder's `save_model` to fail with `FileNotFoundError`. The fix creates a temporary marker file (`.olive_build_<pid>`) in `HF_HUB_CACHE` before calling `create_model` so the directory is never considered empty and never deleted prematurely; the marker is removed in a `finally` block after `create_model` completes. `precision` is a configurable class attribute on `TestCliWhisperSmoke` (defaulting to `"fp32"`) and a parameter of `_run_whisper_capture_onnx_flow`, making it easy to override in subclasses or from the command line. All Whisper-specific code (`DEFAULT_WHISPER_MODEL_IDS`, `_save_local_tiny_whisper`, `_run_whisper_capture_onnx_flow`, `TestCliWhisperSmoke`) lives in `test/cli/test_cli_whisper_smoke.py`, separate from the existing LLaMA smoke tests in `test/cli/test_cli_test_model_smoke.py`. The CI workflow (`test-model-fast.yml`) is updated to run both files. ## 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-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent c24e907 commit d4582d9

4 files changed

Lines changed: 166 additions & 5 deletions

File tree

.github/workflows/test-model-fast.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,4 @@ jobs:
3636
3737
- name: Run fast test
3838
run: |
39-
python -m pytest -v -s -p no:warnings --disable-warnings --log-cli-level=WARNING test/cli/test_cli_test_model_smoke.py
39+
python -m pytest -v -s -p no:warnings --disable-warnings --log-cli-level=WARNING test/cli/test_cli_test_model_smoke.py test/cli/test_cli_whisper_smoke.py

olive/passes/onnx/model_builder.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import importlib
99
import json
1010
import logging
11+
import os
1112
from enum import IntEnum
1213
from pathlib import Path
1314
from typing import Any, ClassVar, Union
@@ -280,6 +281,18 @@ def _run_for_config(
280281

281282
model_attributes = copy.deepcopy(model.model_attributes or {})
282283

284+
# onnxruntime-genai's save_model cleanup deletes cache_dir when it is empty
285+
# (base.py: `if not os.listdir(self.cache_dir): os.rmdir(self.cache_dir)`).
286+
# For multi-component models like Whisper the encoder and decoder share the
287+
# same cache_dir; the encoder's save_model removes the empty directory before
288+
# the decoder's save_model can run, raising FileNotFoundError.
289+
# Fix: keep a marker file in the cache_dir for the duration of create_model so
290+
# that the directory is never considered empty and never deleted prematurely.
291+
cache_dir_path = Path(HF_HUB_CACHE)
292+
cache_dir_path.mkdir(parents=True, exist_ok=True)
293+
marker_path = cache_dir_path / f".olive_build_{os.getpid()}"
294+
marker_path.touch()
295+
283296
try:
284297
logger.debug("Building model with the following args: %s", extra_args)
285298
create_model(
@@ -296,12 +309,13 @@ def _run_for_config(
296309

297310
except Exception:
298311
# if model building fails, clean up the intermediate files in the cache_dir
299-
cache_dir = Path(HF_HUB_CACHE)
300-
if cache_dir.is_dir():
301-
for file in cache_dir.iterdir():
312+
if cache_dir_path.is_dir():
313+
for file in cache_dir_path.iterdir():
302314
if file.suffix == ".bin":
303315
file.unlink()
304316
raise
317+
finally:
318+
marker_path.unlink(missing_ok=True)
305319

306320
# Override default search options with ones from user config
307321
genai_config_filepath = str(output_model_filepath.parent / "genai_config.json")

test/cli/test_cli_test_model_smoke.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
from tokenizers import Tokenizer
1414
from tokenizers.models import WordLevel
1515
from tokenizers.pre_tokenizers import Whitespace
16-
from transformers import LlamaConfig, LlamaForCausalLM, PreTrainedTokenizerFast
1716

1817
from olive.cli.base import TEST_OUTPUT_MARKER_FILE
1918
from olive.common.hf.utils import TEST_MODEL_MARKER_FILE
@@ -26,6 +25,7 @@
2625
"local/tiny-random-llama-a",
2726
"local/tiny-random-llama-b",
2827
"mistralai/Mistral-7B-Instruct-v0.3",
28+
"microsoft/Phi-3-mini-4k-instruct",
2929
)
3030
MAX_ARTIFACT_SIZE_BYTES = 1024 * 1024
3131

@@ -38,6 +38,8 @@
3838

3939

4040
def _save_local_tiny_llama(model_path: Path):
41+
from transformers import LlamaConfig, LlamaForCausalLM, PreTrainedTokenizerFast
42+
4143
model = LlamaForCausalLM(
4244
LlamaConfig.from_dict(
4345
{

test/cli/test_cli_whisper_smoke.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# -------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License.
4+
# --------------------------------------------------------------------------
5+
import argparse
6+
import tempfile
7+
import unittest
8+
from pathlib import Path
9+
10+
from tokenizers import Tokenizer
11+
from tokenizers.models import WordLevel
12+
from tokenizers.pre_tokenizers import Whitespace
13+
14+
DEFAULT_WHISPER_MODEL_IDS = (
15+
"openai/whisper-tiny",
16+
"microsoft/whisper-base",
17+
)
18+
MAX_ARTIFACT_SIZE_BYTES = 1024 * 1024
19+
20+
21+
def _run_cli_main(args):
22+
from olive.cli.launcher import main as cli_main
23+
24+
cli_main(args)
25+
26+
27+
def _save_local_tiny_whisper(model_path: Path):
28+
from transformers import PreTrainedTokenizerFast, WhisperConfig, WhisperForConditionalGeneration
29+
30+
model = WhisperForConditionalGeneration(
31+
WhisperConfig.from_dict(
32+
{
33+
"num_mel_bins": 80,
34+
"encoder_layers": 2,
35+
"encoder_attention_heads": 4,
36+
"decoder_layers": 2,
37+
"decoder_attention_heads": 4,
38+
"d_model": 64,
39+
"encoder_ffn_dim": 128,
40+
"decoder_ffn_dim": 128,
41+
"max_source_positions": 16,
42+
"max_target_positions": 16,
43+
"pad_token_id": 0,
44+
"bos_token_id": 1,
45+
"eos_token_id": 2,
46+
"decoder_start_token_id": 1,
47+
}
48+
)
49+
)
50+
model.save_pretrained(model_path)
51+
52+
tokenizer = Tokenizer(
53+
WordLevel(
54+
vocab={"<pad>": 0, "<bos>": 1, "<eos>": 2, "hello": 3, "world": 4},
55+
unk_token="<pad>",
56+
)
57+
)
58+
tokenizer.pre_tokenizer = Whitespace()
59+
PreTrainedTokenizerFast(
60+
tokenizer_object=tokenizer,
61+
bos_token="<bos>",
62+
eos_token="<eos>",
63+
pad_token="<pad>",
64+
).save_pretrained(model_path)
65+
66+
67+
def _run_whisper_capture_onnx_flow(tmp_path: Path, model_id: str, precision: str = "fp32"):
68+
"""Export a tiny local Whisper model to ONNX via capture-onnx-graph with Model Builder."""
69+
model_name = model_id.replace("/", "--")
70+
model_path = tmp_path / "models" / model_name
71+
run_output_dir = tmp_path / f"{model_name}-onnx"
72+
73+
_save_local_tiny_whisper(model_path)
74+
_run_cli_main(
75+
[
76+
"capture-onnx-graph",
77+
"-m",
78+
str(model_path),
79+
"--use_model_builder",
80+
"--precision",
81+
precision,
82+
"--output_path",
83+
str(run_output_dir),
84+
]
85+
)
86+
87+
return run_output_dir
88+
89+
90+
class TestCliWhisperSmoke(unittest.TestCase):
91+
"""Smoke tests for Whisper encoder-decoder models exported via capture-onnx-graph."""
92+
93+
model_ids = DEFAULT_WHISPER_MODEL_IDS
94+
precision = "fp32"
95+
workdir = None
96+
97+
def test_whisper_capture_onnx_graph(self):
98+
"""Verify that Whisper encoder and decoder are exported to ONNX successfully."""
99+
if self.workdir is None:
100+
with tempfile.TemporaryDirectory() as temp_dir:
101+
self._assert_whisper_export(Path(temp_dir))
102+
else:
103+
workdir = Path(self.workdir)
104+
workdir.mkdir(parents=True, exist_ok=True)
105+
self._assert_whisper_export(workdir)
106+
107+
def _assert_whisper_export(self, tmp_path: Path):
108+
expected_encoder_file = "encoder.onnx"
109+
expected_decoder_file = "decoder.onnx"
110+
for model_id in self.model_ids:
111+
with self.subTest(model_id=model_id):
112+
run_output_dir = _run_whisper_capture_onnx_flow(tmp_path, model_id, self.precision)
113+
output_files = self._list_relative_files(run_output_dir)
114+
assert expected_encoder_file in output_files, (
115+
f"Expected {expected_encoder_file} in output, got: {output_files}"
116+
)
117+
assert expected_decoder_file in output_files, (
118+
f"Expected {expected_decoder_file} in output, got: {output_files}"
119+
)
120+
self._assert_file_size_below_limit(run_output_dir / expected_encoder_file)
121+
self._assert_file_size_below_limit(run_output_dir / expected_decoder_file)
122+
123+
def _assert_file_size_below_limit(self, path: Path):
124+
assert path.exists()
125+
assert path.stat().st_size < MAX_ARTIFACT_SIZE_BYTES
126+
127+
@staticmethod
128+
def _list_relative_files(path: Path):
129+
return {file_path.relative_to(path).as_posix() for file_path in path.rglob("*") if file_path.is_file()}
130+
131+
132+
def _parse_args():
133+
parser = argparse.ArgumentParser(add_help=False)
134+
parser.add_argument("--workdir")
135+
parser.add_argument("--model-id", dest="model_ids", action="append")
136+
return parser.parse_known_args()
137+
138+
139+
if __name__ == "__main__":
140+
parsed_args, remaining = _parse_args()
141+
if parsed_args.workdir:
142+
TestCliWhisperSmoke.workdir = Path(parsed_args.workdir)
143+
if parsed_args.model_ids:
144+
TestCliWhisperSmoke.model_ids = tuple(parsed_args.model_ids)
145+
unittest.main(argv=[__file__, *remaining])

0 commit comments

Comments
 (0)