Skip to content

Commit 8c5b096

Browse files
justinchubyCopilot
andauthored
MobiusBuilder + cache: produce loadable ORT GenAI composite packages (microsoft#2472)
Three fixes that together let `MobiusBuilder`-produced multimodal ORT GenAI packages round-trip through Olive without corruption. ## Problem 1: Composite output flattened, sidecars dropped `mobius.build()` writes a multi-component ORT GenAI package laid out as: ``` <output_dir>/ ├── decoder/model.onnx ├── embedding/model.onnx ├── vision_encoder/model.onnx ├── audio_encoder/model.onnx ├── genai_config.json ├── tokenizer.json / tokenizer_config.json / chat_template.jinja ├── image_processor.json └── audio_feature_extraction.json ``` ORT GenAI reads `genai_config.json` from the root and resolves each component's relative `filename` (e.g. `decoder/model.onnx`) underneath. By default Olive's cache **flattens** `CompositeModelHandler` outputs to top-level `<component_name>.onnx` and **drops sidecars** (`olive/cache.py:394-407`). Result: ``` output/ ├── decoder.onnx, decoder.onnx.data, embedding.onnx, vision_encoder.onnx, audio_encoder.onnx └── (no genai_config.json, no tokenizer*, no image_processor.json) ``` Breaks ORT GenAI loading entirely. ### Fix (commit 1: `15f7c0f0`) Set `no_flatten=True` on the `CompositeModelHandler.model_attributes`. Olive's cache then uses the `shutil.copytree(source_path, actual_output_dir)` branch, preserving the `<component>/model.onnx` subdirectory layout and copying every sidecar file alongside. ## Problem 2: Sidecars duplicated into each component dir after a downstream pass When a downstream pass (e.g. `OnnxKQuantQuantization`) runs on the composite, Olive splits it and runs the pass per component (`olive_pass.py:230-241`). `_carry_forward_additional_files` then copies each input component's `additional_files` into the output component's directory. The original implementation attached the shared package sidecars to **every** component's `additional_files`. After the quant pass: ``` cuda/int4/models/ ├── decoder/{model.onnx, model.onnx.data, genai_config.json, tokenizer.json, image_processor.json, ...} ├── embedding/{model.onnx, model.onnx.data, genai_config.json, tokenizer.json, ...} ├── vision_encoder/... ├── audio_encoder/... └── (no genai_config.json at root!) ``` Sidecars live in every subdirectory but the root has none — still broken for ORT GenAI. ### Fix (commit 2: `3b2ac939`) Attach shared package sidecars to the **CompositeModelHandler**'s `model_attributes['additional_files']`, not each component's. The composite's `model_path` *is* the package root, so the cross-pass carry-forward / `no_flatten` copytree puts them at the root. Per-component `additional_files` now only carries files that genuinely live inside the component dir. ## Problem 3: `model_config.json` records nonexistent component paths `Cache.save_model()`'s `no_flatten` branch (`olive/cache.py:404-407`) rewrites every component's `model_path` to the composite root while leaving `onnx_file_name` untouched. For models whose components live under `<root>/<component>/model.onnx` (both `MobiusBuilder` packages and `optimum`-exported diffusers pipelines from `conversion.py:829-831,863`), the saved `model_config.json` then points each component at `<root>/model.onnx`, which does not exist. Reloading the saved composite fails. ### Fix (commit 3: `14a15462`) Rebase each component's `model_path` by its position relative to the original source dir. A component at `<source>/decoder/` now lands at `<actual_output_dir>/decoder/`, with `onnx_file_name` unchanged. Components that fall outside the source tree fall back to the package root. ## Verification Two recipes from microsoft/olive-recipes#381: **`cpu/fp32/config.json` (MobiusBuilder only):** ``` cpu/fp32/models/ ├── audio_encoder/model.onnx ├── decoder/model.onnx ├── embedding/model.onnx ├── vision_encoder/model.onnx ├── audio_feature_extraction.json ├── chat_template.jinja ├── genai_config.json ├── image_processor.json ├── tokenizer.json └── tokenizer_config.json ``` **`cuda/int4/config.json` (MobiusBuilder → OnnxKQuantQuantization):** ``` cuda/int4/models/ ├── audio_encoder/{model.onnx, model.onnx.data} ├── decoder/{model.onnx, model.onnx.data} ├── embedding/{model.onnx, model.onnx.data} ├── vision_encoder/{model.onnx, model.onnx.data} ├── audio_feature_extraction.json ├── chat_template.jinja ├── genai_config.json ├── image_processor.json ├── tokenizer.json └── tokenizer_config.json ``` `model_config.json` records the correct component paths (`decoder/model.onnx`, `embedding/model.onnx`, `vision_encoder/model.onnx`, `audio_encoder/model.onnx`), matching the on-disk layout. `ruff check` and `ruff format --check` both pass on the changed files. ## Notes - `no_flatten=True` reuses the existing pattern from the diffusers/optimum integration (`olive/cache.py:398`). - `_carry_forward_additional_files` already works for any model whose `model_path` is a directory (`olive_pass.py:266` `is_dir()` branch), so `CompositeModelHandler` (whose `model_path` is the package root dir) is supported without any framework changes. - The cache-layer component-path rebasing also fixes a latent bug in the existing optimum/diffusers `no_flatten` consumer; nobody hit it because nobody had reloaded those saved configs. - No public API change. --------- Signed-off-by: justinchuby <11205048+justinchuby@users.noreply.github.com> Co-authored-by: justinchuby <11205048+justinchuby@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 1db66b8 commit 8c5b096

4 files changed

Lines changed: 154 additions & 13 deletions

File tree

olive/cache.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -396,14 +396,57 @@ def save_model(
396396
model_attributes = model_json_config.get("model_attributes") or {}
397397

398398
if model_attributes.get("no_flatten"):
399-
# Preserve directory structure (e.g., for diffusers models exported by optimum)
399+
# Preserve directory structure (e.g., for diffusers models
400+
# exported by optimum, or multimodal ORT GenAI packages from
401+
# MobiusBuilder where components live in <root>/<component>/).
400402
source_path = Path(model_json_config["model_path"])
403+
source_path_resolved = source_path.resolve()
401404
if source_path.exists():
402405
shutil.copytree(source_path, actual_output_dir, dirs_exist_ok=overwrite)
403406

404-
# Update component paths to point to new location
407+
def _rebase_additional_files(config: dict, fallback_dir: Path):
408+
model_attributes = config.get("model_attributes") or {}
409+
additional_files = model_attributes.get("additional_files") or []
410+
if not additional_files:
411+
return
412+
413+
rebased_additional_files = []
414+
for additional_file in additional_files:
415+
source_additional_file = Path(additional_file)
416+
try:
417+
relative = source_additional_file.resolve().relative_to(source_path_resolved)
418+
output_additional_file = actual_output_dir / relative
419+
except ValueError:
420+
output_additional_file = fallback_dir / source_additional_file.name
421+
if source_additional_file.exists() and not output_additional_file.exists():
422+
output_additional_file.parent.mkdir(parents=True, exist_ok=True)
423+
shutil.copy2(source_additional_file, output_additional_file)
424+
rebased_additional_files.append(str(output_additional_file))
425+
426+
model_attributes["additional_files"] = rebased_additional_files
427+
config["model_attributes"] = model_attributes
428+
429+
# Rewrite each component's model_path so it points into the
430+
# new output location while preserving the component's
431+
# relative position underneath the package root. Without
432+
# rebasing component paths the saved model_config.json
433+
# cannot be loaded (and onnx_file_name is left untouched,
434+
# so we must not collapse component subdirs into the root).
435+
_rebase_additional_files(model_json_config, actual_output_dir)
405436
for component in model_json_config["model_components"]:
406-
component["config"]["model_path"] = str(actual_output_dir)
437+
component_config = component["config"]
438+
component_model_path = component_config.get("model_path")
439+
if component_model_path:
440+
try:
441+
relative = Path(component_model_path).resolve().relative_to(source_path_resolved)
442+
except ValueError:
443+
# Component path is not under the composite root;
444+
# fall back to placing it at the package root.
445+
relative = Path()
446+
component_config["model_path"] = str(actual_output_dir / relative)
447+
else:
448+
component_config["model_path"] = str(actual_output_dir)
449+
_rebase_additional_files(component_config, Path(component_config["model_path"]))
407450
model_json_config["model_path"] = str(actual_output_dir)
408451
else:
409452
copied_components = []

olive/passes/onnx/mobius_model_builder.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,9 @@ def _run_for_config(
201201
)
202202

203203
# Multi-component model (VLMs, encoder-decoders, diffusion pipelines):
204-
# mobius saves each component to <output_dir>/<key>/model.onnx.
204+
# mobius saves each component to <output_dir>/<key>/model.onnx with shared
205+
# sidecar files (genai_config.json, tokenizer.json, image_processor.json,
206+
# audio_feature_extraction.json) at output_dir root.
205207
components = []
206208
for key in package_keys:
207209
component_dir = output_dir / key
@@ -211,18 +213,20 @@ def _run_for_config(
211213
f"MobiusBuilder: expected output file not found: {onnx_path}. "
212214
f"mobius.build() may have failed silently for component '{key}'."
213215
)
214-
additional_files = sorted(
216+
# Per-component additional files: only files that live inside the
217+
# component's own directory. Shared sidecars (genai_config, tokenizer,
218+
# image_processor) are attached to the composite handler below so
219+
# they land in the output root, not duplicated in every component.
220+
component_additional_files = sorted(
215221
{str(fp) for fp in component_dir.iterdir()} - {str(onnx_path), str(onnx_path) + ".data"}
216222
)
217-
# Include ORT GenAI artifacts from root output_dir (shared across components)
218-
additional_files = sorted(set(additional_files) | set(genai_artifacts.values()))
219223
components.append(
220224
ONNXModelHandler(
221225
model_path=str(component_dir),
222226
onnx_file_name="model.onnx",
223227
model_attributes={
224228
"mobius_component": key,
225-
"additional_files": additional_files,
229+
"additional_files": component_additional_files,
226230
**(model.model_attributes or {}),
227231
},
228232
)
@@ -234,6 +238,15 @@ def _run_for_config(
234238
model_path=str(output_dir),
235239
model_attributes={
236240
"mobius_package_keys": package_keys,
241+
# Preserve the <component>/model.onnx subdirectory layout so
242+
# ORT GenAI can resolve each component by its "filename" key.
243+
# Without this, Olive's cache flattens components to top-level
244+
# <name>.onnx files and breaks GenAI loading.
245+
"no_flatten": True,
246+
# Shared package-level sidecars carried via the composite handler
247+
# so they end up at the package root (alongside genai_config.json),
248+
# not duplicated into each <component>/ subdirectory.
249+
"additional_files": sorted(set(genai_artifacts.values())),
237250
**(model.model_attributes or {}),
238251
},
239252
)

test/passes/onnx/test_mobius_model_builder.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -220,12 +220,22 @@ def test_genai_artifacts_in_single_component(tmp_path):
220220

221221

222222
def test_genai_artifacts_in_multi_component(tmp_path):
223-
"""ORT GenAI artifacts must be included in all components of multi-component models."""
223+
"""ORT GenAI artifacts must be attached at composite-level, not duplicated per component."""
224224
out = tmp_path / "out"
225225
out.mkdir(parents=True, exist_ok=True)
226226
keys = ["model", "vision", "embedding"]
227227
pkg = _fake_pkg(keys, out)
228228

229+
def _save_with_component_sidecar(directory: str, **_kwargs):
230+
out_dir = Path(directory)
231+
for key in keys:
232+
component_dir = out_dir / key
233+
component_dir.mkdir(parents=True, exist_ok=True)
234+
(component_dir / "model.onnx").write_text("dummy")
235+
(out_dir / "vision" / "vision_local.txt").write_text("vision")
236+
237+
pkg.save.side_effect = _save_with_component_sidecar
238+
229239
# Mock genai artifact files
230240
genai_config = str(out / "genai_config.json")
231241
image_processor = str(out / "image_processor.json")
@@ -240,11 +250,19 @@ def test_genai_artifacts_in_multi_component(tmp_path):
240250
result = p.run(_make_hf_model("microsoft/phi-4-vision"), out)
241251

242252
assert isinstance(result, CompositeModelHandler)
243-
# Verify all components include genai artifacts
244-
for component in result.model_components:
253+
composite_additional_files = result.model_attributes.get("additional_files", [])
254+
assert genai_config in composite_additional_files
255+
assert image_processor in composite_additional_files
256+
257+
# Shared GenAI sidecars should not be duplicated into each component.
258+
components = list(result.model_components)
259+
for component in components:
245260
additional_files = component.model_attributes.get("additional_files", [])
246-
assert genai_config in additional_files
247-
assert image_processor in additional_files
261+
assert genai_config not in additional_files
262+
assert image_processor not in additional_files
263+
264+
vision = components[result.model_component_names.index("vision")]
265+
assert str(out / "vision" / "vision_local.txt") in vision.model_attributes.get("additional_files", [])
248266

249267

250268
# ---------------------------------------------------------------------------

test/test_cache.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,73 @@ def test_save_model_with_custom_onnx_filename(self, tmp_path):
350350
with open(output_json_path) as f:
351351
assert expected_output_path == json.load(f)["config"]["model_path"]
352352

353+
def test_save_model_no_flatten_rebases_component_and_additional_file_paths(self, tmp_path):
354+
# setup
355+
model_id = "composite_model"
356+
cache = CacheConfig(cache_dir=tmp_path / "cache").create_cache()
357+
358+
source_dir = tmp_path / "source_model"
359+
decoder_dir = source_dir / "decoder"
360+
embedding_dir = source_dir / "embedding"
361+
decoder_dir.mkdir(parents=True, exist_ok=True)
362+
embedding_dir.mkdir(parents=True, exist_ok=True)
363+
(decoder_dir / "model.onnx").write_text("decoder")
364+
(embedding_dir / "model.onnx").write_text("embedding")
365+
(decoder_dir / "decoder_local.txt").write_text("decoder local")
366+
(source_dir / "genai_config.json").write_text("{}")
367+
(source_dir / "tokenizer.json").write_text("{}")
368+
369+
model_json = {
370+
"type": "compositemodel",
371+
"config": {
372+
"model_path": str(source_dir),
373+
"model_component_names": ["decoder", "embedding"],
374+
"model_components": [
375+
{
376+
"type": "onnxmodel",
377+
"config": {
378+
"model_path": str(decoder_dir),
379+
"onnx_file_name": "model.onnx",
380+
"model_attributes": {"additional_files": [str(decoder_dir / "decoder_local.txt")]},
381+
},
382+
},
383+
{
384+
"type": "onnxmodel",
385+
"config": {"model_path": str(embedding_dir), "onnx_file_name": "model.onnx"},
386+
},
387+
],
388+
"model_attributes": {
389+
"no_flatten": True,
390+
"additional_files": [str(source_dir / "genai_config.json"), str(source_dir / "tokenizer.json")],
391+
},
392+
},
393+
}
394+
with cache.get_model_json_path(model_id).open("w") as f:
395+
json.dump(model_json, f)
396+
397+
output_dir = tmp_path / "output"
398+
output_json = cache.save_model(model_id, output_dir, True)
399+
400+
# assert copied layout
401+
assert (output_dir / "decoder" / "model.onnx").exists()
402+
assert (output_dir / "embedding" / "model.onnx").exists()
403+
assert (output_dir / "genai_config.json").exists()
404+
assert (output_dir / "tokenizer.json").exists()
405+
406+
# assert rewritten config paths
407+
assert output_json["config"]["model_path"] == str(output_dir)
408+
decoder_component = output_json["config"]["model_components"][0]["config"]
409+
assert decoder_component["model_path"] == str(output_dir / "decoder")
410+
assert decoder_component["onnx_file_name"] == "model.onnx"
411+
assert decoder_component["model_attributes"]["additional_files"] == [
412+
str(output_dir / "decoder" / "decoder_local.txt")
413+
]
414+
415+
assert output_json["config"]["model_attributes"]["additional_files"] == [
416+
str(output_dir / "genai_config.json"),
417+
str(output_dir / "tokenizer.json"),
418+
]
419+
353420

354421
class TestSharedCache:
355422
@pytest.fixture(autouse=True)

0 commit comments

Comments
 (0)