Skip to content

Commit fe6d687

Browse files
justinchubyCopilot
andcommitted
Fix gemma4 audio-encoder audio_features export rank (#570)
The gemma4 audio dataflow edge `audio_encoder.audio_features -> embedding.audio_features` was rank-3 on the producer side but rank-2 on the consumer side, so onnx-genai pipeline admission rejected every gemma4 audio (E2B/E4B) package: 'audio_encoder.audio_features -> embedding.audio_features' has incompatible ranks: producer rank 3, consumer rank 2. HF selects `audio_features[audio_mask]` (a boolean-mask gather) that both drops padding frames and flattens the batch axis to rank-2 `[num_valid_frames, text_hidden_size]` before scattering rows into the language-model embeddings. Move that mask-selection into `_Gemma4AudioEncoderModel.forward`: after the projector, flatten `[B, T//4, H] -> [-1, H]` and reuse the shared bf16-safe `_dtype_safe_compress` helper to emit rank-2 `[num_valid_frames, H]`, matching the `embedding` consumer and the encoder-free `_Gemma4UnifiedAudioEmbedderModel`. The task builder `_build_audio` no longer duplicates the flatten+Compress. This covers all gemma4 Conformer-audio variants since they share the module. Add `TestGemma4AudioEncoderRank` locking the rank-2 contract: producer rank == consumer rank == 2 (trailing dim == hidden_size) and a Compress node is present so the reduction is a real mask-select, not a bare reshape. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8a565d7 commit fe6d687

3 files changed

Lines changed: 111 additions & 15 deletions

File tree

src/mobius/models/gemma4.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2639,9 +2639,19 @@ class _Gemma4AudioEncoderModel(nn.Module):
26392639
26402640
Inputs:
26412641
- ``input_features [B, T, input_size]``: mel-spectrogram features
2642+
- ``input_features_mask [B, T]``: BOOL mask, ``True`` for valid frames
26422643
26432644
Output:
2644-
- ``audio_features [B, T//4, text_hidden_size]``: projected audio tokens
2645+
- ``audio_features [num_valid_frames, text_hidden_size]``: projected audio
2646+
tokens with padding frames stripped so output rows align 1:1 with audio
2647+
placeholder tokens. This mirrors HF, which selects
2648+
``audio_features[audio_mask]`` (a rank-2 gather) before scattering the
2649+
rows into the language-model embeddings. Emitting rank-2 here keeps the
2650+
producer rank aligned with the ``embedding`` sub-model's rank-2
2651+
``audio_features`` consumer.
2652+
- ``downsampled_mask [B, T//4]``: BOOL mask over the subsampled time axis,
2653+
returned for the ``audio_features_mask`` diagnostic output. ``None`` when
2654+
no ``input_features_mask`` was supplied.
26452655
"""
26462656

26472657
def __init__(self, config: Gemma4Config):
@@ -2701,7 +2711,23 @@ def forward(
27012711
rms = op.Sqrt(op.Add(mean_sq, eps))
27022712
audio_features = op.CastLike(op.Div(x_f32, rms), audio_features)
27032713
# → projector → [B, T//4, text_hidden_size]
2704-
return self.projector(op, audio_features), downsampled_mask
2714+
audio_features = self.projector(op, audio_features)
2715+
if downsampled_mask is None:
2716+
# No validity mask (e.g. eager unit call): emit the rank-3 tensor
2717+
# unchanged. Export always supplies a mask, so the rank-2 path below
2718+
# is the one that reaches the ``embedding`` consumer.
2719+
return audio_features, None
2720+
# Select valid frames so the output rows align 1:1 with audio placeholder
2721+
# tokens, matching HF's ``audio_features[audio_mask]``. Flatten the batch
2722+
# and subsampled-time axes, then compress by the downsampled mask to get
2723+
# rank-2 ``[num_valid_frames, text_hidden_size]`` — the rank the
2724+
# ``embedding`` sub-model consumes.
2725+
flat_features = op.Reshape(
2726+
audio_features, op.Constant(value_ints=[-1, self.config.hidden_size])
2727+
)
2728+
flat_mask = op.Reshape(downsampled_mask, op.Constant(value_ints=[-1]))
2729+
selected = _dtype_safe_compress(op, flat_features, flat_mask, axis=0)
2730+
return selected, downsampled_mask
27052731

27062732

27072733
# ---------------------------------------------------------------------------

src/mobius/models/gemma4_test.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,86 @@ def test_no_block_sequence_ids_when_causal(self):
284284
assert "input_ids" not in dec_inputs
285285

286286

287+
def _tiny_gemma4_audio_config(**overrides) -> Gemma4Config:
288+
"""Minimal gemma4 config with a Conformer audio encoder (E2B-style)."""
289+
from mobius._configs import Gemma4AudioConfig
290+
291+
base = dict(
292+
image_token_id=255,
293+
audio=Gemma4AudioConfig(
294+
input_size=32,
295+
hidden_size=16,
296+
num_layers=1,
297+
subsampling_conv_channels=[8, 8],
298+
output_proj_dims=16,
299+
audio_token_id=254,
300+
),
301+
)
302+
base.update(overrides)
303+
return _tiny_gemma4_config(**base)
304+
305+
306+
class TestGemma4AudioEncoderRank:
307+
"""Lock the audio dataflow-edge rank contract (regression for #570).
308+
309+
The exported ``audio_encoder`` must emit ``audio_features`` at the SAME rank
310+
the ``embedding`` sub-model consumes it (rank-2
311+
``[num_valid_frames, text_hidden_size]``), mirroring HF's
312+
``audio_features[audio_mask]`` selection. A rank-3 producer against a
313+
rank-2 consumer is rejected at onnx-genai pipeline admission.
314+
"""
315+
316+
def test_audio_features_producer_consumer_ranks_match(self):
317+
from mobius.models.gemma4 import Gemma4Model
318+
from mobius.tasks._gemma4 import Gemma4Task
319+
320+
config = _tiny_gemma4_audio_config()
321+
pkg = Gemma4Task().build(Gemma4Model(config), config)
322+
323+
audio_out = {o.name: o for o in pkg["audio_encoder"].graph.outputs}
324+
emb_in = {i.name: i for i in pkg["embedding"].graph.inputs}
325+
326+
producer = audio_out["audio_features"]
327+
consumer = emb_in["audio_features"]
328+
assert producer.shape is not None
329+
assert consumer.shape is not None
330+
producer_rank = len(producer.shape)
331+
consumer_rank = len(consumer.shape)
332+
333+
# Non-vacuous: assert the concrete rank-2 contract on BOTH ends and that
334+
# they agree, not merely that export ran.
335+
assert producer_rank == 2, (
336+
f"audio_encoder audio_features must be rank-2 "
337+
f"[num_valid_frames, hidden]; got rank {producer_rank} {producer.shape}"
338+
)
339+
assert consumer_rank == 2, (
340+
f"embedding audio_features input must be rank-2; "
341+
f"got rank {consumer_rank} {consumer.shape}"
342+
)
343+
assert producer_rank == consumer_rank
344+
# Trailing dim must be the text hidden size on both ends.
345+
assert producer.shape[1] == config.hidden_size
346+
assert consumer.shape[1] == config.hidden_size
347+
348+
def test_audio_encoder_selects_valid_frames_with_compress(self):
349+
"""The rank reduction is a real mask-select (HF ``audio_features[audio_mask]``).
350+
351+
Guards against a "fix" that reshapes B*T//4 into rank-2 without dropping
352+
padding frames, which would misalign rows with placeholder tokens.
353+
"""
354+
from mobius.models.gemma4 import Gemma4Model
355+
from mobius.tasks._gemma4 import Gemma4Task
356+
357+
config = _tiny_gemma4_audio_config()
358+
pkg = Gemma4Task().build(Gemma4Model(config), config)
359+
360+
audio_graph = pkg["audio_encoder"].graph
361+
assert any(n.op_type == "Compress" for n in audio_graph), (
362+
"audio_encoder must Compress by the downsampled validity mask so "
363+
"output rows align 1:1 with audio placeholder tokens"
364+
)
365+
366+
287367
def _tiny_gemma4_unified_config(**overrides) -> Gemma4Config:
288368
"""Minimal gemma4_unified config (dense decoder + encoder-free embedders)."""
289369
from mobius._configs import Gemma4AudioConfig, VisionConfig

src/mobius/tasks/_gemma4.py

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -666,19 +666,9 @@ def _build_audio(
666666
)
667667
if downsampled_mask is None:
668668
raise ValueError("Gemma4 audio encoder must return a downsampled validity mask")
669-
flattened_features = op.Reshape(
670-
audio_features,
671-
op.Constant(value_ints=[-1, config.hidden_size]),
672-
)
673-
flattened_mask = op.Reshape(
674-
downsampled_mask,
675-
op.Constant(value_ints=[-1]),
676-
)
677-
flattened_features_f32 = op.Cast(flattened_features, to=ir.DataType.FLOAT)
678-
audio_features = op.CastLike(
679-
op.Compress(flattened_features_f32, flattened_mask, axis=0),
680-
flattened_features,
681-
)
669+
# The audio encoder module already strips padding frames and flattens the
670+
# batch axis, so ``audio_features`` is rank-2 ``[num_valid, hidden_size]``
671+
# here — the rank the ``embedding`` sub-model consumes.
682672
builder.add_output(audio_features, "audio_features")
683673
builder.add_output(downsampled_mask, "audio_features_mask")
684674

0 commit comments

Comments
 (0)