feat[vLLM × v5]: Add audio support for the Transformers backend (#39330)
Signed-off-by: Harshal Janjani <harshaljanjani@gmail.com> Signed-off-by: Harry Mellor <19981378+hmellor@users.noreply.github.com> Co-authored-by: Harry Mellor <19981378+hmellor@users.noreply.github.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Harshal Janjani committed
Jul 25, 2026 at 15:20 UTC
b9b6306ebe0f5fa5046553667192e56bb3934af0
8 files changed
+680
-150
docs/models/supported_models.md
+3
-2
@@ -19,7 +19,7 @@ vLLM also supports model implementations that are available in Transformers. We
19
20
Currently, the Transformers modeling backend works for the following:
21
22
-- Modalities: embedding models, language models and vision-language models*
22
+- Modalities: embedding models, language models, vision-language models* and audio-language models
23
- Architectures: encoder-only, decoder-only, mixture-of-experts
24
- Attention types: full attention and/or sliding attention
25
@@ -606,7 +606,8 @@ Some models are supported only via the [Transformers modeling backend](#transfor
606
607
| Architecture | Models | Inputs | Example HF Models | [LoRA](../features/lora.md) | [PP](../serving/parallelism_scaling.md) |
608
| ------------ | ------ | ------ | ----------------- | --------------------------- | --------------------------------------- |
609
-| `Emu3ForConditionalGeneration` | Emu3 | T + I | `BAAI/Emu3-Chat-hf` | ✅︎ | ✅︎ |
609
+| `Emu3ForConditionalGeneration` | Emu3 | T + I<sup>+</sup> | `BAAI/Emu3-Chat-hf` | ✅︎ | ✅︎ |
610
+| `VibeVoiceAsrForConditionalGeneration` | VibeVoice-ASR | T + A<sup>+</sup> | `microsoft/VibeVoice-ASR-HF` | ✅︎ | ✅︎ |
611
612
<sup>^</sup> You need to set the architecture name via `--hf-overrides` to match the one in vLLM.</br>
613
<sup>E</sup> Pre-computed embeddings can be inputted for this modality.</br>
tests/models/multimodal/generation/test_transformers_audio.py
new
+133
@@ -0,0 +1,133 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from typing import Any
5
+
6
+import pytest
7
+from transformers import AutoModelForSeq2SeqLM
8
+
9
+from vllm.assets.audio import AudioAsset
10
+from vllm.envs import disable_envs_cache
11
+from vllm.lora.request import LoRARequest
12
+from vllm.multimodal.audio import AudioResampler
13
+
14
+from ....conftest import HfRunner, VllmRunner
15
+from ...utils import check_logprobs_close
16
+
17
+AUDIO_ASSET = AudioAsset("mary_had_lamb")
18
+
19
+AUDIO_MODEL_SETTINGS: dict[str, dict[str, Any]] = {
20
+ "ibm-granite/granite-speech-3.3-2b": {
21
+ "prompt": (
22
+ "<|start_of_role|>system<|end_of_role|>"
23
+ "You are a helpful AI assistant<|end_of_text|>\n"
24
+ "<|start_of_role|>user<|end_of_role|>"
25
+ "<|audio|>can you transcribe the speech into a written format?"
26
+ "<|end_of_text|>\n"
27
+ "<|start_of_role|>assistant<|end_of_role|>"
28
+ ),
29
+ "audio_lora_path": "ibm-granite/granite-speech-3.3-2b",
30
+ },
31
+ "nvidia/audio-flamingo-3-hf": {
32
+ "prompt": (
33
+ "<|im_start|>system\n"
34
+ "You are a helpful assistant.<|im_end|>\n"
35
+ "<|im_start|>user\n"
36
+ "<sound>Transcribe the input speech.<|im_end|>\n"
37
+ "<|im_start|>assistant\n"
38
+ ),
39
+ "vllm_runner_kwargs": {
40
+ "gpu_memory_utilization": 0.85,
41
+ },
42
+ },
43
+ "microsoft/VibeVoice-ASR-HF": {
44
+ "prompt": (
45
+ "<|im_start|>system\n"
46
+ "You are a helpful assistant that transcribes audio input "
47
+ "into text output in JSON format.<|im_end|>\n"
48
+ "<|im_start|>user\n"
49
+ "<|object_ref_start|><|box_start|><|object_ref_end|>\n"
50
+ "This is a 1.0 seconds audio, please transcribe it with "
51
+ "these keys: Start time, End time, Speaker ID, Content"
52
+ "<|im_end|>\n"
53
+ "<|im_start|>assistant\n"
54
+ ),
55
+ "sampling_rate": 24000,
56
+ "vllm_runner_kwargs": {
57
+ "max_num_batched_tokens": 2048,
58
+ "gpu_memory_utilization": 0.85,
59
+ },
60
+ },
61
+ "zai-org/GLM-ASR-Nano-2512": {
62
+ "prompt": (
63
+ "<|user|>\n"
64
+ "<|begin_of_audio|><|pad|><|end_of_audio|><|user|>\n"
65
+ "Please transcribe this audio into text"
66
+ "<|assistant|>\n"
67
+ ),
68
+ },
69
+}
70
+
71
+
72
+@pytest.mark.parametrize("model_id", list(AUDIO_MODEL_SETTINGS))
73
+def test_transformers_audio_generation(
74
+ hf_runner: type[HfRunner],
75
+ vllm_runner: type[VllmRunner],
76
+ monkeypatch,
77
+ model_id: str,
78
+):
79
+ """Single-process workaround for V1 fork safety deadlock issue
80
+ (vllm-project/vllm/issues/17676). Running multiple audio models together
81
+ under pytest can cause (possibly flaky) hangs, so they are grouped under
82
+ the same config. Using VLLM_WORKER_MULTIPROC_METHOD=spawn avoids the
83
+ deadlock and allows worker processes to terminate cleanly, and release
84
+ GPU memory between test runs until the issue is fixed."""
85
+ # TODO: Remove monkeypatch once
86
+ # https://github.com/vllm-project/vllm/issues/17676 is fixed.
87
+ disable_envs_cache()
88
+ monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
89
+
90
+ settings = AUDIO_MODEL_SETTINGS[model_id]
91
+ audio_lora_path = settings.get("audio_lora_path")
92
+
93
+ audio, orig_sr = AUDIO_ASSET.audio_and_sample_rate
94
+ target_sr = settings.get("sampling_rate", orig_sr)
95
+ if orig_sr != target_sr:
96
+ audio = AudioResampler(target_sr=target_sr).resample(audio, orig_sr=orig_sr)
97
+ audio = (audio, target_sr)
98
+
99
+ with vllm_runner(
100
+ model_id,
101
+ model_impl="transformers",
102
+ dtype="bfloat16",
103
+ max_model_len=2048,
104
+ enforce_eager=True,
105
+ limit_mm_per_prompt={"audio": 1},
106
+ enable_lora=audio_lora_path is not None,
107
+ max_lora_rank=64,
108
+ **settings.get("vllm_runner_kwargs", {}),
109
+ ) as vllm_model:
110
+ lora_request = (
111
+ LoRARequest("audio", 1, audio_lora_path) if audio_lora_path else None
112
+ )
113
+ vllm_outputs = vllm_model.generate_greedy_logprobs(
114
+ [settings["prompt"]],
115
+ 128,
116
+ num_logprobs=10,
117
+ audios=[audio],
118
+ lora_request=lora_request,
119
+ )
120
+
121
+ with hf_runner(
122
+ model_id, dtype="bfloat16", auto_cls=AutoModelForSeq2SeqLM
123
+ ) as hf_model:
124
+ hf_outputs = hf_model.generate_greedy_logprobs_limit(
125
+ [settings["prompt"]], 128, num_logprobs=10, audios=[audio]
126
+ )
127
+
128
+ check_logprobs_close(
129
+ outputs_0_lst=hf_outputs,
130
+ outputs_1_lst=vllm_outputs,
131
+ name_0="hf",
132
+ name_1="vllm",
133
+ )
tests/models/multimodal/processing/test_common.py
+8
@@ -452,6 +452,14 @@ def test_processing_correctness(
452
"audio placeholders from processed audio lengths. Its vLLM "
453
"processor paths are covered by test_moss_audio.py."
454
)
455
+ # TODO: Remove when transformers 5.15.0 is released, which contains
456
+ # https://github.com/huggingface/transformers/pull/47483.
457
+ if model_id == "microsoft/VibeVoice-ASR-HF":
458
+ pytest.skip(
459
+ "VibeVoice ASR requires audio as a positional argument and hence "
460
+ "cannot pass the processing correctness test as is. Its generation "
461
+ "is covered by test_transformers_audio.py."
462
+ )
463
if model_id == "lmms-lab-encoder/LLaVA-OneVision-2-8B-Instruct":
464
pytest.skip(
465
"LLaVA-OneVision-2 video processing routes frames through custom "
tests/models/multimodal/processing/test_transformers_audio.py
new
+138
@@ -0,0 +1,138 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+import numpy as np
4
+import pytest
5
+
6
+from vllm.config import ModelConfig
7
+from vllm.multimodal import MULTIMODAL_REGISTRY
8
+
9
+AUDIO_MODEL_SETTINGS = {
10
+ "ibm-granite/granite-speech-3.3-2b": {
11
+ "prompt": (
12
+ "<|start_of_role|>system<|end_of_role|>"
13
+ "You are a helpful AI assistant<|end_of_text|>\n"
14
+ "<|start_of_role|>user<|end_of_role|>"
15
+ "<|audio|>can you transcribe the speech into a written format?"
16
+ "<|end_of_text|>\n"
17
+ "<|start_of_role|>assistant<|end_of_role|>"
18
+ ),
19
+ },
20
+ "nvidia/audio-flamingo-3-hf": {
21
+ "prompt": (
22
+ "<|im_start|>system\n"
23
+ "You are a helpful assistant.<|im_end|>\n"
24
+ "<|im_start|>user\n"
25
+ "<sound>Transcribe the input speech.<|im_end|>\n"
26
+ "<|im_start|>assistant\n"
27
+ ),
28
+ },
29
+ "mistralai/Voxtral-Mini-3B-2507": {
30
+ "prompt": ("[INST][AUDIO]What can you tell me about this audio?[/INST]"),
31
+ },
32
+ "microsoft/VibeVoice-ASR-HF": {
33
+ "prompt": (
34
+ "<|im_start|>system\n"
35
+ "You are a helpful assistant that transcribes audio input "
36
+ "into text output in JSON format.<|im_end|>\n"
37
+ "<|im_start|>user\n"
38
+ "<|object_ref_start|><|box_start|><|object_ref_end|>\n"
39
+ "This is a 1.0 seconds audio, please transcribe it with "
40
+ "these keys: Start time, End time, Speaker ID, Content"
41
+ "<|im_end|>\n"
42
+ "<|im_start|>assistant\n"
43
+ ),
44
+ },
45
+ "zai-org/GLM-ASR-Nano-2512": {
46
+ "prompt": (
47
+ "<|user|>\n"
48
+ "<|begin_of_audio|><|pad|><|end_of_audio|><|user|>\n"
49
+ "Please transcribe this audio into text"
50
+ "<|assistant|>\n"
51
+ ),
52
+ },
53
+}
54
+
55
+
56
+@pytest.mark.parametrize(
57
+ "model_id",
58
+ [
59
+ "ibm-granite/granite-speech-3.3-2b",
60
+ "nvidia/audio-flamingo-3-hf",
61
+ pytest.param(
62
+ "mistralai/Voxtral-Mini-3B-2507",
63
+ marks=pytest.mark.xfail(
64
+ reason="MistralCommonBackend.encode does not produce the audio "
65
+ "placeholder token (ID 24) from raw text. apply_chat_template "
66
+ "yields token IDs with placeholders, but MultiModalProcessor."
67
+ "apply() decodes the prompt back to text and re-tokenizes, at "
68
+ "which point the placeholders are lost. Fix belongs in "
69
+ "mistral_common or in the Voxtral-specific path.",
70
+ strict=False,
71
+ ),
72
+ ),
73
+ "microsoft/VibeVoice-ASR-HF",
74
+ "zai-org/GLM-ASR-Nano-2512",
75
+ ],
76
+)
77
+def test_audio_multimodal_processor(model_id):
78
+ settings = AUDIO_MODEL_SETTINGS[model_id]
79
+
80
+ model_config = ModelConfig(
81
+ model=model_id,
82
+ model_impl="transformers",
83
+ )
84
+
85
+ mm_processor = MULTIMODAL_REGISTRY.create_processor(model_config)
86
+
87
+ audio = np.zeros(16000, dtype=np.float32)
88
+ mm_data = {"audio": (audio, 16000)}
89
+
90
+ result = mm_processor(
91
+ prompt=settings["prompt"],
92
+ mm_items=mm_processor.info.parse_mm_data(mm_data),
93
+ hf_processor_mm_kwargs={},
94
+ )
95
+
96
+ assert "prompt_token_ids" in result
97
+ assert len(result["prompt_token_ids"]) > 0
98
+
99
+ mm_placeholders = result.get("mm_placeholders", {})
100
+ assert "audio" in mm_placeholders, f"No audio placeholders found for {model_id}"
101
+ assert len(mm_placeholders["audio"]) == 1
102
+
103
+ placeholder = mm_placeholders["audio"][0]
104
+ assert placeholder.length > 0
105
+ assert placeholder.offset >= 0
106
+
107
+ audio_items = result.get("mm_kwargs", {}).get("audio", [])
108
+ assert len(audio_items) == 1, f"Expected 1 audio item, got {len(audio_items)}"
109
+ item_keys = list(audio_items[0].keys())
110
+ has_features = "input_features" in item_keys or "input_values" in item_keys
111
+ assert has_features, (
112
+ f"No audio features (input_features/input_values) in {item_keys} for {model_id}"
113
+ )
114
+
115
+
116
+def test_audio_multiple_inputs():
117
+ """Multiple audios per prompt are each detected as a separate placeholder
118
+ and multi-modal item by the Transformers backend."""
119
+ model_id = "ibm-granite/granite-speech-3.3-2b"
120
+ model_config = ModelConfig(model=model_id, model_impl="transformers")
121
+ mm_processor = MULTIMODAL_REGISTRY.create_processor(model_config)
122
+
123
+ audio_token = mm_processor.info.get_hf_processor().audio_token
124
+ # One token per audio; the processor expands each to its placeholder run.
125
+ prompt = (
126
+ "<|start_of_role|>user<|end_of_role|>"
127
+ f"{audio_token} and {audio_token} transcribe<|end_of_text|>\n"
128
+ )
129
+ audios = [np.zeros(16000, dtype=np.float32), np.zeros(24000, dtype=np.float32)]
130
+
131
+ result = mm_processor(
132
+ prompt=prompt,
133
+ mm_items=mm_processor.info.parse_mm_data({"audio": audios}),
134
+ hf_processor_mm_kwargs={},
135
+ )
136
+
137
+ assert len(result["mm_placeholders"]["audio"]) == 2
138
+ assert len(result["mm_kwargs"]["audio"]) == 2
tests/models/multimodal/processing/test_transformers_image.py
renamed
+23
@@ -54,3 +54,26 @@ def test_multimodal_processor(model_id):
54
str_processed_inputs["prompt_token_ids"]
55
== ids_processed_inputs["prompt_token_ids"]
56
)
57
+
58
+
59
+def test_image_multiple_inputs():
60
+ """Multiple images per prompt are each detected as a separate placeholder
61
+ and multi-modal item by the Transformers backend."""
62
+ model_id = "llava-hf/llava-onevision-qwen2-0.5b-ov-hf"
63
+ model_config = ModelConfig(model=model_id, model_impl="transformers")
64
+ mm_processor = MULTIMODAL_REGISTRY.create_processor(model_config)
65
+
66
+ image = ImageAsset("cherry_blossom").pil_image
67
+ prompt = (
68
+ "<|im_start|>user <image>\n and <image>\n"
69
+ "What do these images show?<|im_end|><|im_start|>assistant\n"
70
+ )
71
+
72
+ result = mm_processor(
73
+ prompt=prompt,
74
+ mm_items=mm_processor.info.parse_mm_data({"image": [image, image]}),
75
+ hf_processor_mm_kwargs={},
76
+ )
77
+
78
+ assert len(result["mm_placeholders"]["image"]) == 2
79
+ assert len(result["mm_kwargs"]["image"]) == 2
tests/models/registry.py
+4
@@ -1393,6 +1393,10 @@ _MULTIMODAL_EXAMPLE_MODELS = {
1393
"fixie-ai/ultravox-v0_5-llama-3_2-1b",
1394
trust_remote_code=True,
1395
),
1396
+ "VibeVoiceAsrForConditionalGeneration": _HfExamplesInfo(
1397
+ "microsoft/VibeVoice-ASR-HF",
1398
+ min_transformers_version="5.13.0",
1399
+ ),
1400
"VoxtralForConditionalGeneration": _HfExamplesInfo(
1401
"mistralai/Voxtral-Mini-3B-2507",
1402
tokenizer_mode="mistral",
vllm/model_executor/models/registry.py
+4
@@ -664,6 +664,10 @@ _TRANSFORMERS_SUPPORTED_MODELS = {
664
"transformers",
665
"TransformersMultiModalForCausalLM",
666
),
667
+ "VibeVoiceAsrForConditionalGeneration": (
668
+ "transformers",
669
+ "TransformersMultiModalForCausalLM",
670
+ ),
671
}
672
673
_TRANSFORMERS_BACKEND_MODELS = {
vllm/model_executor/models/transformers/multimodal.py
+367
-148
@@ -17,7 +17,8 @@
17
"""Transformers modeling backend mixin for multi-modal models."""
18
19
from collections.abc import Mapping
20
-from typing import TYPE_CHECKING
20
+from contextlib import nullcontext
21
+from typing import TYPE_CHECKING, Any
22
23
import torch
24
from transformers import AutoModel
@@ -27,6 +28,7 @@ from vllm.config.utils import getattr_iter
28
from vllm.inputs import MultiModalDataDict, MultiModalInput, mm_input
29
from vllm.logger import init_logger
30
from vllm.model_executor.models.interfaces import SupportsMRoPE, SupportsMultiModal
31
+from vllm.model_executor.models.module_mapping import MultiModelKeys
32
from vllm.multimodal import MultiModalKwargsItems
33
from vllm.multimodal.inputs import (
34
MultiModalFeatureSpec,
@@ -36,6 +38,7 @@ from vllm.multimodal.inputs import (
38
from vllm.multimodal.parse import (
39
ImageProcessorItems,
40
MultiModalDataItems,
41
+ MultiModalDataParser,
42
)
43
from vllm.multimodal.processing import (
44
BaseDummyInputsBuilder,
@@ -59,11 +62,90 @@ _MODALITY_TO_TOKEN_TYPE_ID = {"image": 1, "video": 2, "audio": 3}
62
63
64
class MultiModalProcessingInfo(BaseProcessingInfo):
65
+ def _get_audio_processor(self) -> Any:
66
+ # TODO: drop feature_extractor branch once huggingface/transformers#44394 lands.
67
+ return getattr_iter(
68
+ self.get_hf_processor(), ("audio_processor", "feature_extractor")
69
+ )
70
+
71
+ def _is_audio_model(self) -> bool:
72
+ return self._get_audio_processor() is not None
73
+
74
+ def _is_image_model(self) -> bool:
75
+ return hasattr(self.get_hf_processor(), "image_processor")
76
+
77
+ def _is_video_model(self) -> bool:
78
+ return hasattr(self.get_hf_processor(), "video_processor")
79
+
80
+ def _get_audio_token_id(self) -> int:
81
+ processor = self.get_hf_processor()
82
+ if hasattr(processor, "audio_token_id"):
83
+ return processor.audio_token_id
84
+ config = self.get_hf_config()
85
+ val = getattr_iter(config, ("audio_token_id", "audio_token_index"))
86
+ if val is not None:
87
+ return val
88
+ if hasattr(processor, "audio_token"):
89
+ tokenizer = self.get_tokenizer()
90
+ vocab = tokenizer.get_vocab()
91
+ if processor.audio_token in vocab:
92
+ return vocab[processor.audio_token]
93
+ raise ValueError("Cannot find audio_token_id on processor or model config")
94
+
95
+ def _get_audio_sampling_rate(self) -> float:
96
+ sub = self._get_audio_processor()
97
+ if sub is not None and hasattr(sub, "sampling_rate"):
98
+ return sub.sampling_rate
99
+ return 16000.0
100
+
101
+ def get_data_parser(self) -> MultiModalDataParser:
102
+ if self._is_audio_model():
103
+ return MultiModalDataParser(
104
+ target_sr=self._get_audio_sampling_rate(),
105
+ expected_hidden_size=self._get_expected_hidden_size(),
106
+ )
107
+ return super().get_data_parser()
108
+
109
def get_supported_mm_limits(self):
63
- return {"image": None}
110
+ limits = {}
111
+ if self._is_audio_model():
112
+ limits["audio"] = None
113
+ if self._is_image_model():
114
+ limits["image"] = None
115
+ if not limits:
116
+ raise ValueError(
117
+ f"Unable to detect a supported modality on "
118
+ f"{type(self.get_hf_processor()).__name__}. "
119
+ "Checked `_is_audio_model` and `_is_image_model`."
120
+ )
121
+ return limits
122
123
def get_mm_max_tokens_per_item(self, seq_len, mm_counts):
66
- return {"image": self.get_max_image_tokens()}
124
+ result = {}
125
+ if self._is_audio_model():
126
+ result["audio"] = self.get_max_audio_tokens()
127
+ if self._is_image_model():
128
+ result["image"] = self.get_max_image_tokens()
129
+ if not result:
130
+ raise ValueError(
131
+ f"Unable to detect a supported modality on "
132
+ f"{type(self.get_hf_processor()).__name__}. "
133
+ "Checked `_is_audio_model` and `_is_image_model`."
134
+ )
135
+ return result
136
+
137
+ def get_max_audio_tokens(self) -> int:
138
+ config = self.get_hf_config()
139
+ audio_config_names = ("audio_config", "encoder_config")
140
+ names = ("max_source_positions", "max_position_embeddings", "max_pos_emb")
141
+ audio_config = getattr_iter(config, audio_config_names, default=config)
142
+ val = getattr_iter(audio_config, names)
143
+ if val is not None:
144
+ return int(val)
145
+ raise ValueError(
146
+ f"Unable to get max input length from {type(audio_config).__name__}. "
147
+ f"The following attribute names were checked: {names}."
148
+ )
149
150
def get_max_image_tokens(self) -> int:
151
width, height = self.get_max_image_size()
@@ -82,14 +164,19 @@ class MultiModalProcessingInfo(BaseProcessingInfo):
164
165
class MultiModalDummyInputsBuilder(BaseDummyInputsBuilder[MultiModalProcessingInfo]):
166
def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
85
- num_images = mm_counts.get("image", 0)
86
-
87
- processor = self.info.get_hf_processor()
88
- if "gemma3" in processor.__class__.__name__.lower():
89
- image_token = processor.boi_token
90
- else:
91
- image_token = getattr(processor, "image_token", "")
92
- return image_token * num_images
167
+ text = ""
168
+ if self.info._is_audio_model() and (num_audios := mm_counts.get("audio", 0)):
169
+ processor = self.info.get_hf_processor()
170
+ audio_token = getattr(processor, "audio_token", "")
171
+ text += audio_token * num_audios
172
+ if self.info._is_image_model() and (num_images := mm_counts.get("image", 0)):
173
+ processor = self.info.get_hf_processor()
174
+ if "gemma3" in processor.__class__.__name__.lower():
175
+ image_token = processor.boi_token
176
+ else:
177
+ image_token = getattr(processor, "image_token", "")
178
+ text += image_token * num_images
179
+ return text
180
181
def get_dummy_mm_data(
182
self,
@@ -97,20 +184,28 @@ class MultiModalDummyInputsBuilder(BaseDummyInputsBuilder[MultiModalProcessingIn
184
mm_counts: Mapping[str, int],
185
mm_options: Mapping[str, "BaseDummyOptions"],
186
) -> MultiModalDataDict:
100
- num_images = mm_counts.get("image", 0)
101
-
102
- target_width, target_height = self.info.get_max_image_size()
103
-
104
- image_overrides = mm_options.get("image")
105
-
106
- return {
107
- "image": self._get_dummy_images(
187
+ data: MultiModalDataDict = {}
188
+ if self.info._is_audio_model() and (num_audios := mm_counts.get("audio", 0)):
189
+ sampling_rate = self.info._get_audio_sampling_rate()
190
+ sub = self.info._get_audio_processor()
191
+ chunk_length = getattr(sub, "chunk_length", None) if sub else None
192
+ if chunk_length is None:
193
+ chunk_length = 30
194
+ audio_len = int(chunk_length * sampling_rate)
195
+ data["audio"] = self._get_dummy_audios(
196
+ length=audio_len,
197
+ num_audios=num_audios,
198
+ overrides=mm_options.get("audio"),
199
+ )
200
+ if self.info._is_image_model() and (num_images := mm_counts.get("image", 0)):
201
+ target_width, target_height = self.info.get_max_image_size()
202
+ data["image"] = self._get_dummy_images(
203
width=target_width,
204
height=target_height,
205
num_images=num_images,
111
- overrides=image_overrides,
112
- ),
113
- }
206
+ overrides=mm_options.get("image"),
207
+ )
208
+ return data
209
210
211
class MultiModalProcessor(BaseMultiModalProcessor[MultiModalProcessingInfo]):
@@ -142,21 +237,39 @@ class MultiModalProcessor(BaseMultiModalProcessor[MultiModalProcessingInfo]):
237
) -> Mapping[str, MultiModalFieldConfig]:
238
# HF Processors always return a mask but vLLM doesn't need it
239
hf_inputs.pop("attention_mask", None)
145
- num_image_patches = hf_inputs.get("num_image_patches")
146
- mm_fields = {
147
- key: MultiModalFieldConfig.flat_from_sizes("image", num_image_patches)
148
- for key in hf_inputs
149
- }
150
- mm_fields["image_embeds"] = MultiModalFieldConfig.flat_from_sizes(
151
- "image", num_image_patches
152
- )
240
154
- # Keep these as batched, as they always have batch size as first dim
155
- mm_fields["image_grid_thw"] = MultiModalFieldConfig.batched("image")
156
- mm_fields["video_grid_thw"] = MultiModalFieldConfig.batched("image")
157
- mm_fields["num_image_patches"] = MultiModalFieldConfig.batched(
158
- "image", keep_on_cpu=True
159
- )
241
+ mm_fields: dict[str, MultiModalFieldConfig] = {}
242
+ if self.info._is_audio_model():
243
+ num_audio_tokens = hf_inputs.get("num_audio_tokens")
244
+ mm_fields.update(
245
+ {
246
+ key: MultiModalFieldConfig.flat_from_sizes(
247
+ "audio", num_audio_tokens
248
+ )
249
+ for key in hf_inputs
250
+ }
251
+ )
252
+ mm_fields["num_audio_tokens"] = MultiModalFieldConfig.batched("audio")
253
+ if self.info._is_image_model():
254
+ num_image_patches = hf_inputs.get("num_image_patches")
255
+ mm_fields.update(
256
+ {
257
+ key: MultiModalFieldConfig.flat_from_sizes(
258
+ "image", num_image_patches
259
+ )
260
+ for key in hf_inputs
261
+ }
262
+ )
263
+ mm_fields["image_embeds"] = MultiModalFieldConfig.flat_from_sizes(
264
+ "image", num_image_patches
265
+ )
266
+
267
+ # Keep these as batched, as they always have batch size as first dim
268
+ mm_fields["image_grid_thw"] = MultiModalFieldConfig.batched("image")
269
+ mm_fields["video_grid_thw"] = MultiModalFieldConfig.batched("image")
270
+ mm_fields["num_image_patches"] = MultiModalFieldConfig.batched(
271
+ "image", keep_on_cpu=True
272
+ )
273
return mm_fields
274
275
def _get_hf_mm_data(
@@ -164,13 +277,93 @@ class MultiModalProcessor(BaseMultiModalProcessor[MultiModalProcessingInfo]):
277
mm_items: MultiModalDataItems,
278
) -> tuple[Mapping[str, object], Mapping[str, object]]:
279
"""
167
- In contrast to the base class, this method always adds
168
- `return_mm_token_type_ids` to the processor data
280
+ In contrast to the base class, this method requests
281
+ `return_mm_token_type_ids` and remaps the `audios` key to `audio` for
282
+ audio models.
283
"""
284
processor_data, passthrough_data = super()._get_hf_mm_data(mm_items)
285
+ if self.info._is_audio_model() and "audios" in processor_data:
286
+ processor_data["audio"] = processor_data.pop("audios")
287
processor_data["return_mm_token_type_ids"] = True
288
return processor_data, passthrough_data
289
290
+ def _apply_audio(
291
+ self,
292
+ prompt_ids: list[int],
293
+ processed_data: "BatchFeature",
294
+ ) -> dict[str, list[PlaceholderRange]]:
295
+ audio_token_id = self.info._get_audio_token_id()
296
+ prompt_tensor = torch.tensor(prompt_ids)
297
+ is_audio = prompt_tensor == audio_token_id
298
+
299
+ if not is_audio.any():
300
+ return {}
301
+
302
+ padded = torch.cat([torch.tensor([False]), is_audio, torch.tensor([False])])
303
+ transitions = padded.int().diff()
304
+ starts = torch.where(transitions == 1)[0]
305
+ ends = torch.where(transitions == -1)[0]
306
+ lengths = ends - starts
307
+
308
+ ranges = [
309
+ PlaceholderRange(
310
+ offset=s.item(),
311
+ length=ln.item(),
312
+ is_embed=torch.ones(ln.item(), dtype=torch.bool),
313
+ )
314
+ for s, ln in zip(starts, lengths)
315
+ ]
316
+ processed_data["num_audio_tokens"] = lengths
317
+ return {"audio": ranges}
318
+
319
+ def _apply_vision(
320
+ self,
321
+ prompt_ids: list[int],
322
+ processed_data: "BatchFeature",
323
+ mm_items: MultiModalDataItems,
324
+ hf_processor_mm_kwargs: Mapping[str, object],
325
+ mm_token_type_ids: torch.Tensor | None,
326
+ ) -> dict[str, list[PlaceholderRange]]:
327
+ if mm_token_type_ids is None:
328
+ return {}
329
+
330
+ hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
331
+
332
+ # We can infer vLLM style placeholder from token type ids, if we split
333
+ # it for each input `mm_data`.
334
+ mm_positions = torch.where(mm_token_type_ids == 1)[1]
335
+ images = mm_items.get_items("image", ImageProcessorItems)
336
+ image_sizes = []
337
+ for item_idx in range(len(images)):
338
+ image_size = images.get_image_size(item_idx)
339
+ image_sizes.append((image_size.height, image_size.width))
340
+
341
+ mm_tokens_per_modality = hf_processor._get_num_multimodal_tokens(
342
+ image_sizes=image_sizes,
343
+ **self.info.ctx.get_merged_mm_kwargs({}),
344
+ )
345
+
346
+ mm_placeholders: dict[str, list[PlaceholderRange]] = {}
347
+ split_sizes = mm_tokens_per_modality["num_image_tokens"]
348
+ if split_sizes:
349
+ chunked_mm_positions = torch.split(mm_positions, split_sizes)
350
+ mm_tokens = torch.tensor(prompt_ids)[mm_token_type_ids[0].bool()]
351
+ chunked_mm_tokens = torch.split(mm_tokens, split_sizes)
352
+ ranges = [
353
+ PlaceholderRange(
354
+ offset=positions[0].item(),
355
+ length=positions.shape[0],
356
+ is_embed=(mm_tokens == hf_processor.image_token_id).bool(),
357
+ )
358
+ for positions, mm_tokens in zip(chunked_mm_positions, chunked_mm_tokens)
359
+ ]
360
+ mm_placeholders = {"image": ranges}
361
+
362
+ processed_data["num_image_patches"] = torch.tensor(
363
+ mm_tokens_per_modality["num_image_patches"]
364
+ )
365
+ return mm_placeholders
366
+
367
def apply(
368
self,
369
inputs: ProcessorInputs,
@@ -198,8 +391,8 @@ class MultiModalProcessor(BaseMultiModalProcessor[MultiModalProcessingInfo]):
391
# Bypass cached processor and always apply to the full set of mm inputs
392
# NOTE: we can't just set caching=False because base class method
393
# transforms outputs to `MultiModalKwargs` which is not going to
201
- # work for Transformers. We have a lot of logic tied to
202
- # `mm_tokens_per_modality` below
394
+ # work for Transformers. The vision path has logic tied to
395
+ # `mm_tokens_per_modality` in _apply_vision()
396
prompt_ids, processed_data, _ = self._apply_hf_processor_text_mm(
397
prompt_text=prompt,
398
mm_items=mm_items,
@@ -207,52 +400,33 @@ class MultiModalProcessor(BaseMultiModalProcessor[MultiModalProcessingInfo]):
400
tokenization_kwargs=tokenization_kwargs,
401
)
402
403
+ # Use overrides if provided; fallback to data-dependent hashing.
404
+ with timing_ctx.record("get_mm_hashes"):
405
+ mm_hashes = inputs.get_mm_hashes(self.info.model_id)
406
+
407
# For gemma3 we check `token_type_ids` as the key
408
mm_token_type_ids = processed_data.pop("token_type_ids", None)
409
mm_token_type_ids = processed_data.pop("mm_token_type_ids", mm_token_type_ids)
410
214
- # We can infer vLLM style placeholder from token type ids, if we split
215
- # it for each input `mm_data`.
216
- mm_positions = torch.where(mm_token_type_ids == 1)[1]
217
- images = mm_items.get_items("image", ImageProcessorItems)
218
- image_sizes = []
219
- for item_idx in range(len(images)):
220
- image_size = images.get_image_size(item_idx)
221
- image_sizes.append((image_size.height, image_size.width))
222
-
223
- mm_tokens_per_modality = hf_processor._get_num_multimodal_tokens(
224
- image_sizes=image_sizes,
225
- **self.info.ctx.get_merged_mm_kwargs({}),
226
- )
227
-
228
- mm_placeholders = {}
229
- split_sizes = mm_tokens_per_modality["num_image_tokens"]
230
- if split_sizes:
231
- chunked_mm_positions = torch.split(mm_positions, split_sizes)
232
- mm_tokens = torch.tensor(prompt_ids)[mm_token_type_ids[0].bool()]
233
- chunked_mm_tokens = torch.split(mm_tokens, split_sizes)
234
- ranges = [
235
- PlaceholderRange(
236
- offset=positions[0].item(),
237
- length=positions.shape[0],
238
- is_embed=(mm_tokens == hf_processor.image_token_id).bool(),
411
+ mm_placeholders: dict[str, list[PlaceholderRange]] = {}
412
+ if self.info._is_audio_model():
413
+ mm_placeholders.update(self._apply_audio(prompt_ids, processed_data))
414
+ if self.info._is_image_model():
415
+ mm_placeholders.update(
416
+ self._apply_vision(
417
+ prompt_ids,
418
+ processed_data,
419
+ mm_items,
420
+ hf_processor_mm_kwargs,
421
+ mm_token_type_ids,
422
)
240
- for positions, mm_tokens in zip(chunked_mm_positions, chunked_mm_tokens)
241
- ]
242
- mm_placeholders = {"image": ranges}
423
+ )
424
244
- processed_data["num_image_patches"] = torch.tensor(
245
- mm_tokens_per_modality["num_image_patches"]
246
- )
425
mm_kwargs = MultiModalKwargsItems.from_hf_inputs(
426
processed_data,
427
self._get_mm_fields_config(processed_data, hf_processor_mm_kwargs),
428
)
429
252
- # Use overrides if provided; fallback to data-dependent hashing.
253
- with timing_ctx.record("get_mm_hashes"):
254
- mm_hashes = inputs.get_mm_hashes(self.info.model_id)
255
-
430
return mm_input(
431
prompt_token_ids=prompt_ids,
432
mm_kwargs=mm_kwargs,
@@ -364,7 +538,79 @@ class MultiModalMixin(SupportsMultiModal, SupportsMRoPE):
538
539
return LanguageModel(self)
540
367
- def embed_multimodal(self, **kwargs):
541
+ def get_mm_mapping(self) -> MultiModelKeys:
542
+ """
543
+ Get the module prefix in multimodal models
544
+ """
545
+ for name in ("language_model", "text_model"):
546
+ if getattr(self.model, name, None) is not None:
547
+ return MultiModelKeys.from_string_field(language_model=f"model.{name}")
548
+ raise ValueError(
549
+ "Could not locate the language model submodule for LoRA support"
550
+ )
551
+
552
+ def _split_embeddings(
553
+ self, embeddings: torch.Tensor, split_sizes: list[int]
554
+ ) -> list[torch.Tensor]:
555
+ total_expected = sum(split_sizes)
556
+
557
+ # Flatten to 2D: [total_tokens, hidden_dim]
558
+ if embeddings.ndim == 3:
559
+ embeddings = embeddings.view(-1, embeddings.shape[-1])
560
+
561
+ total_tokens = embeddings.shape[0]
562
+ if total_tokens == total_expected:
563
+ # Direct match: split_sizes are actual token counts
564
+ token_split_sizes = split_sizes
565
+ elif total_expected > 0 and total_tokens % total_expected == 0:
566
+ # Uniform expansion: each item expands to N tokens
567
+ tokens_per_item = total_tokens // total_expected
568
+ token_split_sizes = [s * tokens_per_item for s in split_sizes]
569
+ elif total_expected > 0:
570
+ # Mismatch (profiling with dummy data) - pad/truncate
571
+ if total_tokens == 0:
572
+ raise ValueError(
573
+ "Encoder returned empty embeddings. "
574
+ f"Expected {total_expected} tokens from "
575
+ f"split_sizes={split_sizes}"
576
+ )
577
+ if total_tokens < total_expected:
578
+ repeat_factor = (total_expected + total_tokens - 1) // total_tokens
579
+ embeddings = embeddings.repeat(repeat_factor, 1)
580
+ embeddings = embeddings[:total_expected]
581
+ token_split_sizes = split_sizes
582
+ else:
583
+ return []
584
+
585
+ return list(torch.split(embeddings, token_split_sizes, dim=0))
586
+
587
+ def _embed_audio(self, **kwargs) -> list[torch.Tensor] | None:
588
+ self.check_version("5.13.0", "audio models support")
589
+ input_features: torch.Tensor | None = kwargs.pop("input_features", None)
590
+ if input_features is None:
591
+ input_features = kwargs.pop("input_values", None)
592
+ if input_features is None:
593
+ return None
594
+
595
+ num_audio_tokens = kwargs.pop("num_audio_tokens")
596
+ kwargs.pop("token_type_ids", None)
597
+ kwargs.pop("mm_token_type_ids", None)
598
+
599
+ context = nullcontext()
600
+ if current_platform.is_rocm():
601
+ context = torch.nn.attention.sdpa_kernel(
602
+ backends=[torch.nn.attention.SDPBackend.MATH]
603
+ )
604
+ with context:
605
+ audio_output = self.model.get_audio_features(
606
+ input_features, return_dict=True, **kwargs
607
+ )
608
+ audio_embeddings = audio_output.pooler_output
609
+
610
+ split_sizes = num_audio_tokens.flatten().tolist()
611
+ return self._split_embeddings(audio_embeddings, split_sizes)
612
+
613
+ def _embed_vision(self, **kwargs) -> list[torch.Tensor] | torch.Tensor | None:
614
pixel_values: torch.Tensor | None = kwargs.pop("pixel_values", None)
615
image_embeds: torch.Tensor | None = kwargs.pop("image_embeds", None)
616
# Model might use `image_patches` instead of `pixel_values`
@@ -379,84 +625,57 @@ class MultiModalMixin(SupportsMultiModal, SupportsMRoPE):
625
626
num_image_patches = kwargs.pop("num_image_patches")
627
382
- if pixel_values is not None:
628
+ context = nullcontext()
629
+ if current_platform.is_rocm():
630
# ROCm: Force math SDP backend for vision encoder to avoid accuracy issues
631
# with flash_sdp and mem_efficient_sdp
385
- if current_platform.is_rocm():
386
- # TODO: [ROCm] Fix accuracy issues with flash backend
387
- logger.debug(
388
- "ROCm platform detected. Forcing math SDP backend "
389
- "for vision encoder. Currently ROCm platform has "
390
- "accuracy issues with `flash_sdp` and"
391
- "`mem_efficient_sdp` backends. See issue: "
392
- "https://github.com/vllm-project/vllm/issues/30167"
393
- )
394
- with torch.nn.attention.sdpa_kernel(
395
- backends=[torch.nn.attention.SDPBackend.MATH]
396
- ):
397
- vision_embeddings = self.model.get_image_features(
398
- pixel_values, **kwargs
399
- )
400
- else:
401
- vision_embeddings = self.model.get_image_features(
402
- pixel_values, **kwargs
403
- )
632
+ # TODO: [ROCm] Fix accuracy issues with flash backend
633
+ logger.debug(
634
+ "ROCm platform detected. Forcing math SDP backend "
635
+ "for vision encoder. Currently ROCm platform has "
636
+ "accuracy issues with `flash_sdp` and"
637
+ "`mem_efficient_sdp` backends. See issue: "
638
+ "https://github.com/vllm-project/vllm/issues/30167"
639
+ )
640
+ context = torch.nn.attention.sdpa_kernel(
641
+ backends=[torch.nn.attention.SDPBackend.MATH]
642
+ )
643
+ with context:
644
+ vision_embeddings = self.model.get_image_features(pixel_values, **kwargs)
645
405
- # Transformers `v5`, `self.get_image_features` returns a tuple
406
- # containing the features and optionally attentions/hidden_states
407
- # After v5 is settled, we can enable qwen3-vl with several outputs
408
- # from `self.get_image_features`
409
- if isinstance(vision_embeddings, tuple):
410
- vision_embeddings = vision_embeddings[0]
411
- elif isinstance(vision_embeddings, dict):
412
- vision_embeddings = vision_embeddings.pooler_output
413
-
414
- if isinstance(vision_embeddings, torch.Tensor):
415
- split_sizes = num_image_patches.flatten().tolist()
416
- total_patches = sum(split_sizes)
417
-
418
- # Flatten to 2D: [total_tokens, hidden_dim]
419
- if vision_embeddings.ndim == 3:
420
- vision_embeddings = vision_embeddings.view(
421
- -1, vision_embeddings.shape[-1]
422
- )
646
+ # Transformers `v5`, `self.get_image_features` returns a tuple
647
+ # containing the features and optionally attentions/hidden_states
648
+ # After v5 is settled, we can enable qwen3-vl with several outputs
649
+ # from `self.get_image_features`
650
+ if isinstance(vision_embeddings, tuple):
651
+ vision_embeddings = vision_embeddings[0]
652
+ elif isinstance(vision_embeddings, dict):
653
+ vision_embeddings = vision_embeddings.pooler_output
654
424
- total_tokens = vision_embeddings.shape[0]
425
- if total_tokens == total_patches:
426
- # Direct match: num_image_patches are actual token counts
427
- # (e.g., Qwen2.5-VL style)
428
- token_split_sizes = split_sizes
429
- elif total_patches > 0 and total_tokens % total_patches == 0:
430
- # Uniform expansion: each patch expands to N tokens
431
- # (e.g., Idefics3 style)
432
- tokens_per_patch = total_tokens // total_patches
433
- token_split_sizes = [s * tokens_per_patch for s in split_sizes]
434
- elif total_patches > 0:
435
- # Mismatch (profiling with dummy data) - pad/truncate
436
- if total_tokens == 0:
437
- raise ValueError(
438
- "Vision encoder returned empty embeddings. "
439
- f"Expected {total_patches} patches from "
440
- f"num_image_patches={split_sizes}"
441
- )
442
- if total_tokens < total_patches:
443
- repeat_factor = (
444
- total_patches + total_tokens - 1
445
- ) // total_tokens
446
- vision_embeddings = vision_embeddings.repeat(repeat_factor, 1)
447
- vision_embeddings = vision_embeddings[:total_patches]
448
- token_split_sizes = split_sizes
449
- else:
450
- return []
655
+ if isinstance(vision_embeddings, torch.Tensor):
656
+ split_sizes = num_image_patches.flatten().tolist()
657
+ return self._split_embeddings(vision_embeddings, split_sizes)
658
452
- return list(torch.split(vision_embeddings, token_split_sizes, dim=0))
659
+ return vision_embeddings
660
454
- return vision_embeddings
455
- else:
456
- logger.debug(
457
- "No pixel values or image embeddings provided for multimodal embedding."
458
- )
459
- return None
661
+ def embed_multimodal(self, **kwargs):
662
+ embeddings: tuple[torch.Tensor, ...] = ()
663
+ if "input_features" in kwargs or "input_values" in kwargs:
664
+ audio_embeddings = self._embed_audio(**kwargs)
665
+ if audio_embeddings is not None:
666
+ embeddings += tuple(audio_embeddings)
667
+ if (
668
+ "pixel_values" in kwargs
669
+ or "image_embeds" in kwargs
670
+ or "image_patches" in kwargs
671
+ ):
672
+ vision_embeddings = self._embed_vision(**kwargs)
673
+ if vision_embeddings is not None:
674
+ if isinstance(vision_embeddings, torch.Tensor):
675
+ embeddings += (vision_embeddings,)
676
+ else:
677
+ embeddings += tuple(vision_embeddings)
678
+ return embeddings
679
680
def get_mrope_input_positions(
681
self,