Skip to content

Commit 94c2163

Browse files
xiaoyu-workCopilotCopilot
authored
Fix ORT GenAI export metadata (#377)
## Summary - copy tokenizer files locally when hf_model_id is a local directory - emit qwen3_vl model type instead of qwen2_5_vl for Qwen3-VL exports - include Qwen3-VL vision/video metadata in genai_config.json ## Validation - PYTHONPATH=$PWD/src conda run -n olive python -m pytest src/mobius/integrations/ort_genai/auto_export_test.py -q -k 'local_hf_model_id_uses_local_tokenizer_copy or qwen3_vl_writes_qwen3_vl_model_type_and_vision_fields or writes_processor_config_for_vision_language_model' --------- Signed-off-by: Xiaoyu <xiaoyuzhang@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 4ae819f commit 94c2163

4 files changed

Lines changed: 127 additions & 14 deletions

File tree

src/mobius/integrations/ort_genai/auto_export.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,10 @@
8787
# ORT GenAI (see onnxruntime-genai/src/models/model_type.h LLM list).
8888
"hunyuan_v1_dense": "decoder",
8989
"deepseek_v4": "decoder",
90-
# Qwen VL models all use the same GenAI pipeline as qwen2_5_vl
90+
# Qwen VL model families have separate ORT GenAI model types.
9191
"qwen2_vl": "qwen2_5_vl",
92-
"qwen3_vl": "qwen2_5_vl",
93-
"qwen3_vl_text": "qwen2_5_vl",
92+
"qwen3_vl": "qwen3_vl",
93+
"qwen3_vl_text": "qwen3_vl",
9494
"qwen3_5": "qwen2_5_vl",
9595
"qwen3_5_vl": "qwen2_5_vl",
9696
}
@@ -880,6 +880,16 @@ def _write_genai_config(
880880
if sms is not None:
881881
vision_kwargs["spatial_merge_size"] = sms
882882
vision_kwargs["config_filename"] = "processor_config.json"
883+
if model_type in {"qwen3_vl", "qwen3_vl_text"}:
884+
patch_size = getattr(vision_cfg, "patch_size", None)
885+
window_size = getattr(vision_cfg, "window_size", None)
886+
if patch_size is not None:
887+
vision_kwargs["patch_size"] = patch_size
888+
if window_size is not None:
889+
vision_kwargs["window_size"] = window_size
890+
vision_kwargs["tokens_per_second"] = float(
891+
getattr(config, "tokens_per_second", 2.0)
892+
)
883893

884894
if vision_input_mapping is not None:
885895
vision_kwargs["input_names"] = vision_input_mapping
@@ -889,6 +899,12 @@ def _write_genai_config(
889899
embedding_output_mapping = _introspect_outputs(pkg, "embedding")
890900
if embedding_output_mapping is not None:
891901
vision_kwargs["embedding_output_names"] = embedding_output_mapping
902+
vision_start_token_id = getattr(config, "vision_start_token_id", None)
903+
video_token_id = getattr(config, "video_token_id", None)
904+
if vision_start_token_id is not None:
905+
vision_kwargs["vision_start_token_id"] = vision_start_token_id
906+
if video_token_id is not None:
907+
vision_kwargs["video_token_id"] = video_token_id
892908

893909
generator.with_vision(image_token_id=image_token_id, **vision_kwargs)
894910

@@ -946,8 +962,8 @@ def write_ort_genai_config(
946962
pkg: Already-built :class:`~mobius._model_package.ModelPackage` with
947963
weights applied and ``config`` set.
948964
directory: Output directory (created if needed).
949-
hf_model_id: HuggingFace model ID. When provided, used to fetch token
950-
IDs (``bos``/``eos``/``pad``) and download tokenizer files.
965+
hf_model_id: HuggingFace model ID or local model directory. When provided,
966+
used to fetch token IDs (``bos``/``eos``/``pad``) and copy tokenizer files.
951967
When ``None``, token IDs are read from ``pkg.config`` fields
952968
(``bos_token_id``, ``eos_token_id``, ``pad_token_id``) populated
953969
by :meth:`~mobius._configs.ArchitectureConfig.from_transformers`,
@@ -1112,11 +1128,15 @@ def write_ort_genai_config(
11121128
f.write("\n")
11131129
result["mtp_config"] = mtp_path
11141130

1115-
# Copy tokenizer files — HF Hub takes precedence; local dir is the fallback
1116-
# for --config mode where no HF model ID is available.
1131+
# Copy tokenizer files. A local hf_model_id is a local model directory, not a
1132+
# Hub repo id; copy directly instead of calling hf_hub_download.
11171133
if hf_model_id is not None:
1118-
logger.info("Copying tokenizer files from %s", hf_model_id)
1119-
tokenizer_files = _copy_tokenizer_files(hf_model_id, directory)
1134+
if os.path.isdir(hf_model_id):
1135+
logger.info("Copying tokenizer files from local model directory %s", hf_model_id)
1136+
tokenizer_files = _copy_tokenizer_files_from_local(hf_model_id, directory)
1137+
else:
1138+
logger.info("Copying tokenizer files from %s", hf_model_id)
1139+
tokenizer_files = _copy_tokenizer_files(hf_model_id, directory)
11201140
for tf in tokenizer_files:
11211141
result[tf] = os.path.join(directory, tf)
11221142
elif local_config_dir is not None:

src/mobius/integrations/ort_genai/auto_export_test.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,36 @@ def test_hf_model_id_takes_precedence_over_local_dir(self, tmp_path):
602602
mock_hf.assert_called_once()
603603
mock_local.assert_not_called()
604604

605+
def test_local_hf_model_id_uses_local_tokenizer_copy(self, tmp_path):
606+
"""A local hf_model_id should copy tokenizer files locally, not call the Hub."""
607+
src = tmp_path / "local_model"
608+
src.mkdir()
609+
(src / "config.json").write_text(
610+
'{"model_type": "llama", "bos_token_id": 1, "eos_token_id": 2}'
611+
)
612+
(src / "tokenizer.json").write_text('{"local": true}')
613+
614+
out = tmp_path / "output"
615+
out.mkdir()
616+
pkg = self._make_pkg()
617+
618+
with (
619+
mock.patch(
620+
"mobius.integrations.ort_genai.auto_export._copy_tokenizer_files",
621+
return_value=[],
622+
) as mock_hub_copy,
623+
mock.patch(
624+
"mobius.integrations.ort_genai.auto_export._copy_tokenizer_files_from_local",
625+
wraps=_copy_tokenizer_files_from_local,
626+
) as mock_local_copy,
627+
):
628+
result = write_ort_genai_config(pkg, str(out), hf_model_id=str(src))
629+
630+
mock_hub_copy.assert_not_called()
631+
mock_local_copy.assert_called_once_with(str(src), str(out))
632+
assert "tokenizer.json" in result
633+
assert (out / "tokenizer.json").read_text() == '{"local": true}'
634+
605635

606636
class TestExportForOrtGenai:
607637
"""Unit tests for write_ort_genai_config()."""
@@ -656,7 +686,7 @@ class FakeConfig:
656686
},
657687
config=FakeConfig(),
658688
)
659-
result = write_ort_genai_config(pkg, str(tmp_path))
689+
result = write_ort_genai_config(pkg, str(tmp_path), ep="cuda")
660690

661691
assert "processor_config" in result
662692
assert os.path.isfile(result["processor_config"])
@@ -670,6 +700,57 @@ class FakeConfig:
670700
resize = transforms[2]["operation"]["attrs"]
671701
assert resize["patch_size"] == 14
672702

703+
def test_qwen3_vl_writes_qwen3_vl_model_type_and_vision_fields(self, tmp_path):
704+
import dataclasses
705+
706+
from mobius._model_package import ModelPackage
707+
from mobius.integrations.ort_genai.auto_export import write_ort_genai_config
708+
709+
@dataclasses.dataclass
710+
class FakeVision:
711+
image_size: int = 448
712+
patch_size: int = 16
713+
spatial_merge_size: int = 2
714+
window_size: int = 64
715+
model_type: str | None = None
716+
717+
@dataclasses.dataclass
718+
class FakeConfig:
719+
model_type: str = "qwen3_vl"
720+
vocab_size: int = 151936
721+
hidden_size: int = 2048
722+
num_hidden_layers: int = 1
723+
num_attention_heads: int = 16
724+
num_key_value_heads: int = 8
725+
head_dim: int = 128
726+
image_token_id: int = 151655
727+
vision_start_token_id: int = 151652
728+
video_token_id: int = 151656
729+
tokens_per_second: float = 2.0
730+
temporal_patch_size: int = 2
731+
vision: FakeVision = dataclasses.field(default_factory=FakeVision)
732+
733+
pkg = ModelPackage(
734+
{
735+
"decoder": mock.MagicMock(),
736+
"vision_encoder": mock.MagicMock(),
737+
"embedding": mock.MagicMock(),
738+
},
739+
config=FakeConfig(),
740+
)
741+
742+
result = write_ort_genai_config(pkg, str(tmp_path), ep="cuda")
743+
744+
with open(result["genai_config"]) as f:
745+
data = json.load(f)
746+
model = data["model"]
747+
assert model["type"] == "qwen3_vl"
748+
assert model["vision_start_token_id"] == 151652
749+
assert model["video_token_id"] == 151656
750+
assert model["vision"]["tokens_per_second"] == pytest.approx(2.0)
751+
assert model["vision"]["patch_size"] == 16
752+
assert model["vision"]["window_size"] == 64
753+
673754
def test_processor_config_not_written_without_vision(self, tmp_path):
674755
"""image_processor.json is NOT written when pkg.config has no vision attr."""
675756
from mobius.integrations.ort_genai.auto_export import write_ort_genai_config

src/mobius/integrations/ort_genai/genai_config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,9 @@ def with_vision(
269269
embedding_output_names: dict[str, str] | None = None,
270270
vision_start_token_id: int | None = None,
271271
video_token_id: int | None = None,
272+
tokens_per_second: float | None = None,
273+
patch_size: int | None = None,
274+
window_size: int | None = None,
272275
) -> GenaiConfigGenerator:
273276
"""Add VLM vision + embedding sections.
274277
@@ -293,6 +296,9 @@ def with_vision(
293296
mapping. Defaults to inputs_embeds.
294297
vision_start_token_id: Token ID for ``<|vision_start|>``.
295298
video_token_id: Token ID for video placeholders.
299+
tokens_per_second: Video/image timestamp rate for Qwen3-VL.
300+
patch_size: Vision patch size.
301+
window_size: Vision window size.
296302
297303
Returns self for chaining.
298304
"""
@@ -320,6 +326,12 @@ def with_vision(
320326
}
321327
if spatial_merge_size is not None:
322328
self._vision["spatial_merge_size"] = spatial_merge_size
329+
if tokens_per_second is not None:
330+
self._vision["tokens_per_second"] = tokens_per_second
331+
if patch_size is not None:
332+
self._vision["patch_size"] = patch_size
333+
if window_size is not None:
334+
self._vision["window_size"] = window_size
323335

324336
self._embedding = {
325337
"filename": embedding_filename,

tests/integration_test.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3782,10 +3782,10 @@ def test_qwen35_deltanet_single_layer_parity():
37823782
hidden_states=torch.from_numpy(hidden_np).float(),
37833783
cache_params=cache,
37843784
).numpy()
3785-
hf_rec = cache.layers[0].recurrent_states
3786-
if isinstance(hf_rec, dict):
3787-
hf_rec = hf_rec[0]
3788-
hf_rec = hf_rec.numpy()
3785+
# transformers >=5.14 changed recurrent_states from a tensor to a dict
3786+
# keyed by layer index; extract the tensor for either version.
3787+
_rec_states = cache.layers[0].recurrent_states
3788+
hf_rec = (_rec_states[0] if isinstance(_rec_states, dict) else _rec_states).numpy()
37893789

37903790
# ONNX forward
37913791
sess = _make_session(onnx_model)

0 commit comments

Comments
 (0)