Skip to content

Commit 6898d97

Browse files
authored
[OpenVINO] Support Qwen3-Omni MoE with full Talker speech stack (#1700)
* Qwen 3 Omni MoE support * CI-related fixes * Test fixes * latest fixes * Fixes * Polishing * Remaining fixes * CI fix
1 parent 604a19a commit 6898d97

17 files changed

Lines changed: 3279 additions & 107 deletions

‎docs/source/openvino/models.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ Here is the list of the supported architectures :
139139
- Qwen3
140140
- Qwen3MoE
141141
- Qwen3-VL (including Qwen3-VL-Embedding)
142+
- Qwen3-Omni-MoE
142143
- Qwen3.5
143144
- Qwen3.5-MoE
144145
- Qwen3.6

‎optimum/exporters/openvino/__main__.py‎

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from optimum.exporters.tasks import TasksManager
3333
from optimum.intel.utils.import_utils import (
3434
DIFFUSERS_IMPORT_ERROR,
35+
_transformers_version,
3536
is_diffusers_available,
3637
is_nncf_available,
3738
is_openvino_tokenizers_available,
@@ -176,6 +177,38 @@ def infer_task(
176177
return task
177178

178179

180+
def _ensure_qwen3_omni_rope_scaling(config):
181+
"""Fill in a valid ``rope_scaling`` for Qwen3-Omni-MoE sub-configs that are missing it."""
182+
thinker_config = getattr(config, "thinker_config", None)
183+
talker_config = getattr(config, "talker_config", None)
184+
sub_configs = [
185+
getattr(thinker_config, "text_config", None),
186+
getattr(talker_config, "text_config", None),
187+
getattr(talker_config, "code_predictor_config", None),
188+
]
189+
190+
for sub_config in sub_configs:
191+
if sub_config is None or getattr(sub_config, "rope_scaling", None) is not None:
192+
continue
193+
194+
head_dim = getattr(sub_config, "head_dim", None)
195+
if head_dim is None:
196+
hidden_size = getattr(sub_config, "hidden_size", None)
197+
num_heads = getattr(sub_config, "num_attention_heads", None)
198+
if not hidden_size or not num_heads:
199+
continue
200+
head_dim = hidden_size // num_heads
201+
202+
# mrope splits head_dim // 2 across the (temporal, height, width) axes; distribute the
203+
# remainder to the leading axis, matching how real checkpoints are laid out.
204+
half = head_dim // 2
205+
base, remainder = divmod(half, 3)
206+
mrope_section = [base + remainder, base, base]
207+
sub_config.rope_scaling = {"rope_type": "default", "type": "default", "mrope_section": mrope_section}
208+
209+
return config
210+
211+
179212
def update_config_for_eagle3(config):
180213
moduler_name = "optimum.exporters.openvino.model_patcher"
181214
spec = importlib.util.find_spec(moduler_name)
@@ -373,6 +406,20 @@ def main_export(
373406
patch_qwenvl_configs()
374407

375408
model_type = config.model_type
409+
410+
if model_type == "qwen3_omni_moe":
411+
if is_transformers_version("<", "5.0"):
412+
raise ValueError(
413+
"Exporting Qwen3-Omni-MoE models requires transformers >= 5.0, "
414+
f"but found {_transformers_version}. Please upgrade transformers."
415+
)
416+
loading_kwargs["config"] = _ensure_qwen3_omni_rope_scaling(config)
417+
418+
if original_task == "auto" and model_type in {"phi4mm", "phi4_multimodal"}:
419+
task = "image-text-to-text"
420+
elif model_type == "qwen3_omni_moe":
421+
task = "image-text-to-text"
422+
376423
if model_type not in TasksManager._SUPPORTED_MODEL_TYPE:
377424
if custom_export_configs is None:
378425
raise ValueError(
@@ -687,11 +734,14 @@ def _main_quantize(
687734
token=token,
688735
)
689736

690-
# NOTE: The Phi-4-multimodal-instruct model card contains a pipeline_tag set to automatic-speech-recognition,
691-
# which is returned as the inferred task. As a result, we try to load the exported model using the
692-
# OVModelForSpeechSeq2Seq class instead of the OVModelForVisualCausalLM class when the task is not specified
693-
# explicitly. Because of this, we get an error.
694-
if original_task == "auto" and library_name == "transformers":
737+
# Some multimodal models must always be reloaded through OVModelForVisualCausalLM, but their task
738+
# would otherwise route to the wrong OV class:
739+
# - Phi-4-multimodal-instruct: its model card pins pipeline_tag=automatic-speech-recognition, so the
740+
# *inferred* (auto) task is wrong and would pick OVModelForSpeechSeq2Seq.
741+
# - Qwen3-Omni-MoE: registers several tasks (text-to-audio, ASR, image-text-to-text); any of them,
742+
# whether inferred or passed explicitly, must still load through OVModelForVisualCausalLM.
743+
# AutoConfig is cheap (cached) so we always read it to decide on the redirect.
744+
if library_name == "transformers":
695745
config = AutoConfig.from_pretrained(
696746
model_name_or_path,
697747
subfolder=subfolder,
@@ -701,7 +751,10 @@ def _main_quantize(
701751
trust_remote_code=trust_remote_code,
702752
)
703753
model_type = config.model_type
704-
if model_type in ["phi4mm", "phi4_multimodal"]:
754+
# phi4mm is only misrouted by task inference, so keep its redirect limited to the auto case.
755+
if original_task == "auto" and model_type in ["phi4mm", "phi4_multimodal"]:
756+
task = "image-text-to-text"
757+
elif model_type == "qwen3_omni_moe":
705758
task = "image-text-to-text"
706759

707760
# Step 1. Obtain the correct OpenVINO model class
@@ -777,7 +830,12 @@ def maybe_convert_tokenizers(library_name: str, output: Path, model=None, prepro
777830
processor_chat_template = processor.chat_template
778831
if tokenizer:
779832
try:
780-
export_tokenizer(tokenizer, output, task=task, processor_chat_template=processor_chat_template)
833+
export_tokenizer(
834+
tokenizer,
835+
output,
836+
task=task,
837+
processor_chat_template=processor_chat_template,
838+
)
781839
except Exception as exception:
782840
logger.warning(
783841
"Could not load tokenizer using specified model ID or path. OpenVINO tokenizer/detokenizer "

‎optimum/exporters/openvino/input_generators.py‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1799,3 +1799,139 @@ def generate(self, input_name: str, framework: str = "pt", int_dtype: str = "int
17991799
return self.random_int_tensor(shape=[1], min_value=1, max_value=10, framework=framework, dtype=float_dtype)
18001800
else:
18011801
raise ValueError(f"Unsupported input {input_name} for DummyKokoroInputGenerator")
1802+
1803+
1804+
class DummyQwen3OmniMoeAudioInputGenerator(DummyInputGenerator):
1805+
SUPPORTED_INPUT_NAMES = ("padded_feature", "padded_mask_after_cnn", "aftercnn_lens", "cu_seqlens")
1806+
1807+
def __init__(
1808+
self,
1809+
task: str,
1810+
normalized_config: NormalizedVisionConfig,
1811+
batch_size: int = 3,
1812+
**kwargs,
1813+
):
1814+
self.batch_size = batch_size
1815+
audio_config = normalized_config.config
1816+
self.num_mels = getattr(audio_config, "num_mel_bins", 128)
1817+
self.time_in = 200 # Mel spectrogram frames (~12.5s audio)
1818+
self.aftercnn_time = self.time_in // 8 # After 3 stride-2 conv layers
1819+
n_window = audio_config.n_window
1820+
n_window_infer = audio_config.n_window_infer
1821+
window_aftercnn = self.aftercnn_time * (n_window_infer // (n_window * 2))
1822+
num_full = self.aftercnn_time // window_aftercnn
1823+
single_batch_chunks = [window_aftercnn] * num_full
1824+
remainder = self.aftercnn_time % window_aftercnn
1825+
if remainder != 0:
1826+
single_batch_chunks.append(remainder)
1827+
cu_chunk_lens = single_batch_chunks * self.batch_size
1828+
cumsum = [0]
1829+
for length in cu_chunk_lens:
1830+
cumsum.append(cumsum[-1] + length)
1831+
self._cu_seqlens = cumsum
1832+
1833+
def generate(self, input_name: str, framework: str = "pt", int_dtype: str = "int64", float_dtype: str = "fp32"):
1834+
if input_name == "padded_feature":
1835+
return self.random_float_tensor([self.batch_size, self.num_mels, self.time_in], framework=framework)
1836+
if input_name == "padded_mask_after_cnn":
1837+
return self.constant_tensor(
1838+
[self.batch_size, self.aftercnn_time], framework=framework, value=1, dtype=DTYPE_MAPPER.pt("bool")
1839+
)
1840+
if input_name == "aftercnn_lens":
1841+
return self.constant_tensor(
1842+
[self.batch_size], framework=framework, value=self.aftercnn_time, dtype=DTYPE_MAPPER.pt(int_dtype)
1843+
)
1844+
if input_name == "cu_seqlens":
1845+
import torch
1846+
1847+
return torch.tensor(self._cu_seqlens, dtype=torch.int32)
1848+
return super().generate(input_name, framework, int_dtype, float_dtype)
1849+
1850+
1851+
class DummyQwen3OmniMoeLMInputGenerator(DummyQwen3VLLMInputGenerator):
1852+
def generate(
1853+
self,
1854+
input_name: str,
1855+
framework: str = "pt",
1856+
int_dtype: str = "int64",
1857+
float_dtype: str = "fp32",
1858+
bool_dtype: str = "bool",
1859+
):
1860+
if input_name == "position_ids":
1861+
base = DummyTextInputGenerator.generate(self, input_name, framework, int_dtype, float_dtype)
1862+
return base.unsqueeze(0).expand(4, -1, -1)
1863+
return super().generate(input_name, framework, int_dtype, float_dtype, bool_dtype)
1864+
1865+
1866+
class DummyQwen3OmniMoeCode2WavInputGenerator(DummyInputGenerator):
1867+
SUPPORTED_INPUT_NAMES = ("codes",)
1868+
1869+
def __init__(self, task: str, normalized_config: NormalizedVisionConfig, batch_size: int = 1, **kwargs):
1870+
self.batch_size = batch_size
1871+
code2wav_config = normalized_config.config
1872+
self.num_quantizers = getattr(code2wav_config, "num_quantizers", 16)
1873+
self.codebook_size = getattr(code2wav_config, "codebook_size", 2048)
1874+
self.seq_len = 10
1875+
1876+
def generate(self, input_name: str, framework: str = "pt", int_dtype: str = "int64", float_dtype: str = "fp32"):
1877+
if input_name == "codes":
1878+
return self.random_int_tensor(
1879+
[self.batch_size, self.num_quantizers, self.seq_len],
1880+
min_value=0,
1881+
max_value=self.codebook_size,
1882+
framework=framework,
1883+
)
1884+
return super().generate(input_name, framework, int_dtype, float_dtype)
1885+
1886+
1887+
class DummyQwen3OmniMoeProjectionInputGenerator(DummyInputGenerator):
1888+
SUPPORTED_INPUT_NAMES = ("hidden_state",)
1889+
1890+
def __init__(
1891+
self,
1892+
task: str,
1893+
normalized_config: NormalizedVisionConfig,
1894+
batch_size: int = DEFAULT_DUMMY_SHAPES["batch_size"],
1895+
sequence_length: int = DEFAULT_DUMMY_SHAPES["sequence_length"],
1896+
**kwargs,
1897+
):
1898+
self.batch_size = batch_size
1899+
self.sequence_length = sequence_length
1900+
config = normalized_config.config
1901+
# The projection (Qwen3OmniMoeTalkerResizeMLP.linear_fc1) consumes thinker hidden states whose width is
1902+
# talker_config.thinker_hidden_size; fall back to text_config.hidden_size only if it's absent.
1903+
text_config = getattr(config, "text_config", config)
1904+
self.hidden_size = getattr(config, "thinker_hidden_size", None) or text_config.hidden_size
1905+
1906+
def generate(self, input_name: str, framework: str = "pt", int_dtype: str = "int64", float_dtype: str = "fp32"):
1907+
if input_name == "hidden_state":
1908+
return self.random_float_tensor(
1909+
[self.batch_size, self.sequence_length, self.hidden_size], framework=framework
1910+
)
1911+
return super().generate(input_name, framework, int_dtype, float_dtype)
1912+
1913+
1914+
class DummyQwen3OmniMoeVisionInputGenerator(DummyQwen3VLVisionEmbedInputGenerator):
1915+
SUPPORTED_INPUT_NAMES = ("hidden_states", "pos_embeds", "attention_mask", "rotary_pos_emb", "input")
1916+
1917+
def __init__(self, task, normalized_config, batch_size=1, **kwargs):
1918+
super().__init__(task, normalized_config, batch_size=batch_size, **kwargs)
1919+
self.patch_channels = (
1920+
normalized_config.config.in_channels
1921+
* normalized_config.config.temporal_patch_size
1922+
* normalized_config.config.patch_size
1923+
* normalized_config.config.patch_size
1924+
)
1925+
1926+
def generate(self, input_name, framework="pt", int_dtype="int64", float_dtype="fp32"):
1927+
grid_h, grid_w = self.height // self.patch_size, self.width // self.patch_size
1928+
seq_len = self.batch_size * grid_h * grid_w
1929+
1930+
if input_name == "hidden_states":
1931+
# Raw patch data (patch_channels), not embeddings: the vision model embeds patches internally.
1932+
return self.random_float_tensor([seq_len, self.patch_channels], framework=framework, dtype=float_dtype)
1933+
1934+
if input_name == "pos_embeds":
1935+
return self.random_float_tensor([seq_len, self.embed_dim], framework=framework, dtype=float_dtype)
1936+
1937+
return super().generate(input_name, framework, int_dtype, float_dtype)

0 commit comments

Comments
 (0)