Skip to content

Commit 40dbf1f

Browse files
justinchubyCopilot
andauthored
Add onnx-genai InferenceMetadata export (--runtime onnx-genai) (#398)
Adds a Mobius integration that emits onnx-genai's own `inference_metadata.yaml` (the InferenceMetadata schema) instead of ORT-GenAI's `genai_config.json`, so onnx-genai models carry the config that runtime needs. - New `--runtime onnx-genai` build target; emitter `src/mobius/integrations/onnx_genai/inference_metadata.py`. - Emits `model.attention` (type/heads/head_dim), `model.max_sequence_length`, `kv_cache.native_dtype`, and `required_capabilities` using the runtime's supported strings (`grouped_query_attention` / `multi_head_attention`). - KV pre-size capacity defaults to `min(4096, model limit)` (avoids exceeding WebGPU's 256 MiB buffer limit for large context windows); optional `--max-length` override. - **Round-trip verified:** onnx-genai loads a GQA WebGPU model from the emitted `inference_metadata.yaml` alone (no genai_config.json) and produces coherent output. lintrunner clean; emitter pytest 8 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1ab1661 commit 40dbf1f

5 files changed

Lines changed: 300 additions & 7 deletions

File tree

src/mobius/__main__.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,11 @@ def _cmd_build(args: argparse.Namespace) -> None:
127127
if args.max_seq_len is not None and args.max_seq_len <= 0:
128128
raise SystemExit("Error: --max-seq-len must be a positive integer.")
129129

130+
if args.max_length is not None and args.runtime != "onnx-genai":
131+
raise SystemExit("Error: --max-length can only be used with --runtime onnx-genai.")
132+
if args.max_length is not None and args.max_length <= 0:
133+
raise SystemExit("Error: --max-length must be a positive integer.")
134+
130135
# Validate --static-cache + --task compatibility
131136
if args.static_cache and args.task is not None:
132137
raise SystemExit(
@@ -293,6 +298,13 @@ def _save_package(
293298
)
294299
for name, path in artifacts.items():
295300
print(f" {name}: {path}")
301+
elif runtime == "onnx-genai":
302+
from mobius.integrations.onnx_genai import write_inference_metadata
303+
304+
path = write_inference_metadata(
305+
pkg, output_dir, max_sequence_length=getattr(args, "max_length", None)
306+
)
307+
print(f" inference_metadata: {path}")
296308

297309

298310
def _cmd_list(args: argparse.Namespace) -> None:
@@ -555,14 +567,25 @@ def main(argv: list[str] | None = None) -> None:
555567
build_parser.add_argument(
556568
"--runtime",
557569
default=None,
558-
choices=["ort-genai"],
570+
choices=["onnx-genai", "ort-genai"],
559571
metavar="RUNTIME",
560572
help=(
561573
"Generate runtime-specific config files after building. "
562-
"Currently supports: 'ort-genai' (writes genai_config.json and "
563-
"copies tokenizer files). When used with --model, tokenizer files "
564-
"are downloaded from HuggingFace. When used with --config (local "
565-
"directory), tokenizer files are copied from that directory."
574+
"Supports: 'onnx-genai' (writes inference_metadata.yaml) and "
575+
"'ort-genai' (writes genai_config.json and copies tokenizer files). "
576+
"For ort-genai, --model downloads tokenizer files from HuggingFace "
577+
"and --config copies them from the local directory."
578+
),
579+
)
580+
build_parser.add_argument(
581+
"--max-length",
582+
type=int,
583+
default=None,
584+
metavar="N",
585+
help=(
586+
"Serving KV capacity written to onnx-genai inference metadata. "
587+
"Only used with --runtime onnx-genai. Defaults to the smaller of "
588+
"4096 and the model's max_position_embeddings."
566589
),
567590
)
568591
build_parser.add_argument(

src/mobius/_model_package.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,10 @@ def save(
7777
This method writes ONNX files only. If you need a directory that
7878
``onnxruntime-genai`` can load (i.e. with ``genai_config.json`` and
7979
tokenizer files), use
80-
:func:`mobius.integrations.ort_genai.export_package` instead — it
81-
wraps :meth:`save` with the ORT-GenAI config-generation step.
80+
:func:`mobius.integrations.ort_genai.export_package` instead. For
81+
onnx-genai, call
82+
:func:`mobius.integrations.onnx_genai.write_inference_metadata`
83+
after saving, or use ``mobius build --runtime onnx-genai``.
8284
8385
Args:
8486
directory: Path to the output directory (created if needed).
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""onnx-genai integration for inference metadata generation."""
5+
6+
from mobius.integrations.onnx_genai.inference_metadata import (
7+
generate_inference_metadata,
8+
write_inference_metadata,
9+
)
10+
11+
__all__ = ["generate_inference_metadata", "write_inference_metadata"]
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Generate onnx-genai ``inference_metadata.yaml`` sidecars."""
5+
6+
from __future__ import annotations
7+
8+
import os
9+
from typing import Any
10+
11+
import onnx_ir as ir
12+
13+
from mobius._model_package import ModelPackage
14+
15+
_DTYPE_NAMES = {
16+
ir.DataType.FLOAT: "float32",
17+
ir.DataType.FLOAT16: "float16",
18+
ir.DataType.BFLOAT16: "bfloat16",
19+
}
20+
_DEFAULT_MAX_SEQUENCE_LENGTH = 4096
21+
22+
23+
def _positive_int(config: object, name: str) -> int:
24+
value = getattr(config, name, None)
25+
if not isinstance(value, int) or value <= 0:
26+
raise ValueError(
27+
f"onnx-genai inference metadata requires a positive {name}, got {value!r}."
28+
)
29+
return value
30+
31+
32+
def _kv_dtype(config: object) -> str:
33+
dtype = getattr(config, "dtype", None)
34+
try:
35+
return _DTYPE_NAMES[dtype]
36+
except KeyError:
37+
raise ValueError(
38+
"onnx-genai inference metadata supports float32, float16, or bfloat16 "
39+
f"KV caches, got {dtype!r}."
40+
) from None
41+
42+
43+
def _max_sequence_length(config: object, requested: int | None) -> int:
44+
model_max = _positive_int(config, "max_position_embeddings")
45+
if requested is None:
46+
return min(model_max, _DEFAULT_MAX_SEQUENCE_LENGTH)
47+
if not isinstance(requested, int) or requested <= 0:
48+
raise ValueError(
49+
f"onnx-genai max_sequence_length must be a positive integer, got {requested!r}."
50+
)
51+
if requested > model_max:
52+
raise ValueError(
53+
f"onnx-genai max_sequence_length {requested} exceeds the model limit {model_max}."
54+
)
55+
return requested
56+
57+
58+
def generate_inference_metadata(
59+
config: object, *, max_sequence_length: int | None = None
60+
) -> dict[str, Any]:
61+
"""Map a decoder config to metadata with a conservative serving KV capacity."""
62+
num_attention_heads = _positive_int(config, "num_attention_heads")
63+
num_kv_heads = _positive_int(config, "num_key_value_heads")
64+
head_dim = _positive_int(config, "head_dim")
65+
max_sequence_length = _max_sequence_length(config, max_sequence_length)
66+
kv_dtype = _kv_dtype(config)
67+
68+
is_gqa = num_kv_heads != num_attention_heads
69+
capabilities = ["grouped_query_attention" if is_gqa else "multi_head_attention"]
70+
71+
attention: dict[str, Any] = {
72+
"type": "group_query_attention" if is_gqa else "multi_head_attention",
73+
"num_kv_heads": num_kv_heads,
74+
"num_attention_heads": num_attention_heads,
75+
"head_dim": head_dim,
76+
}
77+
sliding_window = getattr(config, "sliding_window", None)
78+
if isinstance(sliding_window, int) and sliding_window > 0:
79+
attention["sliding_window"] = sliding_window
80+
81+
return {
82+
"required_capabilities": capabilities,
83+
"model": {
84+
"attention": attention,
85+
"max_sequence_length": max_sequence_length,
86+
"runtime_configurable": {"kv_cache": {"dtype": [kv_dtype]}},
87+
},
88+
"kv_cache": {"native_dtype": kv_dtype},
89+
}
90+
91+
92+
def _to_yaml(metadata: dict[str, Any]) -> str:
93+
capabilities = metadata["required_capabilities"]
94+
attention = metadata["model"]["attention"]
95+
kv_dtypes = metadata["model"]["runtime_configurable"]["kv_cache"]["dtype"]
96+
97+
lines = ["required_capabilities:"]
98+
if capabilities:
99+
lines.extend(f" - {capability}" for capability in capabilities)
100+
else:
101+
lines[-1] += " []"
102+
103+
lines.extend(
104+
[
105+
"model:",
106+
" attention:",
107+
f" type: {attention['type']}",
108+
f" num_kv_heads: {attention['num_kv_heads']}",
109+
f" num_attention_heads: {attention['num_attention_heads']}",
110+
f" head_dim: {attention['head_dim']}",
111+
]
112+
)
113+
if "sliding_window" in attention:
114+
lines.append(f" sliding_window: {attention['sliding_window']}")
115+
lines.extend(
116+
[
117+
f" max_sequence_length: {metadata['model']['max_sequence_length']}",
118+
" runtime_configurable:",
119+
" kv_cache:",
120+
" dtype:",
121+
*(f" - {dtype}" for dtype in kv_dtypes),
122+
"kv_cache:",
123+
f" native_dtype: {metadata['kv_cache']['native_dtype']}",
124+
]
125+
)
126+
return "\n".join(lines) + "\n"
127+
128+
129+
def write_inference_metadata(
130+
pkg: ModelPackage,
131+
directory: str,
132+
*,
133+
max_sequence_length: int | None = None,
134+
) -> str:
135+
"""Write ``inference_metadata.yaml`` for an already-built model package."""
136+
config = getattr(pkg, "config", None)
137+
if config is None:
138+
raise ValueError(
139+
"write_inference_metadata requires ModelPackage.config to be set. "
140+
"This is set automatically when building with mobius.build()."
141+
)
142+
143+
os.makedirs(directory, exist_ok=True)
144+
path = os.path.join(directory, "inference_metadata.yaml")
145+
with open(path, "w", encoding="utf-8") as file:
146+
file.write(
147+
_to_yaml(
148+
generate_inference_metadata(config, max_sequence_length=max_sequence_length)
149+
)
150+
)
151+
return path
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
import onnx_ir as ir
5+
import pytest
6+
7+
from mobius._configs import ArchitectureConfig
8+
from mobius._model_package import ModelPackage
9+
from mobius.integrations.onnx_genai import (
10+
generate_inference_metadata,
11+
write_inference_metadata,
12+
)
13+
14+
15+
def _config(**kwargs) -> ArchitectureConfig:
16+
values = {
17+
"num_attention_heads": 14,
18+
"num_key_value_heads": 2,
19+
"head_dim": 64,
20+
"max_position_embeddings": 32768,
21+
"dtype": ir.DataType.FLOAT16,
22+
}
23+
values.update(kwargs)
24+
return ArchitectureConfig(**values)
25+
26+
27+
def test_generate_gqa_fp16_metadata() -> None:
28+
metadata = generate_inference_metadata(_config(sliding_window=4096))
29+
30+
assert metadata == {
31+
"required_capabilities": ["grouped_query_attention"],
32+
"model": {
33+
"attention": {
34+
"type": "group_query_attention",
35+
"num_kv_heads": 2,
36+
"num_attention_heads": 14,
37+
"head_dim": 64,
38+
"sliding_window": 4096,
39+
},
40+
"max_sequence_length": 4096,
41+
"runtime_configurable": {"kv_cache": {"dtype": ["float16"]}},
42+
},
43+
"kv_cache": {"native_dtype": "float16"},
44+
}
45+
46+
47+
def test_generate_mha_float32_metadata() -> None:
48+
metadata = generate_inference_metadata(
49+
_config(
50+
num_attention_heads=8,
51+
num_key_value_heads=8,
52+
dtype=ir.DataType.FLOAT,
53+
sliding_window=None,
54+
)
55+
)
56+
57+
assert metadata["required_capabilities"] == ["multi_head_attention"]
58+
assert metadata["model"]["attention"] == {
59+
"type": "multi_head_attention",
60+
"num_kv_heads": 8,
61+
"num_attention_heads": 8,
62+
"head_dim": 64,
63+
}
64+
assert metadata["kv_cache"]["native_dtype"] == "float32"
65+
66+
67+
def test_write_inference_metadata_yaml(tmp_path) -> None:
68+
pkg = ModelPackage(config=_config())
69+
70+
path = write_inference_metadata(pkg, str(tmp_path))
71+
72+
assert path == str(tmp_path / "inference_metadata.yaml")
73+
assert (tmp_path / "inference_metadata.yaml").read_text() == (
74+
"required_capabilities:\n"
75+
" - grouped_query_attention\n"
76+
"model:\n"
77+
" attention:\n"
78+
" type: group_query_attention\n"
79+
" num_kv_heads: 2\n"
80+
" num_attention_heads: 14\n"
81+
" head_dim: 64\n"
82+
" max_sequence_length: 4096\n"
83+
" runtime_configurable:\n"
84+
" kv_cache:\n"
85+
" dtype:\n"
86+
" - float16\n"
87+
"kv_cache:\n"
88+
" native_dtype: float16\n"
89+
)
90+
91+
92+
def test_max_sequence_length_override() -> None:
93+
metadata = generate_inference_metadata(_config(), max_sequence_length=2048)
94+
95+
assert metadata["model"]["max_sequence_length"] == 2048
96+
97+
98+
@pytest.mark.parametrize("max_sequence_length", [0, -1, 32769])
99+
def test_invalid_max_sequence_length(max_sequence_length: int) -> None:
100+
with pytest.raises(ValueError, match="max_sequence_length"):
101+
generate_inference_metadata(_config(), max_sequence_length=max_sequence_length)
102+
103+
104+
def test_write_requires_package_config(tmp_path) -> None:
105+
with pytest.raises(ValueError, match=r"ModelPackage\.config"):
106+
write_inference_metadata(ModelPackage(), str(tmp_path))

0 commit comments

Comments
 (0)