From c52b9cf0dcd0901a9ec153d86346ba8b44d5b5c5 Mon Sep 17 00:00:00 2001 From: Delwin Kim <139003345+DelwinKim@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:58:11 +0000 Subject: [PATCH 1/4] Add ORT-GenAI package adapter Preserve complete ORT-GenAI package layouts across composite conversion and cache export, including direct file and nested-directory handlers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- olive/cache.py | 44 +++ olive/olive_config.json | 8 + .../passes/onnx/composite_to_onnx_package.py | 174 ++++++++++++ .../onnx/test_composite_to_onnx_package.py | 252 ++++++++++++++++++ test/test_cache.py | 163 +++++++++++ 5 files changed, 641 insertions(+) create mode 100644 olive/passes/onnx/composite_to_onnx_package.py create mode 100644 test/passes/onnx/test_composite_to_onnx_package.py diff --git a/olive/cache.py b/olive/cache.py index 7f27a50de1..e9b8ea04c5 100644 --- a/olive/cache.py +++ b/olive/cache.py @@ -20,6 +20,7 @@ from olive.common.container_client_factory import AzureContainerClientFactory from olive.common.utils import hash_dict, hf_repo_exists, set_nested_dict_value from olive.model.config.model_config import ModelConfig +from olive.model.utils.onnx_utils import get_onnx_file_path from olive.resource_path import ResourcePath, create_resource_path, find_all_resources if TYPE_CHECKING: @@ -532,6 +533,49 @@ def _save_model( model_path_resource = model_json["config"]["model_path"] source_path = Path(model_path_resource.get_path()) onnx_file_name = model_json["config"].get("onnx_file_name") + model_attributes = model_json["config"].get("model_attributes") or {} + + package_root = source_path if source_path.is_dir() else source_path.parent + for _ in range(3): + if (package_root / "genai_config.json").is_file(): + break + if package_root.parent == package_root: + package_root = None + break + package_root = package_root.parent + else: + package_root = None + + if package_root is not None or model_attributes.get("ort_genai_package"): + if package_root is None: + raise ValueError(f"ORT-GenAI package has no discoverable genai_config.json: {source_path}") + if output_dir.suffix == ".onnx": + raise ValueError("ORT-GenAI packages must be saved to a directory, not an ONNX file path.") + package_root = package_root.resolve() + resolved_output_dir = output_dir.resolve() + if ( + package_root == resolved_output_dir + or package_root.is_relative_to(resolved_output_dir) + or resolved_output_dir.is_relative_to(package_root) + ): + raise ValueError( + "ORT-GenAI package source and output directories must not overlap: " + f"source={package_root}, output={resolved_output_dir}." + ) + entry_path = Path(get_onnx_file_path(str(source_path), onnx_file_name)).resolve() + if not entry_path.is_relative_to(package_root) or not entry_path.is_file(): + raise ValueError(f"ORT-GenAI package entry point is invalid: {entry_path}") + if resolved_output_dir.exists(): + if any(resolved_output_dir.iterdir()) and not overwrite: + raise FileExistsError(f"Output directory is not empty: {resolved_output_dir}") + if any(resolved_output_dir.iterdir()): + shutil.rmtree(resolved_output_dir) + else: + resolved_output_dir.rmdir() + shutil.copytree(package_root, resolved_output_dir) + model_json["config"]["model_path"] = str(resolved_output_dir) + model_json["config"]["onnx_file_name"] = entry_path.relative_to(package_root).as_posix() + return self._save_additional_files(model_json, resolved_output_dir) # Determine if source has external data or additional files has_additional_files = bool(onnx_file_name) diff --git a/olive/olive_config.json b/olive/olive_config.json index 25c4b546e5..c8cdc8f2cf 100644 --- a/olive/olive_config.json +++ b/olive/olive_config.json @@ -8,6 +8,14 @@ "supported_algorithms": [ ], "supported_quantization_encodings": [ ] }, + "CompositeToOnnxPackage": { + "module_path": "olive.passes.onnx.composite_to_onnx_package.CompositeToOnnxPackage", + "supported_providers": [ "*" ], + "supported_accelerators": [ "*" ], + "supported_precisions": [ "*" ], + "supported_algorithms": [ ], + "supported_quantization_encodings": [ ] + }, "AimetQuantization": { "module_path": "olive.passes.onnx.aimet_quantization.AimetQuantization", "supported_providers": [ "*" ], diff --git a/olive/passes/onnx/composite_to_onnx_package.py b/olive/passes/onnx/composite_to_onnx_package.py new file mode 100644 index 0000000000..3beb9eea81 --- /dev/null +++ b/olive/passes/onnx/composite_to_onnx_package.py @@ -0,0 +1,174 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- +"""Wrap a multi-component CompositeModel ORT-GenAI package as a single ONNXModel handler.""" + +from __future__ import annotations + +import json +import logging +import shutil +from pathlib import Path +from typing import TYPE_CHECKING + +from olive.common.utils import hardlink_copy_dir +from olive.model import ONNXModelHandler +from olive.model.handler.composite import CompositeModelHandler +from olive.passes import Pass +from olive.passes.pass_config import BasePassConfig, PassConfigParam + +if TYPE_CHECKING: + from olive.hardware.accelerator import AcceleratorSpec + +logger = logging.getLogger(__name__) + + +class CompositeToOnnxPackage(Pass): + """Repackage a CompositeModel ORT-GenAI package as a single :class:`ONNXModelHandler`. + + ``MobiusBuilder`` and similar passes emit multi-component ORT-GenAI packages as a + :class:`CompositeModelHandler` whose components live in subdirectories:: + + output_dir/ + genai_config.json + tokenizer.json + decoder/model.onnx + vision_encoder/model.onnx + audio_encoder/model.onnx + embedding/model.onnx + + Two consumers need a concrete :class:`ONNXModelHandler` rather than a composite: + + * ``LocalSystem.evaluate_model`` raises ``NotImplementedError`` for composite + models, so a composite package can't be evaluated directly. + * ORT-GenAI evaluators (``LMMSEvaluator``, ``OnnxEvaluator._inference_vision_genai``) + dispatch on ``ONNXModelHandler`` and locate ``genai_config.json`` relative to + the handler's ONNX file. + + This pass performs that conversion **without flattening the layout**: the nested + directory structure is preserved (ONNX Runtime GenAI loads nested packages + directly, and ``genai_config.json`` already references the nested paths), so no + ONNX re-serialization or external-data rewriting is needed. The package tree is + hardlink-copied into the pass output directory and a single + :class:`ONNXModelHandler` is returned, pointing at the entry-point component + (defaults to ``decoder``). Evaluators discover ``genai_config.json`` by searching + upward from the entry ONNX file (see ``_find_genai_config`` in the evaluator). + """ + + _accepts_composite_model = True + + @classmethod + def _default_config(cls, accelerator_spec: AcceleratorSpec) -> dict[str, PassConfigParam]: + return { + "entry_point_component": PassConfigParam( + type_=str, + default_value="decoder", + description=( + "Name of the genai_config 'model' subsection (e.g. 'decoder', 'text') " + "whose ONNX file the returned ONNXModelHandler will point at. If the " + "name is not found, falls back to the first component with a 'filename' field." + ), + ), + } + + @classmethod + def is_accelerator_agnostic(cls, accelerator_spec: AcceleratorSpec) -> bool: + # Pure file-system repackaging — no EP-specific behavior. + return True + + def _run_for_config( + self, + model: CompositeModelHandler, + config: type[BasePassConfig], + output_model_path: str, + ) -> ONNXModelHandler: + if not isinstance(model, CompositeModelHandler): + raise ValueError( + f"CompositeToOnnxPackage expects a CompositeModelHandler input, got {type(model).__name__}." + ) + + src_dir = Path(model.model_path).resolve() + if not src_dir.is_dir(): + raise ValueError(f"CompositeModel model_path is not a directory: {src_dir}") + + src_genai_config = src_dir / "genai_config.json" + if not src_genai_config.is_file(): + raise ValueError( + f"CompositeToOnnxPackage requires genai_config.json at the package root: {src_genai_config} not found." + ) + + genai_config = json.loads(src_genai_config.read_text(encoding="utf-8")) + model_section = genai_config.get("model") + if not isinstance(model_section, dict): + raise ValueError(f"Invalid genai_config.json at {src_genai_config}: missing 'model' section.") + + entry_filename = self._select_entry_filename(model_section, config.entry_point_component) + if entry_filename is None: + raise ValueError( + "Failed to determine an entry-point component for CompositeToOnnxPackage. " + f"Requested '{config.entry_point_component}', no component matched and no fallback available." + ) + + dst_dir = self._resolve_output_dir(output_model_path).resolve() + # Copy the entire nested package tree (cheap hardlinks) into the pass output + # directory, preserving the multi-component subdirectory layout as-is. + hardlink_copy_dir(src_dir, dst_dir) + # genai_config.json is mutable deployment metadata (provider/session + # options are commonly adjusted after export). Give the output package an + # independent copy so changing it cannot mutate the input pass cache. + dst_genai_config = dst_dir / "genai_config.json" + dst_genai_config.unlink() + shutil.copy2(src_genai_config, dst_genai_config) + + entry_path = (dst_dir / entry_filename).resolve() + if not entry_path.is_relative_to(dst_dir): + raise ValueError( + f"Entry-point component '{entry_filename}' escapes the ORT-GenAI package directory {dst_dir}." + ) + if not entry_path.is_file(): + raise ValueError( + f"Entry-point component '{entry_filename}' not found in packaged output at {entry_path}. " + "genai_config.json references a component file that does not exist on disk." + ) + + logger.info( + "CompositeToOnnxPackage: packaged %d components into '%s' (entry_point=%s, nested layout preserved)", + sum(1 for v in model_section.values() if isinstance(v, dict) and v.get("filename")), + dst_dir, + entry_filename, + ) + + return ONNXModelHandler( + model_path=str(dst_dir), + onnx_file_name=entry_filename, + model_attributes={ + **(model.model_attributes or {}), + "ort_genai_package": True, + "entry_point_component": config.entry_point_component, + }, + ) + + @staticmethod + def _resolve_output_dir(output_model_path: str) -> Path: + """Olive sometimes passes a `.onnx` file path; in that case use its stem as the directory.""" + output_path = Path(output_model_path) + if output_path.suffix == ".onnx": + return output_path.parent / output_path.stem + return output_path + + @staticmethod + def _select_entry_filename(model_section: dict, entry_point_component: str) -> str | None: + """Pick the (nested) filename for the entry-point component, falling back if missing.""" + preferred = model_section.get(entry_point_component) + if isinstance(preferred, dict): + filename = preferred.get("filename") + if isinstance(filename, str): + return filename + + for component_cfg in model_section.values(): + if isinstance(component_cfg, dict): + filename = component_cfg.get("filename") + if isinstance(filename, str): + return filename + return None diff --git a/test/passes/onnx/test_composite_to_onnx_package.py b/test/passes/onnx/test_composite_to_onnx_package.py new file mode 100644 index 0000000000..78b9d15cab --- /dev/null +++ b/test/passes/onnx/test_composite_to_onnx_package.py @@ -0,0 +1,252 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- +"""Tests for the CompositeToOnnxPackage pass. + +The pass repackages a nested multi-component CompositeModel ORT-GenAI package as a +single ONNXModelHandler pointing at the entry-point component, preserving the nested +directory layout (no flattening / external-data rewriting). +""" + +import json +from pathlib import Path + +import numpy as np +import onnx +import pytest +from onnx import TensorProto, helper, numpy_helper + +from olive.model import ONNXModelHandler +from olive.model.handler.composite import CompositeModelHandler +from olive.passes.olive_pass import create_pass_from_dict +from olive.passes.onnx.composite_to_onnx_package import CompositeToOnnxPackage + + +def _write_tiny_onnx_with_external_data(onnx_path: Path, data_filename: str = "model.onnx.data") -> None: + """Write a minimal valid ONNX model whose single initializer lives in an external data sidecar.""" + data = np.arange(1024, dtype=np.float32) + init_tensor = numpy_helper.from_array(data, name="weight") + output = helper.make_tensor_value_info("y", TensorProto.FLOAT, [1024]) + node = helper.make_node("Identity", inputs=["weight"], outputs=["y"]) + graph = helper.make_graph([node], "g", inputs=[], outputs=[output], initializer=[init_tensor]) + model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 17)]) + + onnx_path.parent.mkdir(parents=True, exist_ok=True) + onnx.save_model( + model, + str(onnx_path), + save_as_external_data=True, + all_tensors_to_one_file=True, + location=data_filename, + ) + + +def _make_nested_genai_package(root: Path, components: dict[str, str]) -> Path: + """Build a fake nested ORT-GenAI package at ``root``. + + ``components`` maps genai_config component keys (e.g. ``decoder``) to the + relative ONNX filename under ``root`` (e.g. ``decoder/model.onnx``). + """ + root.mkdir(parents=True, exist_ok=True) + + model_section: dict[str, dict[str, str]] = {} + for key, rel_path in components.items(): + _write_tiny_onnx_with_external_data(root / rel_path) + model_section[key] = {"filename": rel_path} + + # Shared root-level sidecars. + (root / "tokenizer.json").write_text("{}", encoding="utf-8") + (root / "chat_template.jinja").write_text("template", encoding="utf-8") + + (root / "genai_config.json").write_text( + json.dumps({"model": {"type": "gemma4", **model_section}}, indent=2), + encoding="utf-8", + ) + return root + + +def _make_composite_handler( + root: Path, components: dict[str, str], model_attributes: dict | None = None +) -> CompositeModelHandler: + component_handlers = [ONNXModelHandler(model_path=str(root / rel_path)) for rel_path in components.values()] + return CompositeModelHandler( + model_components=component_handlers, + model_component_names=list(components.keys()), + model_path=str(root), + model_attributes=model_attributes, + ) + + +def _find_genai_config_upward(onnx_file: Path, levels: int = 3) -> Path | None: + """Mirror the evaluator's upward search for genai_config.json from an ONNX file.""" + candidate = onnx_file.parent + for _ in range(levels): + if (candidate / "genai_config.json").is_file(): + return candidate / "genai_config.json" + if candidate.parent == candidate: + break + candidate = candidate.parent + return None + + +class TestCompositeToOnnxPackage: + def test_preserves_nested_component_layout(self, tmp_path): + src_root = _make_nested_genai_package( + tmp_path / "src", + { + "decoder": "decoder/model.onnx", + "vision": "vision_encoder/model.onnx", + "audio": "audio_encoder/model.onnx", + "embedding": "embedding/model.onnx", + }, + ) + composite = _make_composite_handler( + src_root, + { + "decoder": "decoder/model.onnx", + "vision_encoder": "vision_encoder/model.onnx", + "audio_encoder": "audio_encoder/model.onnx", + "embedding": "embedding/model.onnx", + }, + ) + + p = create_pass_from_dict(CompositeToOnnxPackage, {}, disable_search=True) + out = p.run(composite, str(tmp_path / "out")) + + pkg_root = Path(out.model_path).parents[1] + # Nested layout preserved (components stay in their subdirectories). + assert (pkg_root / "decoder" / "model.onnx").is_file() + assert (pkg_root / "vision_encoder" / "model.onnx").is_file() + assert (pkg_root / "audio_encoder" / "model.onnx").is_file() + assert (pkg_root / "embedding" / "model.onnx").is_file() + # Shared sidecars and config copied to the package root. + assert (pkg_root / "genai_config.json").is_file() + assert (pkg_root / "tokenizer.json").is_file() + assert (pkg_root / "chat_template.jinja").is_file() + + def test_returns_onnx_handler_pointing_at_nested_entry(self, tmp_path): + src_root = _make_nested_genai_package( + tmp_path / "src", + {"decoder": "decoder/model.onnx", "vision": "vision_encoder/model.onnx"}, + ) + composite = _make_composite_handler( + src_root, {"decoder": "decoder/model.onnx", "vision_encoder": "vision_encoder/model.onnx"} + ) + + p = create_pass_from_dict(CompositeToOnnxPackage, {}, disable_search=True) + out = p.run(composite, str(tmp_path / "out")) + + assert isinstance(out, ONNXModelHandler) + entry = Path(out.model_path) + # Handler points at the nested decoder entry, which really exists. + assert entry.is_file() + assert entry.name == "model.onnx" + assert entry.parent.name == "decoder" + # genai_config.json is discoverable by searching upward from the entry ONNX file. + assert _find_genai_config_upward(entry) is not None + + def test_enforces_package_model_attributes(self, tmp_path): + src_root = _make_nested_genai_package(tmp_path / "src", {"decoder": "decoder/model.onnx"}) + composite = _make_composite_handler( + src_root, + {"decoder": "decoder/model.onnx"}, + model_attributes={"ort_genai_package": False, "entry_point_component": "untrusted", "source": "mobius"}, + ) + + p = create_pass_from_dict(CompositeToOnnxPackage, {}, disable_search=True) + out = p.run(composite, str(tmp_path / "out")) + + assert out.model_attributes == { + "ort_genai_package": True, + "entry_point_component": "decoder", + "source": "mobius", + } + + def test_does_not_rewrite_genai_config_filenames(self, tmp_path): + src_root = _make_nested_genai_package( + tmp_path / "src", + {"decoder": "decoder/model.onnx", "vision": "vision_encoder/model.onnx"}, + ) + composite = _make_composite_handler( + src_root, {"decoder": "decoder/model.onnx", "vision_encoder": "vision_encoder/model.onnx"} + ) + + p = create_pass_from_dict(CompositeToOnnxPackage, {}, disable_search=True) + out = p.run(composite, str(tmp_path / "out")) + + pkg_root = Path(out.model_path).parents[1] + genai_config = json.loads((pkg_root / "genai_config.json").read_text(encoding="utf-8")) + # Nested paths are preserved verbatim (no flattening rewrite). + assert genai_config["model"]["decoder"]["filename"] == "decoder/model.onnx" + assert genai_config["model"]["vision"]["filename"] == "vision_encoder/model.onnx" + + def test_genai_config_is_independent_from_input_package(self, tmp_path): + src_root = _make_nested_genai_package(tmp_path / "src", {"decoder": "decoder/model.onnx"}) + composite = _make_composite_handler(src_root, {"decoder": "decoder/model.onnx"}) + + p = create_pass_from_dict(CompositeToOnnxPackage, {}, disable_search=True) + out = p.run(composite, str(tmp_path / "out")) + + pkg_root = Path(out.model_path).parents[1] + src_config = src_root / "genai_config.json" + output_config = pkg_root / "genai_config.json" + assert src_config.stat().st_ino != output_config.stat().st_ino + output_config.write_text('{"model": {"type": "patched"}}', encoding="utf-8") + assert json.loads(src_config.read_text(encoding="utf-8"))["model"]["type"] == "gemma4" + + def test_honors_explicit_entry_point_component(self, tmp_path): + src_root = _make_nested_genai_package( + tmp_path / "src", + {"decoder": "decoder/model.onnx", "embedding": "embedding/model.onnx"}, + ) + composite = _make_composite_handler( + src_root, {"decoder": "decoder/model.onnx", "embedding": "embedding/model.onnx"} + ) + + p = create_pass_from_dict(CompositeToOnnxPackage, {"entry_point_component": "embedding"}, disable_search=True) + out = p.run(composite, str(tmp_path / "out")) + + assert Path(out.model_path).parent.name == "embedding" + + def test_uses_fallback_entry_point_when_requested_one_missing(self, tmp_path): + src_root = _make_nested_genai_package( + tmp_path / "src", + {"vision": "vision_encoder/model.onnx", "embedding": "embedding/model.onnx"}, + ) + composite = _make_composite_handler( + src_root, {"vision_encoder": "vision_encoder/model.onnx", "embedding": "embedding/model.onnx"} + ) + + # 'decoder' (the default entry) is absent -> falls back to a present component. + p = create_pass_from_dict(CompositeToOnnxPackage, {}, disable_search=True) + out = p.run(composite, str(tmp_path / "out")) + + assert Path(out.model_path).parent.name in {"vision_encoder", "embedding"} + + def test_raises_on_non_composite_input(self, tmp_path): + _write_tiny_onnx_with_external_data(tmp_path / "model.onnx") + onnx_model = ONNXModelHandler(model_path=str(tmp_path / "model.onnx")) + + p = create_pass_from_dict(CompositeToOnnxPackage, {}, disable_search=True) + with pytest.raises(ValueError, match="expects a CompositeModelHandler"): + p.run(onnx_model, str(tmp_path / "out")) + + def test_raises_when_entry_component_file_missing(self, tmp_path): + src_root = _make_nested_genai_package(tmp_path / "src", {"decoder": "decoder/model.onnx"}) + composite = _make_composite_handler(src_root, {"decoder": "decoder/model.onnx"}) + # Remove the referenced component file so the config points at a missing file + # (after building the handler, whose components assert file existence). + (src_root / "decoder" / "model.onnx").unlink() + + p = create_pass_from_dict(CompositeToOnnxPackage, {}, disable_search=True) + with pytest.raises(ValueError, match="not found"): + p.run(composite, str(tmp_path / "out")) + + def test_rejects_entry_component_outside_package(self, tmp_path): + src_root = _make_nested_genai_package(tmp_path / "src", {"decoder": "../outside.onnx"}) + composite = _make_composite_handler(src_root, {"decoder": "../outside.onnx"}) + + p = create_pass_from_dict(CompositeToOnnxPackage, {}, disable_search=True) + with pytest.raises(ValueError, match="escapes the ORT-GenAI package directory"): + p.run(composite, str(tmp_path / "out")) diff --git a/test/test_cache.py b/test/test_cache.py index 4063ff1554..1a5ae58121 100644 --- a/test/test_cache.py +++ b/test/test_cache.py @@ -350,6 +350,169 @@ def test_save_model_with_custom_onnx_filename(self, tmp_path): with open(output_json_path) as f: assert expected_output_path == json.load(f)["config"]["model_path"] + @pytest.mark.parametrize("model_attributes", [None, {"ort_genai_package": True}]) + def test_save_model_preserves_ort_genai_package_layout(self, tmp_path, model_attributes): + model_id = "ort_genai_package" + cache = CacheConfig(cache_dir=tmp_path / "cache").create_cache() + + source_dir = tmp_path / "source_package" + decoder_dir = source_dir / "decoder" + vision_dir = source_dir / "vision_encoder" + decoder_dir.mkdir(parents=True) + vision_dir.mkdir() + (source_dir / "genai_config.json").write_text("{}", encoding="utf-8") + (decoder_dir / "model.onnx").write_text("decoder", encoding="utf-8") + (vision_dir / "model.onnx").write_text("vision", encoding="utf-8") + + model_config = { + "model_path": str(source_dir), + "onnx_file_name": "decoder/model.onnx", + } + if model_attributes: + model_config["model_attributes"] = model_attributes + model_json = { + "type": "onnxmodel", + "config": model_config, + } + with cache.get_model_json_path(model_id).open("w") as f: + json.dump(model_json, f) + + output_dir = tmp_path / "output_package" + output_json = cache.save_model(model_id, output_dir, True) + + assert output_json["config"]["model_path"] == str(output_dir.resolve()) + assert output_json["config"]["onnx_file_name"] == "decoder/model.onnx" + assert (output_dir / "genai_config.json").read_text(encoding="utf-8") == "{}" + assert (output_dir / "decoder" / "model.onnx").read_text(encoding="utf-8") == "decoder" + assert (output_dir / "vision_encoder" / "model.onnx").read_text(encoding="utf-8") == "vision" + + def test_save_model_preserves_file_backed_ort_genai_package_layout(self, tmp_path): + model_id = "ort_genai_package" + cache = CacheConfig(cache_dir=tmp_path / "cache").create_cache() + + source_dir = tmp_path / "source_package" + decoder_dir = source_dir / "decoder" + vision_dir = source_dir / "vision_encoder" + decoder_dir.mkdir(parents=True) + vision_dir.mkdir() + (source_dir / "genai_config.json").write_text("{}", encoding="utf-8") + decoder_path = decoder_dir / "model.onnx" + decoder_path.write_text("decoder", encoding="utf-8") + (vision_dir / "model.onnx").write_text("vision", encoding="utf-8") + model_json = { + "type": "onnxmodel", + "config": {"model_path": str(decoder_path), "model_attributes": {"ort_genai_package": True}}, + } + with cache.get_model_json_path(model_id).open("w") as f: + json.dump(model_json, f) + + output_dir = tmp_path / "output_package" + output_json = cache.save_model(model_id, output_dir, True) + + assert output_json["config"]["model_path"] == str(output_dir.resolve()) + assert output_json["config"]["onnx_file_name"] == "decoder/model.onnx" + assert (output_dir / "genai_config.json").is_file() + assert (output_dir / "decoder" / "model.onnx").read_text(encoding="utf-8") == "decoder" + assert (output_dir / "vision_encoder" / "model.onnx").read_text(encoding="utf-8") == "vision" + + @pytest.mark.parametrize("onnx_file_name", [None, "model.onnx"]) + def test_save_model_preserves_nested_directory_backed_ort_genai_package_layout(self, tmp_path, onnx_file_name): + model_id = "ort_genai_package" + cache = CacheConfig(cache_dir=tmp_path / "cache").create_cache() + + source_dir = tmp_path / "source_package" + decoder_dir = source_dir / "decoder" + vision_dir = source_dir / "vision_encoder" + decoder_dir.mkdir(parents=True) + vision_dir.mkdir() + (source_dir / "genai_config.json").write_text("{}", encoding="utf-8") + (decoder_dir / "model.onnx").write_text("decoder", encoding="utf-8") + (vision_dir / "model.onnx").write_text("vision", encoding="utf-8") + model_config = {"model_path": str(decoder_dir)} + if onnx_file_name is not None: + model_config["onnx_file_name"] = onnx_file_name + model_json = {"type": "onnxmodel", "config": model_config} + with cache.get_model_json_path(model_id).open("w") as f: + json.dump(model_json, f) + + output_dir = tmp_path / "output_package" + output_json = cache.save_model(model_id, output_dir, True) + + assert output_json["config"]["model_path"] == str(output_dir.resolve()) + assert output_json["config"]["onnx_file_name"] == "decoder/model.onnx" + assert (output_dir / "genai_config.json").is_file() + assert (output_dir / "decoder" / "model.onnx").read_text(encoding="utf-8") == "decoder" + assert (output_dir / "vision_encoder" / "model.onnx").read_text(encoding="utf-8") == "vision" + + def test_save_model_honors_overwrite_for_ort_genai_package(self, tmp_path): + model_id = "ort_genai_package" + cache = CacheConfig(cache_dir=tmp_path / "cache").create_cache() + source_dir = tmp_path / "source_package" + decoder_dir = source_dir / "decoder" + decoder_dir.mkdir(parents=True) + (source_dir / "genai_config.json").write_text("{}", encoding="utf-8") + (decoder_dir / "model.onnx").write_text("decoder", encoding="utf-8") + model_json = { + "type": "onnxmodel", + "config": {"model_path": str(source_dir), "onnx_file_name": "decoder/model.onnx"}, + } + with cache.get_model_json_path(model_id).open("w") as f: + json.dump(model_json, f) + + output_dir = tmp_path / "output_package" + output_dir.mkdir() + stale_file = output_dir / "stale.txt" + stale_file.write_text("stale", encoding="utf-8") + + with pytest.raises(FileExistsError, match="Output directory is not empty"): + cache.save_model(model_id, output_dir, overwrite=False) + + cache.save_model(model_id, output_dir, overwrite=True) + assert not stale_file.exists() + assert (output_dir / "genai_config.json").is_file() + + def test_save_model_rejects_ort_genai_package_file_output(self, tmp_path): + model_id = "ort_genai_package" + cache = CacheConfig(cache_dir=tmp_path / "cache").create_cache() + source_dir = tmp_path / "source_package" + source_dir.mkdir() + (source_dir / "genai_config.json").write_text("{}", encoding="utf-8") + model_json = { + "type": "onnxmodel", + "config": { + "model_path": str(source_dir), + "onnx_file_name": "decoder/model.onnx", + "model_attributes": {"ort_genai_package": True}, + }, + } + with cache.get_model_json_path(model_id).open("w") as f: + json.dump(model_json, f) + + with pytest.raises(ValueError, match="must be saved to a directory"): + cache.save_model(model_id, tmp_path / "output.onnx", True) + + @pytest.mark.parametrize("output_relation", ["inside", "contains"]) + def test_save_model_rejects_overlapping_ort_genai_package_paths(self, tmp_path, output_relation): + model_id = "ort_genai_package" + cache = CacheConfig(cache_dir=tmp_path / "cache").create_cache() + container_dir = tmp_path / "container" + source_dir = container_dir / "source_package" + source_dir.mkdir(parents=True) + (source_dir / "genai_config.json").write_text("{}", encoding="utf-8") + model_json = { + "type": "onnxmodel", + "config": {"model_path": str(source_dir), "onnx_file_name": "decoder/model.onnx"}, + } + with cache.get_model_json_path(model_id).open("w") as f: + json.dump(model_json, f) + + output_dir = source_dir / "output" if output_relation == "inside" else container_dir + with pytest.raises(ValueError, match="must not overlap"): + cache.save_model(model_id, output_dir, overwrite=True) + + assert source_dir.is_dir() + assert (source_dir / "genai_config.json").is_file() + def test_save_model_no_flatten_rebases_component_and_additional_file_paths(self, tmp_path): # setup model_id = "composite_model" From 7008f22ab0e951bccfc63d6c5b16f674ba8fa804 Mon Sep 17 00:00:00 2001 From: Delwin Kim <139003345+DelwinKim@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:58:16 +0000 Subject: [PATCH 2/4] Add lmms multimodal evaluator Integrate upstream lmms-eval generation tasks with Hugging Face wrappers and deployed ORT-GenAI image/audio packages, including custom task paths and result persistence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- olive/evaluator/lmms_ort.py | 1055 +++++++++++++ olive/evaluator/olive_evaluator.py | 536 +++++++ test/evaluator/test_lmms_ort.py | 1921 ++++++++++++++++++++++++ test/evaluator/test_olive_evaluator.py | 18 + test/requirements-test.txt | 2 + 5 files changed, 3532 insertions(+) create mode 100644 olive/evaluator/lmms_ort.py create mode 100644 test/evaluator/test_lmms_ort.py diff --git a/olive/evaluator/lmms_ort.py b/olive/evaluator/lmms_ort.py new file mode 100644 index 0000000000..6057af99b6 --- /dev/null +++ b/olive/evaluator/lmms_ort.py @@ -0,0 +1,1055 @@ +# ------------------------------------------------------------------------- +# ORT-GenAI wrapper for lmms-eval multimodal evaluation +# +# Supports evaluating multimodal ONNX +# models through the EvolvingLMMs-Lab/lmms-eval harness, mirroring how +# olive/evaluator/lmeval_ort.py wraps lm-evaluation-harness for text models. +# +# Registers an LMMSORTGenAIEvaluator class with lmms-eval's legacy +# @register_model registry under the name "ortgenai_mm". Consumers obtain it +# via lmms_eval.api.registry.get_model("ortgenai_mm"). +# ------------------------------------------------------------------------- +"""ORT-GenAI wrapper for lmms-eval multimodal evaluation.""" + +from __future__ import annotations + +import io +import json +import logging +import math +import re +import tempfile +from functools import lru_cache +from pathlib import Path +from typing import Any + +import numpy as np +import PIL.Image +from tqdm import tqdm + +try: + import onnxruntime_genai as og +except ImportError: # pragma: no cover - optional dep + og = None + +try: + from lmms_eval.api.instance import Instance + from lmms_eval.api.model import lmms + from lmms_eval.api.registry import register_model + + _LMMS_EVAL_IMPORT_ERROR = None +except ImportError as e: # pragma: no cover - optional dep + Instance = Any + _LMMS_EVAL_IMPORT_ERROR = e + + class lmms: # noqa: N801 + pass + + def register_model(_name): + def decorator(cls): + return cls + + return decorator + + +logger = logging.getLogger(__name__) + + +@lru_cache(maxsize=1) +def warmup_image_decoder() -> None: + """Force ORT-GenAI's image decoder to initialize once, early in the process. + + ORT-GenAI's ``ImageDecoder`` statically links its own zlib. Pillow, torch, + ``decord`` and other libraries loaded by the HuggingFace evaluation path also + bundle their own zlib. Because these are static copies of the same symbols + (an ODR violation), the *first* library to actually decode an image binds its + zlib process-wide. When ``LMMSEvaluator`` runs an HF input-model evaluation + before the ORT-GenAI (ONNX) evaluation in the same process + (``evaluate_input_model: true``), the HF path decodes images first, so + ORT-GenAI's PNG decoder later binds the wrong zlib and fails with + ``libpng error: bad parameters to zlib`` on every image. + + Decoding a tiny throwaway PNG through ORT-GenAI here — before the HF path + runs — makes ORT-GenAI's zlib win the binding, so the later ONNX evaluation + decodes correctly. Idempotent and best-effort: a no-op if onnxruntime-genai + is unavailable or the warmup fails (nothing is broken for HF-only usage). + """ + if og is None: + return + try: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "warmup.png" + PIL.Image.new("RGB", (8, 8), (0, 0, 0)).save(path, format="PNG") + # og.Images.open touches ORT-GenAI's libpng/zlib, binding its symbols. + og.Images.open(str(path)) + except Exception as e: # pragma: no cover - defensive, must never break a run + logger.debug("ORT-GenAI image decoder warmup skipped: %s", e) + + +_PROVIDER_ALIASES = { + "cuda": "cuda", + "cudaexecutionprovider": "cuda", + "gpu": "cuda", + "cpu": "cpu", + "cpuexecutionprovider": "cpu", + "dml": "dml", + "dmlexecutionprovider": "dml", + "directml": "dml", + "webgpu": "webgpu", + "webgpuexecutionprovider": "webgpu", + "js": "web", + "jsexecutionprovider": "web", + "nvtensorrtrtx": "NvTensorRtRtx", + "nvtensorrtrtxexecutionprovider": "NvTensorRtRtx", +} + + +# ----------------------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------------------- + + +_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"} +_AUDIO_SUFFIXES = {".wav", ".mp3", ".flac", ".ogg", ".m4a"} +_VIDEO_SUFFIXES = {".mp4", ".avi", ".mov", ".mkv", ".webm"} +_MEDIA_PLACEHOLDER_RE = re.compile(r"(|