[openvino] Add audio embeddings export support for gemma4_unified#1813
[openvino] Add audio embeddings export support for gemma4_unified#1813lcretan wants to merge 5 commits into
Conversation
Gemma4UnifiedForConditionalGeneration has an encoder-free audio embedder (embed_audio = RMSNorm + Linear(640→3840)) that was explicitly excluded from OpenVINO export with the comment "audio is not exported". This commit adds full audio export and inference support. Changes: - Add DummyGemma4UnifiedAudioInputGenerator for tracing input_features - Add Gemma4UnifiedAudioEmbeddingsModelPatcher to wrap embed_audio.forward() - Update Gemma4UnifiedOpenVINOConfig with audio_embeddings behavior, inputs/outputs shape annotations, and with_behavior() routing - Add audio inference support to _OVGemma4UnifiedForCausalLM: additional_parts, get_audio_embeddings(), merge_audio_text_embeddings() - Maintain backward compatibility: models without openvino_audio_embeddings_model.xml load normally via from_pretrained() fallback Fixes huggingface#1764
There was a problem hiding this comment.
Pull request overview
Adds OpenVINO export + runtime support for Gemma 4 Unified audio embeddings by introducing an audio dummy input generator, an audio export model patcher, wiring a new "audio_embeddings" export behavior into Gemma4UnifiedOpenVINOConfig, and extending the runtime VLM wrapper to accept audio inputs and merge audio soft tokens into the text embedding stream.
Changes:
- Add an OpenVINO export path for Gemma4 Unified audio embeddings (
embed_audio) including dummy tracing inputs and a dedicated model patcher. - Extend Gemma4 Unified OpenVINO config behaviors to include
"audio_embeddings"and produce anopenvino_audio_embeddings_model.*artifact. - Add runtime-side audio preprocessing/forward plumbing and embedding scatter logic in
_OVGemma4UnifiedForCausalLM.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
optimum/intel/openvino/modeling_visual_language.py |
Adds optional audio submodel loading, audio embedding inference, and audio token scatter/merge support for gemma4_unified. |
optimum/exporters/openvino/model_patcher.py |
Introduces a patcher to export embed_audio with a trace-friendly forward signature. |
optimum/exporters/openvino/model_configs.py |
Adds "audio_embeddings" behavior for gemma4_unified export and configures dummy inputs / patching / IO shapes. |
optimum/exporters/openvino/input_generators.py |
Adds a dummy input generator producing [batch, n_frames, 640] audio frames for tracing. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| audio_path = None | ||
| if isinstance(model_id, (str, os.PathLike)): | ||
| audio_path = os.path.join(str(model_id), "openvino_audio_embeddings_model.xml") | ||
|
|
||
| if audio_path is not None and not os.path.exists(audio_path): | ||
| # Backwards compat: load without audio_embeddings |
There was a problem hiding this comment.
Fixed in c3718cb — from_pretrained now checks os.path.isdir(model_id) before treating the path as local, so Hub IDs like google/gemma-4-12B-it fall through to the parent's hf_hub_download optional-key handling instead of being incorrectly stripped of audio. Covered by TestFromPretrainedBackwardCompat::test_hub_id_passes_through_unchanged.
| def prepare_inputs_and_embeddings( | ||
| self, | ||
| input_ids, | ||
| pixel_values=None, | ||
| attention_mask=None, | ||
| position_ids=None, | ||
| **kwargs, | ||
| ): | ||
| """Extend parent to also scatter audio tokens when input_features is provided.""" | ||
| # Parent handles vision | ||
| inputs_embeds, attention_mask, position_ids = super().prepare_inputs_and_embeddings( | ||
| input_ids, | ||
| pixel_values=pixel_values, | ||
| attention_mask=attention_mask, | ||
| position_ids=position_ids, | ||
| **kwargs, | ||
| ) |
There was a problem hiding this comment.
Addressed in 601a348 — replaced prepare_inputs_and_embeddings() with an override of get_multimodal_embeddings() that calls super().get_multimodal_embeddings() then scatters audio, so the audio path is now in the base call chain and input_features is no longer ignored at inference.
| special_audio_mask = (input_ids == audio_token_id).unsqueeze(-1).expand_as(inputs_embeds) | ||
| audio_embeds = audio_embeds.to(inputs_embeds.dtype) | ||
| inputs_embeds = inputs_embeds.masked_scatter(special_audio_mask, audio_embeds) |
There was a problem hiding this comment.
Addressed in 601a348 — added an explicit count check before masked_scatter(): it compares <audio> token positions against audio_embeds.shape[0] and raises a clear ValueError on mismatch. Covered by TestMergeAudioTextEmbeddings::test_shape_mismatch_raises.
| input_features_mask: Optional[torch.Tensor] = None, | ||
| input_ids: Optional[torch.Tensor] = None, | ||
| ) -> Optional[torch.Tensor]: | ||
| """Run the audio embedder and return [batch, n_valid_frames, hidden_size]. |
There was a problem hiding this comment.
Fixed in 601a348 — the docstring now states the actual return shape [n_valid_frames_total, hidden_size] (flattened, padding removed via input_features_mask).
| if input_name == "input_features_mask": | ||
| # All frames valid (True) for the dummy trace. | ||
| import torch | ||
| return torch.ones( | ||
| (self.batch_size, self.num_audio_frames), dtype=torch.bool | ||
| ) |
There was a problem hiding this comment.
Fixed in 601a348 — input_features_mask now uses constant_tensor() so the dummy input honors the framework argument like the other generators.
| _AUDIO_BEHAVIOR = _Gemma4UnifiedAudioBehavior.AUDIO_EMBEDDINGS.value | ||
| SUPPORTED_BEHAVIORS = [model_type.value for model_type in VLMConfigBehavior] + [_AUDIO_BEHAVIOR] | ||
| DUMMY_INPUT_GENERATOR_CLASSES = (DummyVisionInputGenerator, DummyTextInputGenerator) |
…gnostic mask - Replace prepare_inputs_and_embeddings() with get_multimodal_embeddings() so audio tokens are actually merged at inference (was never called before) - Make additional_parts loading optional in both local-dir and HF Hub paths; wrap hf_hub_download for optional files in try/except so Hub models without audio export still load cleanly - Fix _component_names / _ov_model_names to use getattr(...) is not None instead of hasattr(), preventing compile() crash on None components - Use constant_tensor() for input_features_mask dummy input (framework-agnostic) - Add explicit audio token count check before masked_scatter() for clearer errors - Fix get_audio_embeddings() docstring to match actual return shape Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The audio_embeddings sub-model (embed_audio: RMSNorm + Linear 640→3840) produces 1 INT8-quantized weight node. Without this entry check_compression_ state_per_model fails on len(models) != len(expected) when the model is exported with audio support enabled. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…m_pretrained
- Add tests/openvino/test_gemma4_audio.py with 14 tests covering:
* merge_audio_text_embeddings: scatter correctness, shape-mismatch guard,
numpy input conversion, attention_mask/position_ids passthrough
* get_audio_embeddings: decode-step skip, missing-model ValueError,
mask-based frame stripping, no-mask flatten
* from_pretrained backward-compat: local dir without audio XML, Hub ID passthrough
* Integration: audio model loaded, output shape, dtype, merge roundtrip
- Fix from_pretrained Hub-ID bug: use os.path.isdir() instead of os.path.exists()
so Hub model IDs (e.g. "google/gemma-4-12B-it") are not incorrectly treated as
local directories — audio_embeddings stripping now only applies to actual local dirs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Thanks for the review, @rkazants! Tests added —
|
| Class | Coverage |
|---|---|
TestMergeAudioTextEmbeddings |
scatter correctness, shape-mismatch guard, numpy→torch conversion, mask/position_ids passthrough |
TestGetAudioEmbeddings |
decode-step skip, ValueError when audio model absent, mask-based frame stripping, no-mask flatten |
TestFromPretrainedBackwardCompat |
local dir without audio XML skips gracefully; Hub ID passes through unchanged |
TestGemma4AudioIntegration |
audio model loaded, output shape, dtype, full merge roundtrip (requires local int8-audio model) |
Also fixed a Hub-ID bug in from_pretrained: previously os.path.exists("google/gemma-4-12B-it/openvino_audio_embeddings_model.xml") always returned False, causing Hub models to silently strip audio. Fixed by checking os.path.isdir() first — Hub IDs are not local dirs, so they fall through to the parent's hf_hub_download optional-key try/except.
Inference code snippet
Export with audio support:
optimum-cli export openvino \
--model google/gemma-4-12B-it \
--task image-text-to-text \
--weight-format int8 \
./gemma-4-12B-it-openvino-int8-audioThe exported directory will contain openvino_audio_embeddings_model.xml (~2.4 MB) alongside the existing vision and language model files.
Text-only inference (unchanged):
from optimum.intel import OVModelForVisualCausalLM
from transformers import AutoProcessor
model_dir = "./gemma-4-12B-it-openvino-int8-audio"
processor = AutoProcessor.from_pretrained(model_dir)
model = OVModelForVisualCausalLM.from_pretrained(model_dir, device="CPU")
messages = [{"role": "user", "content": "What is the capital of Japan?"}]
text = processor.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
inputs = processor(text=text, return_tensors="pt")
out = model.generate(**inputs, max_new_tokens=64)
print(processor.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True))
# → "The capital of Japan is Tokyo."Audio inference:
import numpy as np
import soundfile as sf # or librosa / scipy
# Load 16 kHz mono audio
audio, sr = sf.read("speech.wav") # shape: [n_samples]
assert sr == 16000, "Gemma4 audio feature extractor expects 16 kHz"
# Build a multimodal message — processor handles feature extraction internally
messages = [{"role": "user", "content": [
{"type": "audio", "audio": audio},
{"type": "text", "text": "Transcribe the audio above in Japanese."},
]}]
# apply_chat_template with audio data → produces input_features + input_features_mask
inputs = processor.apply_chat_template(
messages,
add_generation_prompt=True,
tokenize=True,
return_tensors="pt",
return_dict=True,
)
# inputs now contains: input_ids, attention_mask, input_features [B, T, 640], input_features_mask
out = model.generate(**inputs, max_new_tokens=256)
print(processor.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True))Backward compatibility (models without audio):
# Loading a model exported before audio support works transparently.
# The audio sub-model is simply absent and audio inputs raise a clear ValueError.
model = OVModelForVisualCausalLM.from_pretrained(
"./gemma-4-12B-it-openvino-int4", # no openvino_audio_embeddings_model.xml
device="CPU",
)
# model.audio_embeddings is None; text+vision still work normallyThere was a problem hiding this comment.
use existing test suite in test_seq2seq and test_export. No need to implement a new one
|
Thanks @rkazants — makes sense, I'll fold this into the existing suites rather than a separate file. Since One note for your call: a few cases in that file are fine-grained unit tests for the audio↔text embedding-merge helpers (scatter correctness, shape-mismatch guard, mask-based frame stripping) that don't map onto the export/inference model tests. My default is to drop the standalone units and keep equivalent coverage as assertions within the |
Per @rkazants review, fold gemma4_unified audio coverage into the existing parametrized suites instead of a separate file: - export + the openvino_audio_embeddings_model artifact + its INT8 quantization are already asserted via tests/openvino/utils_tests.py (gemma4_unified expectations) exercised by test_export.py / test_seq2seq.py. - the audio embeddings component is structurally validated by test_compare_to_transformers -> _check_openvino_model_attributes (every component, incl. audio, must load as a valid openvino.Model).
|
Pushed 5d59cbc — removed
So the audio embedder is covered structurally without a dedicated file. The fine-grained merge-helper unit tests are dropped as discussed; a dedicated audio-inference check would need audio preprocessing the image-text-to-text suite doesn't currently do — happy to add one if you'd like it. Thanks again for the steer. |
Summary
Add audio embeddings export support for
gemma4_unifiedmodels (e.g.,google/gemma-4-12B-it).Background
Gemma4UnifiedForConditionalGenerationhas an encoder-free audio embedder (embed_audio)consisting of
RMSNorm + Linear(640 → 3840)— identical in structure to the vision embedder.While
_OVGemma4UnifiedForCausalLMalready supports text and vision modalities, audio wasexplicitly excluded with the comment
"audio is not exported".This PR adds full audio export and inference support.
Changes
New:
DummyGemma4UnifiedAudioInputGenerator(input_generators.py)input_features: [batch, n_frames, 640]for tracingembed_audio— trace is straightforwardNew:
Gemma4UnifiedAudioEmbeddingsModelPatcher(model_patcher.py)model.model.embed_audio.forward()for OV exportopenvino_audio_embeddings_model.xml/binUpdated:
Gemma4UnifiedOpenVINOConfig(model_configs.py)SUPPORTED_BEHAVIORSnow includes"audio_embeddings"with_behavior("audio_embeddings")returns audio export configget_model_for_behavior()returns the audio sub-modulepatch_model_for_export()usesGemma4UnifiedAudioEmbeddingsModelPatcherinputs/outputsproperties handle audio shape annotationsUpdated:
_OVGemma4UnifiedForCausalLM(modeling_visual_language.py)additional_parts = ["audio_embeddings"](with backward-compatfrom_pretrained)get_audio_embeddings(input_features, input_features_mask)— calls OV audio model, strips paddingmerge_audio_text_embeddings()— scatters audio embeddings ataudio_token_idpositionsAudio Architecture
Backward Compatibility
openvino_audio_embeddings_model.xml) load normallyfrom_pretrained()detects audio model file and skips gracefully if absentTesting
References
Gemma4UnifiedAudioConfig,Gemma4UnifiedMultimodalEmbedderaudio_token_id=258881,boa_token_id=256000,eoa_token_index=258883