Skip to content

Commit e845a3d

Browse files
justinchubyCopilot
andauthored
fix: Gemma4 genai config - vision inputs, decoder input_ids, processor (#199)
## Summary Fix Gemma4 ORT GenAI config generation to produce correct genai_config.json and processor_config.json. ## Changes ### 1. Vision inputs (auto_export.py) Gemma4 uses `pixel_values + pixel_position_ids` (not `image_grid_thw`). Added Gemma4-specific branch in `_write_genai_config()` that sets the correct input names and disables `spatial_merge_size`. ### 2. Decoder input_ids (genai_config.py) Gemma4 decoders need `input_ids` alongside `inputs_embeds` for per-layer token embeddings (E2B architecture). Added `with_extra_decoder_inputs()` method to `GenaiConfigGenerator` and wired it for Gemma4 in `_write_genai_config()`. ### 3. Processor config (auto_export.py) Updated `_write_processor_config()` to detect Gemma4 and write the correct format with `name`, `tokens_per_image`, `mean`, and `std` fields wrapped under a `processor` key, matching the ort-extensions expected format. ### 4. Reference config updated Added `input_ids` to decoder inputs in `examples/gemma4/ort_genai/vlm/genai_config.json`. ## Testing - All 85 ORT GenAI tests pass - All 12 Gemma4 build graph tests pass - Full test suite: 2557 passed, 41 skipped - Linter clean --------- Signed-off-by: Justin Chu <justinchu@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1045792 commit e845a3d

9 files changed

Lines changed: 548 additions & 25 deletions

File tree

examples/gemma4/ort_genai/vlm/genai_config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"num_hidden_layers": 15,
2121
"inputs": {
2222
"inputs_embeds": "inputs_embeds",
23+
"input_ids": "input_ids",
2324
"attention_mask": "attention_mask",
2425
"position_ids": "position_ids",
2526
"past_key_names": "past_key_values.%d.key",

examples/gemma4/ort_genai/vlm/processor_config.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33
"name": "gemma4_image_processor",
44
"image_size": 448,
55
"patch_size": 16,
6-
"tokens_per_image": 280,
7-
"mean": [0.5, 0.5, 0.5],
8-
"std": [0.5, 0.5, 0.5]
6+
"tokens_per_image": 280
97
}
108
}

src/mobius/_optimizations.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@
5454

5555
from mobius._execution_providers import EpCapabilities, ep_registry
5656
from mobius._flags import flags
57-
from mobius._passes import FoldConcatInitializersPass, FoldTransposedInitializerPass
57+
from mobius._passes import (
58+
FoldConcatInitializersPass,
59+
FoldTransposedInitializerPass,
60+
RemoveDeadGraphInputsPass,
61+
)
5862
from mobius.functions import register_function_bodies
5963
from mobius.rewrite_rules import (
6064
gelu_fusion_rules,
@@ -445,7 +449,8 @@ def _should_inline(func: ir.Function) -> bool:
445449
for _, ir_pass in lower_ir_passes:
446450
ir_pass(model)
447451

448-
# Stage 4: Final dead-node removal and constant folding after rewrites.
452+
# Stage 4: Final dead-node removal, constant folding, and dead input
453+
# cleanup after rewrites.
449454
if trace:
450455
before_fold = sum(_count_all_ops(model).values())
451456
logger.info("[EP Trace] Stage 4: Constant folding")
@@ -461,6 +466,9 @@ def _should_inline(func: ir.Function) -> bool:
461466
input_size_limit=8192,
462467
output_size_limit=_FOLD_OUTPUT_SIZE_LIMIT,
463468
),
469+
# Remove graph inputs whose consumers were all eliminated by
470+
# fusion (e.g. position_ids when GQA absorbs RoPE).
471+
RemoveDeadGraphInputsPass(),
464472
]
465473
)
466474
fold_pass(model)

src/mobius/_passes/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@
4242
__all__ = [
4343
"FoldTransposedInitializerPass",
4444
"FoldConcatInitializersPass",
45+
"RemoveDeadGraphInputsPass",
4546
]
4647

4748
from mobius._passes._fold_concat import FoldConcatInitializersPass
4849
from mobius._passes._fold_transpose import FoldTransposedInitializerPass
50+
from mobius._passes._remove_dead_inputs import RemoveDeadGraphInputsPass
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Pass that removes unused graph inputs.
5+
6+
After EP-aware optimization (e.g. GQA fusion absorbs RoPE), some graph
7+
inputs may have zero consumers. For example, ``position_ids`` becomes
8+
dead when all attention layers use ``GroupQueryAttention`` with
9+
``do_rotary=1``. Removing dead inputs produces cleaner models and
10+
avoids requiring the runtime to provide dummy feed values.
11+
12+
KV cache inputs (``past_key_values.*``) are always retained even if
13+
they appear unused in the graph, because ORT GenAI manages them
14+
externally via the KV cache protocol.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import logging
20+
21+
import onnx_ir as ir
22+
23+
logger = logging.getLogger(__name__)
24+
25+
26+
class RemoveDeadGraphInputsPass(ir.passes.InPlacePass):
27+
"""Remove graph inputs that have no consumers.
28+
29+
Skips inputs whose name starts with ``past_key_values.`` (KV cache
30+
entries managed by the runtime) and inputs with ``None`` names.
31+
"""
32+
33+
def call(self, model: ir.Model) -> ir.passes.PassResult:
34+
dead = [
35+
inp
36+
for inp in model.graph.inputs
37+
if inp.name is not None
38+
and not inp.name.startswith("past_key_values.")
39+
and len(inp.uses()) == 0
40+
]
41+
for inp in dead:
42+
model.graph.inputs.remove(inp)
43+
logger.debug("Removed dead graph input: %s", inp.name)
44+
45+
modified = len(dead) > 0
46+
return ir.passes.PassResult(model, modified=modified)

src/mobius/integrations/ort_genai/auto_export.py

Lines changed: 90 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
from typing import TYPE_CHECKING, Any
4343

4444
if TYPE_CHECKING:
45+
import onnx_ir as ir
46+
4547
from mobius._model_package import ModelPackage
4648

4749
logger = logging.getLogger(__name__)
@@ -69,6 +71,22 @@ def _resolve_ort_genai_model_type(model_type: str) -> str:
6971
return _ORT_GENAI_MODEL_TYPE.get(model_type, model_type)
7072

7173

74+
def _graph_input_names(model: ir.Model) -> list[str]:
75+
"""Return non-KV-cache input names from an ONNX model graph.
76+
77+
Filters out KV cache inputs (``past_key_values.*`` and ``past_*``)
78+
since those are represented as template patterns in genai_config.json,
79+
not as literal graph input names.
80+
"""
81+
return [
82+
inp.name
83+
for inp in model.graph.inputs
84+
if inp.name is not None
85+
and not inp.name.startswith("past_key_values.")
86+
and not inp.name.startswith("past_")
87+
]
88+
89+
7290
def _copy_tokenizer_files(
7391
model_id: str,
7492
output_dir: str,
@@ -152,10 +170,32 @@ def _write_processor_config(
152170
if vision is None:
153171
return None
154172

155-
processor: dict[str, Any] = {
156-
"image_size": getattr(vision, "image_size", 448),
157-
"patch_size": getattr(vision, "patch_size", 14),
158-
}
173+
model_type = getattr(config, "model_type", "")
174+
175+
if model_type in ("gemma4", "gemma4_text"):
176+
# Gemma4 needs a processor wrapper with model-specific fields
177+
tokens_per_image = (
178+
getattr(vision, "mm_tokens_per_image", None)
179+
or getattr(config, "mm_tokens_per_image", None)
180+
or getattr(vision, "max_soft_tokens", None)
181+
or 280
182+
)
183+
image_size = getattr(vision, "image_size", None) or 448
184+
patch_size = getattr(vision, "patch_size", None) or 16
185+
processor: dict[str, Any] = {
186+
"processor": {
187+
"name": "gemma4_image_processor",
188+
"image_size": image_size,
189+
"patch_size": patch_size,
190+
"tokens_per_image": tokens_per_image,
191+
}
192+
}
193+
else:
194+
processor = {
195+
"image_size": getattr(vision, "image_size", None) or 448,
196+
"patch_size": getattr(vision, "patch_size", None) or 14,
197+
}
198+
159199
path = os.path.join(output_dir, "processor_config.json")
160200
with open(path, "w", encoding="utf-8") as f:
161201
json.dump(processor, f, indent=4)
@@ -166,6 +206,7 @@ def _write_genai_config(
166206
config: Any,
167207
output_dir: str,
168208
*,
209+
pkg: ModelPackage,
169210
ort_model_type: str,
170211
ep: str,
171212
context_length: int,
@@ -177,10 +218,25 @@ def _write_genai_config(
177218
) -> str:
178219
"""Generate and write genai_config.json.
179220
221+
Input names for each sub-model (decoder, vision, embedding) are
222+
introspected from the ONNX graphs in *pkg* rather than hard-coded
223+
per model type.
224+
180225
Returns the path to the written file.
181226
"""
182227
from mobius.integrations.ort_genai.genai_config import GenaiConfigGenerator
183228

229+
# --- Discover decoder inputs from the ONNX graph ---
230+
decoder_model = pkg.get("decoder") or pkg.get("model")
231+
if decoder_model is not None:
232+
decoder_input_names = _graph_input_names(decoder_model)
233+
decoder_inputs: dict[str, str] | None = {name: name for name in decoder_input_names}
234+
# KV cache entries are template-based, not per-input
235+
decoder_inputs["past_key_names"] = "past_key_values.%d.key"
236+
decoder_inputs["past_value_names"] = "past_key_values.%d.value"
237+
else:
238+
decoder_inputs = None # fall back to defaults
239+
184240
generator = GenaiConfigGenerator.from_config(
185241
config,
186242
ort_model_type,
@@ -189,20 +245,44 @@ def _write_genai_config(
189245
bos_token_id=bos_token_id,
190246
eos_token_id=eos_token_id,
191247
pad_token_id=pad_token_id,
248+
decoder_inputs=decoder_inputs,
192249
)
193250

194251
if is_vlm:
195252
image_token_id = getattr(config, "image_token_id", None)
196253
if image_token_id is not None:
254+
# Discover vision inputs from the graph
255+
vision_model = pkg.get("vision")
256+
if vision_model is not None:
257+
names = _graph_input_names(vision_model)
258+
vision_input_mapping: dict[str, str] | None = {n: n for n in names}
259+
else:
260+
vision_input_mapping = None
261+
262+
# Discover embedding inputs from the graph
263+
embedding_model = pkg.get("embedding")
264+
if embedding_model is not None:
265+
names = _graph_input_names(embedding_model)
266+
embedding_input_mapping: dict[str, str] | None = {n: n for n in names}
267+
else:
268+
embedding_input_mapping = None
269+
270+
# spatial_merge_size and config_filename are config-level
271+
# properties that cannot be inferred from the graph.
197272
vision_kwargs: dict[str, Any] = {}
273+
model_type = getattr(config, "model_type", "")
198274
if has_speech:
199-
# Phi4MM uses different vision inputs than Qwen2.5-VL
200275
vision_kwargs["spatial_merge_size"] = None
201276
vision_kwargs["config_filename"] = "vision_processor.json"
202-
vision_kwargs["input_names"] = {
203-
"pixel_values": "pixel_values",
204-
"image_sizes": "image_sizes",
205-
}
277+
elif model_type in ("gemma4", "gemma4_text"):
278+
vision_kwargs["spatial_merge_size"] = None
279+
vision_kwargs["config_filename"] = "processor_config.json"
280+
281+
if vision_input_mapping is not None:
282+
vision_kwargs["input_names"] = vision_input_mapping
283+
if embedding_input_mapping is not None:
284+
vision_kwargs["embedding_input_names"] = embedding_input_mapping
285+
206286
generator.with_vision(image_token_id=image_token_id, **vision_kwargs)
207287

208288
if has_speech:
@@ -333,6 +413,7 @@ def write_ort_genai_config(
333413
genai_path = _write_genai_config(
334414
config,
335415
directory,
416+
pkg=pkg,
336417
ort_model_type=ort_model_type,
337418
ep=ep,
338419
context_length=context_length,

0 commit comments

Comments
 (0)