Skip to content

Commit f027a8e

Browse files
justinchubyCopilot
andauthored
Cast vision/audio inputs f32 to model dtype for GenAI compatibility (#265)
## Summary Keep vision/audio encoder graph inputs as float32 (matching GenAI image/audio processor output) and add a Cast(f32→model_dtype) at the graph entry. Encoder weights use the requested f16/bf16 dtype for memory efficiency. ## Problem GenAI multimodal processor outputs float32 pixel values and audio features. When building with `--dtype f16` or `--dtype bf16`, the vision/audio encoder graph inputs were also cast to f16/bf16, creating a type mismatch: GenAI sends f32, encoder expects f16, ORT produces all-zero output (silent failure). ## Solution: Cast at graph entry Instead of keeping entire vision/audio encoders at f32 (wasteful), add a `Cast` op at the start of each encoder graph: ``` pixel_values (f32, from GenAI) → Cast(f32→f16) → vision encoder (f16 weights) → image_features ``` This gives both **GenAI compatibility** (f32 input) and **memory efficiency** (f16/bf16 weights). ## Changes - `_gemma4.py`: Vision/audio encoder inputs always `ir.DataType.FLOAT`, with `op.Cast` to `config.dtype` when dtype ≠ f32 - `_builder.py`: No changes needed — all parameters cast to requested dtype as before ## Testing 13 gemma4 tests pass. Verified f16 build: vision input=f32, first node=Cast, weights=f16. --------- Signed-off-by: Justin Chu <justinchu@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e2631ab commit f027a8e

7 files changed

Lines changed: 171 additions & 16 deletions

File tree

src/mobius/_builder.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,9 @@ def forward(self, op, input_ids, attention_mask,
192192
if hasattr(config, "validate"):
193193
config.validate()
194194
dtype = getattr(config, "dtype", ir.DataType.FLOAT)
195+
# Cast all parameters to the target dtype. Vision/audio encoder weights
196+
# are included — their graph inputs are kept at f32 (matching GenAI's
197+
# image processor output) with a Cast at the graph entry.
195198
_cast_module_dtype(module, dtype)
196199
resolved_task = get_task(task)
197200
capabilities = ep_registry.require(execution_provider)

src/mobius/tasks/_fun_asr_speech_language.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,19 @@ def _build_audio_encoder(
9191
input_dim = (config.audio.input_size if config.audio else None) or 560
9292

9393
graph, builder = _make_graph(name="audio_encoder")
94+
op = builder.op
9495

96+
# Audio encoder input is always f32 (matching audio processor output).
97+
# Cast at graph entry for f16/bf16 builds.
9598
input_features = builder.input(
9699
"input_features",
97-
dtype=config.dtype,
100+
dtype=ir.DataType.FLOAT,
98101
shape=[batch, seq_len, input_dim],
99102
)
103+
if config.dtype and config.dtype != ir.DataType.FLOAT:
104+
input_features = op.Cast(input_features, to=config.dtype)
100105

101-
audio_features = audio_encoder(builder.op, input_features)
106+
audio_features = audio_encoder(op, input_features)
102107

103108
builder.add_output(audio_features, "audio_features")
104109
return _make_model(graph)

src/mobius/tasks/_gemma4.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,11 +298,16 @@ def _build_vision(
298298
graph, builder = _make_graph(name="vision_encoder")
299299
op = builder.op
300300

301+
# Vision encoder input is always f32 (matching GenAI's image processor
302+
# output). When the model uses f16/bf16, add a Cast at the graph entry
303+
# so weights can stay at the requested dtype for memory efficiency.
301304
pixel_values = builder.input(
302305
"pixel_values",
303-
dtype=config.dtype,
306+
dtype=ir.DataType.FLOAT,
304307
shape=[batch, num_patches, pixel_dim],
305308
)
309+
if config.dtype and config.dtype != ir.DataType.FLOAT:
310+
pixel_values = op.Cast(pixel_values, to=config.dtype)
306311
pixel_position_ids = builder.input(
307312
"pixel_position_ids",
308313
dtype=ir.DataType.INT64,
@@ -352,9 +357,11 @@ def _build_audio(
352357

353358
input_features = builder.input(
354359
"input_features",
355-
dtype=config.dtype,
360+
dtype=ir.DataType.FLOAT, # Always f32 (matching audio processor output)
356361
shape=[batch, time, input_size],
357362
)
363+
if config.dtype and config.dtype != ir.DataType.FLOAT:
364+
input_features = op.Cast(input_features, to=config.dtype)
358365
input_features_mask = builder.input(
359366
"input_features_mask",
360367
dtype=ir.DataType.BOOL,

src/mobius/tasks/_phi4mm_multimodal.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,19 +85,24 @@ def _build_vision(
8585
image_size = (config.vision.image_size if config.vision else None) or 448
8686

8787
graph, builder = _make_graph(name="vision_encoder")
88+
op = builder.op
8889

90+
# Vision encoder input is always f32 (matching GenAI's image processor
91+
# output). Cast at graph entry for f16/bf16 builds.
8992
pixel_values = builder.input(
9093
"pixel_values",
91-
dtype=config.dtype,
94+
dtype=ir.DataType.FLOAT,
9295
shape=[batch, 3, image_size, image_size],
9396
)
97+
if config.dtype and config.dtype != ir.DataType.FLOAT:
98+
pixel_values = op.Cast(pixel_values, to=config.dtype)
9499
image_sizes = builder.input(
95100
"image_sizes",
96101
dtype=ir.DataType.INT64,
97102
shape=[num_images, 2],
98103
)
99104

100-
image_features = vision(builder.op, pixel_values, image_sizes=image_sizes)
105+
image_features = vision(op, pixel_values, image_sizes=image_sizes)
101106

102107
builder.add_output(image_features, "image_features")
103108
return _make_model(graph)
@@ -118,12 +123,17 @@ def _build_speech(
118123
input_size = (config.audio.input_size if config.audio else None) or 80
119124

120125
graph, builder = _make_graph(name="audio_encoder")
126+
op = builder.op
121127

128+
# Audio encoder input is always f32 (matching audio processor output).
129+
# Cast at graph entry for f16/bf16 builds.
122130
audio_embeds = builder.input(
123131
"audio_embeds",
124-
dtype=config.dtype,
132+
dtype=ir.DataType.FLOAT,
125133
shape=[batch, audio_seq_len, input_size],
126134
)
135+
if config.dtype and config.dtype != ir.DataType.FLOAT:
136+
audio_embeds = op.Cast(audio_embeds, to=config.dtype)
127137
audio_sizes = builder.input(
128138
"audio_sizes",
129139
dtype=ir.DataType.INT64,
@@ -136,7 +146,7 @@ def _build_speech(
136146
)
137147

138148
speech_out = speech(
139-
builder.op,
149+
op,
140150
audio_embeds,
141151
audio_sizes=audio_sizes,
142152
audio_projection_mode=audio_projection_mode,

src/mobius/tasks/_speech_language.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,19 @@ def _build_audio_encoder(
8383
n_mels = (config.audio.num_mel_bins if config.audio else None) or 128
8484

8585
graph, builder = _make_graph(name="audio_encoder")
86+
op = builder.op
8687

88+
# Audio encoder input is always f32 (matching audio processor output).
89+
# Cast at graph entry for f16/bf16 builds.
8790
input_features = builder.input(
8891
"input_features",
89-
dtype=config.dtype,
92+
dtype=ir.DataType.FLOAT,
9093
shape=[batch, n_mels, mel_seq],
9194
)
95+
if config.dtype and config.dtype != ir.DataType.FLOAT:
96+
input_features = op.Cast(input_features, to=config.dtype)
9297

93-
audio_features = audio_encoder(builder.op, input_features)
98+
audio_features = audio_encoder(op, input_features)
9499

95100
builder.add_output(audio_features, "audio_features")
96101
return _make_model(graph)

src/mobius/tasks/_vision_language_3model.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,17 @@ def _build_vision(
8383
image_size = (config.vision.image_size if config.vision else None) or 224
8484

8585
graph, builder = _make_graph(name="vision_encoder")
86+
op = builder.op
87+
# Vision encoder input is always f32 (matching GenAI's image processor
88+
# output). Cast at graph entry for f16/bf16 builds.
8689
pixel_values = builder.input(
8790
"pixel_values",
88-
dtype=config.dtype,
91+
dtype=ir.DataType.FLOAT,
8992
shape=[batch, 3, image_size, image_size],
9093
)
91-
image_features = vision(builder.op, pixel_values=pixel_values)
94+
if config.dtype and config.dtype != ir.DataType.FLOAT:
95+
pixel_values = op.Cast(pixel_values, to=config.dtype)
96+
image_features = vision(op, pixel_values=pixel_values)
9297

9398
builder.add_output(image_features, "image_features")
9499
return _make_model(graph)
@@ -134,19 +139,24 @@ def _build_vision(
134139
pixel_dim = in_channels * temporal_patch_size * patch_size * patch_size
135140

136141
graph, builder = _make_graph(name="vision_encoder")
142+
op = builder.op
143+
# Vision encoder input is always f32 (matching GenAI's image processor
144+
# output). Cast at graph entry for f16/bf16 builds.
137145
pixel_values = builder.input(
138146
"pixel_values",
139-
dtype=config.dtype,
147+
dtype=ir.DataType.FLOAT,
140148
shape=[total_patches, pixel_dim],
141149
)
150+
if config.dtype and config.dtype != ir.DataType.FLOAT:
151+
pixel_values = op.Cast(pixel_values, to=config.dtype)
142152
image_grid_thw = builder.input(
143153
"image_grid_thw",
144154
dtype=ir.DataType.INT64,
145155
shape=[num_images, 3],
146156
)
147157

148158
image_features = vision(
149-
builder.op,
159+
op,
150160
pixel_values=pixel_values,
151161
image_grid_thw=image_grid_thw,
152162
)
@@ -209,12 +219,16 @@ def _build_vision(
209219
width = ir.SymbolicDim("width")
210220

211221
graph, builder = _make_graph(name="vision_encoder")
222+
op = builder.op
223+
# Vision encoder input is always f32 (matching GenAI's image processor
224+
# output). Cast at graph entry for f16/bf16 builds.
212225
pixel_values = builder.input(
213226
"pixel_values",
214-
dtype=config.dtype,
227+
dtype=ir.DataType.FLOAT,
215228
shape=[batch, 3, height, width],
216229
)
217-
op = builder.op
230+
if config.dtype and config.dtype != ir.DataType.FLOAT:
231+
pixel_values = op.Cast(pixel_values, to=config.dtype)
218232

219233
image_features = vision(
220234
op,

tests/build_graph_test.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1857,6 +1857,117 @@ def test_dtype_casts_float_initializers(self, dtype_str, expected):
18571857
f"Initializer '{name}' dtype is {init.dtype}, expected {expected_dtype}"
18581858
)
18591859

1860+
@pytest.mark.parametrize(
1861+
"dtype_str",
1862+
["f16", "bf16"],
1863+
)
1864+
def test_multimodal_encoder_inputs_are_float32(self, dtype_str):
1865+
"""Vision/audio encoder graph inputs stay f32 with Cast at entry.
1866+
1867+
When building multimodal models with f16/bf16, encoder graph inputs
1868+
(pixel_values, input_features) must remain FLOAT because ORT GenAI's
1869+
image/audio processors output f32. A Cast node at graph entry converts
1870+
to the target dtype for the encoder's internal computation.
1871+
"""
1872+
# Use a VL model with 3-model split (vision_encoder is separate)
1873+
config = _base_config(
1874+
vision=VisionConfig(
1875+
hidden_size=32,
1876+
intermediate_size=64,
1877+
num_hidden_layers=1,
1878+
num_attention_heads=2,
1879+
image_size=28,
1880+
patch_size=14,
1881+
norm_eps=1e-6,
1882+
),
1883+
image_token_id=32000,
1884+
)
1885+
config.dtype = DTYPE_MAP[dtype_str]
1886+
model_cls = registry.get("llava")
1887+
module = model_cls(config)
1888+
task = get_task("vision-language")
1889+
pkg = task.build(module, config)
1890+
1891+
# Vision encoder pixel_values input must be FLOAT
1892+
vision_model = pkg["vision_encoder"]
1893+
pixel_values_input = vision_model.graph.inputs[0]
1894+
assert pixel_values_input.name == "pixel_values"
1895+
assert pixel_values_input.dtype == ir.DataType.FLOAT, (
1896+
f"Vision encoder input dtype is {pixel_values_input.dtype}, "
1897+
f"expected FLOAT (Cast should handle conversion to {dtype_str})"
1898+
)
1899+
1900+
# First non-input node should be Cast to target dtype
1901+
first_node = next(iter(vision_model.graph))
1902+
assert first_node.op_type == "Cast", (
1903+
f"Expected Cast as first node, got {first_node.op_type}"
1904+
)
1905+
1906+
@pytest.mark.parametrize(
1907+
"dtype_str",
1908+
["f16", "bf16"],
1909+
)
1910+
def test_gemma4_encoder_inputs_are_float32(self, dtype_str):
1911+
"""Gemma4 vision and audio encoder inputs stay f32 in bf16/f16 builds."""
1912+
from mobius._configs import Gemma4AudioConfig, Gemma4Config
1913+
1914+
config = Gemma4Config(
1915+
num_hidden_layers=2,
1916+
hidden_size=64,
1917+
intermediate_size=128,
1918+
num_attention_heads=4,
1919+
num_key_value_heads=1,
1920+
head_dim=16,
1921+
vocab_size=256,
1922+
rms_norm_eps=1e-6,
1923+
hidden_act="silu",
1924+
attn_qk_norm=True,
1925+
layer_types=["sliding_attention", "sliding_attention"],
1926+
sliding_window=8,
1927+
global_head_dim=16,
1928+
global_rope_theta=10_000.0,
1929+
global_partial_rotary_factor=0.25,
1930+
final_logit_softcapping=0.0,
1931+
hidden_size_per_layer_input=0,
1932+
image_token_id=255999,
1933+
pad_token_id=0,
1934+
tie_word_embeddings=True,
1935+
num_kv_shared_layers=1,
1936+
vision=VisionConfig(
1937+
hidden_size=32,
1938+
intermediate_size=64,
1939+
num_hidden_layers=1,
1940+
num_attention_heads=2,
1941+
patch_size=16,
1942+
norm_eps=1e-6,
1943+
),
1944+
audio=Gemma4AudioConfig(
1945+
input_size=16,
1946+
hidden_size=32,
1947+
num_layers=1,
1948+
output_dim=64,
1949+
output_proj_dims=64,
1950+
audio_token_id=255998,
1951+
),
1952+
dtype=DTYPE_MAP[dtype_str],
1953+
)
1954+
model_cls = registry.get("gemma4")
1955+
module = model_cls(config)
1956+
task = get_task("gemma4")
1957+
pkg = task.build(module, config)
1958+
1959+
# Vision encoder pixel_values must be FLOAT
1960+
vision_model = pkg["vision_encoder"]
1961+
pv_input = vision_model.graph.inputs[0]
1962+
assert pv_input.name == "pixel_values"
1963+
assert pv_input.dtype == ir.DataType.FLOAT
1964+
1965+
# Audio encoder input_features must be FLOAT
1966+
audio_model = pkg["audio_encoder"]
1967+
af_input = audio_model.graph.inputs[0]
1968+
assert af_input.name == "input_features"
1969+
assert af_input.dtype == ir.DataType.FLOAT
1970+
18601971

18611972
class TestBuildGraphMultiModal:
18621973
"""Verify Phi4MM builds with Phi4MMMultiModalTask (4-model split)."""

0 commit comments

Comments
 (0)