[3/N] Harden Transformers modelling backend multi-modal path (#51827)

Signed-off-by: Harry Mellor <19981378+hmellor@users.noreply.github.com>

Harry Mellor committed Aug 20, 2026 at 11:24 UTC 6e85feb1b7592e2bb73ff9227b2e529b8a270ec2
4 files changed +838 -161
tests/models/multimodal/processing/test_transformers_audio.py
+31 -31
@@ -4,8 +4,11 @@ import numpy as np
4 import pytest
5
6 from vllm.config import ModelConfig
7 +from vllm.model_executor.models.transformers.multimodal import LegacyMultiModalProcessor
8 from vllm.multimodal import MULTIMODAL_REGISTRY
9
10 +from .transformers_backend import PROCESSOR_CLASSES, create_processor
11 +
12 AUDIO_MODEL_SETTINGS = {
13 "ibm-granite/granite-speech-3.3-2b": {
14 "prompt": (
@@ -53,6 +56,7 @@ AUDIO_MODEL_SETTINGS = {
56 }
57
58
59 +@pytest.mark.parametrize("processor_cls", PROCESSOR_CLASSES)
60 @pytest.mark.parametrize(
61 "model_id",
62 [
@@ -61,12 +65,12 @@ AUDIO_MODEL_SETTINGS = {
65 pytest.param(
66 "mistralai/Voxtral-Mini-3B-2507",
67 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.",
68 + reason="Voxtral's mistral_common processor does not compose with "
69 + "the Transformers modelling backend. Loading it currently fails "
70 + "outright, because MistralCommonBackend.from_pretrained rejects "
71 + "the kwargs vLLM passes, and it implements no "
72 + "`replace_audio_token`, so it would report no replacement "
73 + "offsets. Both fixes belong in mistral_common or transformers.",
74 strict=False,
75 ),
76 ),
@@ -74,15 +78,10 @@ AUDIO_MODEL_SETTINGS = {
78 "zai-org/GLM-ASR-Nano-2512",
79 ],
80 )
77 -def test_audio_multimodal_processor(model_id):
81 +def test_audio_multimodal_processor(model_id, processor_cls):
82 settings = AUDIO_MODEL_SETTINGS[model_id]
83
80 - model_config = ModelConfig(
81 - model=model_id,
82 - model_impl="transformers",
83 - )
84 -
85 - mm_processor = MULTIMODAL_REGISTRY.create_processor(model_config)
84 + mm_processor = create_processor(model_id, processor_cls)
85
86 audio = np.zeros(16000, dtype=np.float32)
87 mm_data = {"audio": (audio, 16000)}
@@ -113,10 +112,13 @@ def test_audio_multimodal_processor(model_id):
112 )
113
114
116 -def _process_granite_speech(separator: str):
115 +@pytest.mark.parametrize("processor_cls", PROCESSOR_CLASSES)
116 +@pytest.mark.parametrize("separator", [" and ", ""])
117 +def test_audio_multiple_inputs(separator, processor_cls):
118 + """Multiple audios per prompt are each detected as a separate placeholder
119 + and multi-modal item by the Transformers modelling backend."""
120 model_id = "ibm-granite/granite-speech-3.3-2b"
118 - model_config = ModelConfig(model=model_id, model_impl="transformers")
119 - mm_processor = MULTIMODAL_REGISTRY.create_processor(model_config)
121 + mm_processor = create_processor(model_id, processor_cls)
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.
@@ -126,17 +128,21 @@ def _process_granite_speech(separator: str):
128 )
129 audios = [np.zeros(16000, dtype=np.float32), np.zeros(24000, dtype=np.float32)]
130
129 - return mm_processor(
130 - prompt=prompt,
131 - mm_items=mm_processor.info.parse_mm_data({"audio": audios}),
132 - hf_processor_mm_kwargs={},
133 - )
131 + def process():
132 + return mm_processor(
133 + prompt=prompt,
134 + mm_items=mm_processor.info.parse_mm_data({"audio": audios}),
135 + hf_processor_mm_kwargs={},
136 + )
137
138 + # The legacy path reads placeholders off contiguous runs of the audio token, so
139 + # it cannot tell adjacent ones apart and says so instead of merging them
140 + if processor_cls is LegacyMultiModalProcessor and not separator:
141 + with pytest.raises(ValueError, match="Separate them in the prompt"):
142 + process()
143 + return
144
136 -def test_audio_multiple_inputs():
137 - """Multiple audios per prompt are each detected as a separate placeholder
138 - and multi-modal item by the Transformers modelling backend."""
139 - result = _process_granite_speech(separator=" and ")
145 + result = process()
146
147 assert len(result["mm_placeholders"]["audio"]) == 2
148 assert len(result["mm_kwargs"]["audio"]) == 2
@@ -167,9 +173,3 @@ def test_unclaimed_fields_warn_rather_than_raise():
173
174 assert owned["audio"] == ["input_features"]
175 assert owned["image"] == []
170 -
171 -
172 -def test_audio_adjacent_inputs():
173 - """Adjacent audios are rejected rather than silently merged into one placeholder."""
174 - with pytest.raises(ValueError, match="told apart"):
175 - _process_granite_speech(separator="")
tests/models/multimodal/processing/test_transformers_image.py
+228 -19
@@ -1,20 +1,27 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from unittest.mock import patch
4 +
5 import pytest
6
7 from vllm.assets.image import ImageAsset
6 -from vllm.config import ModelConfig
7 -from vllm.multimodal import MULTIMODAL_REGISTRY
8 +from vllm.model_executor.models.transformers.multimodal import (
9 + LegacyMultiModalProcessor,
10 + OffsetsMultiModalProcessor,
11 +)
12
13 +from .transformers_backend import (
14 + PROCESSOR_CLASSES,
15 + create_cached_processor,
16 + create_processor,
17 + offsets_only,
18 +)
19
10 -@pytest.mark.parametrize("model_id", ["llava-hf/llava-onevision-qwen2-0.5b-ov-hf"])
11 -def test_multimodal_processor(model_id):
12 - model_config = ModelConfig(
13 - model=model_id,
14 - model_impl="transformers",
15 - )
20
17 - mm_processor = MULTIMODAL_REGISTRY.create_processor(model_config)
21 +@pytest.mark.parametrize("processor_cls", PROCESSOR_CLASSES)
22 +@pytest.mark.parametrize("model_id", ["llava-hf/llava-onevision-qwen2-0.5b-ov-hf"])
23 +def test_multimodal_processor(model_id, processor_cls):
24 + mm_processor = create_processor(model_id, processor_cls)
25
26 image_pil = ImageAsset("cherry_blossom").pil_image
27 mm_data = {"image": image_pil}
@@ -56,10 +63,9 @@ def test_multimodal_processor(model_id):
63 )
64
65
59 -def _process_two_images(separator: str):
66 +def _process_two_images(processor_cls, separator: str):
67 model_id = "llava-hf/llava-onevision-qwen2-0.5b-ov-hf"
61 - model_config = ModelConfig(model=model_id, model_impl="transformers")
62 - mm_processor = MULTIMODAL_REGISTRY.create_processor(model_config)
68 + mm_processor = create_processor(model_id, processor_cls)
69
70 image = ImageAsset("cherry_blossom").pil_image
71 prompt = (
@@ -74,28 +80,147 @@ def _process_two_images(separator: str):
80 )
81
82
77 -def test_image_multiple_inputs():
83 +@pytest.mark.parametrize("processor_cls", PROCESSOR_CLASSES)
84 +def test_image_multiple_inputs(processor_cls):
85 """Multiple images per prompt are each detected as a separate placeholder
86 and multi-modal item by the Transformers modelling backend."""
80 - result = _process_two_images(separator="\n and ")
87 + result = _process_two_images(processor_cls, separator="\n and ")
88
89 assert len(result["mm_placeholders"]["image"]) == 2
90 assert len(result["mm_kwargs"]["image"]) == 2
91
92
86 -def test_image_adjacent_inputs():
93 +@pytest.mark.parametrize("processor_cls", PROCESSOR_CLASSES)
94 +def test_image_adjacent_inputs(processor_cls):
95 """Adjacent images stay separate placeholders rather than merging into one."""
88 - result = _process_two_images(separator="")
96 + result = _process_two_images(processor_cls, separator="")
97
98 assert len(result["mm_placeholders"]["image"]) == 2
99 assert len(result["mm_kwargs"]["image"]) == 2
100
101
94 -def test_text_only_prompt():
102 +@pytest.mark.parametrize("processor_cls", PROCESSOR_CLASSES)
103 +def test_batch_padding_removed_from_image_items(processor_cls):
104 + """Emu3 pads every image up to the largest in the batch, which would leave an
105 + item's data dependent on what it was processed with and so uncacheable."""
106 + mm_processor = create_processor("BAAI/Emu3-Chat-hf", processor_cls)
107 + image_token = mm_processor.info.get_hf_processor().image_token
108 +
109 + images = [
110 + ImageAsset("cherry_blossom").pil_image,
111 + ImageAsset("cherry_blossom").pil_image.resize((256, 1024)),
112 + ]
113 + result = mm_processor(
114 + prompt=f"{image_token} and {image_token}",
115 + mm_items=mm_processor.info.parse_mm_data({"image": images}),
116 + hf_processor_mm_kwargs={},
117 + )
118 +
119 + items = result["mm_kwargs"]["image"]
120 + shapes = set()
121 + for item in items:
122 + height, width = item["image_sizes"].data.flatten().tolist()
123 + pixel_values = item["pixel_values"].data
124 + assert tuple(pixel_values.shape[-2:]) == (height, width)
125 + shapes.add(tuple(pixel_values.shape))
126 +
127 + # Both images would have been padded to a common shape had they been kept
128 + assert len(shapes) == 2
129 +
130 +
131 +def _process_one_gemma3_image(processor_cls):
132 + mm_processor = create_processor("google/gemma-3-4b-it", processor_cls)
133 + hf_processor = mm_processor.info.get_hf_processor()
134 + result = mm_processor(
135 + prompt=f"{hf_processor.boi_token} What is this?",
136 + mm_items=mm_processor.info.parse_mm_data(
137 + {"image": ImageAsset("cherry_blossom").pil_image}
138 + ),
139 + hf_processor_mm_kwargs={},
140 + )
141 + return hf_processor, result
142 +
143 +
144 +@offsets_only
145 +def test_non_embedding_tokens_excluded_from_placeholders():
146 + """Gemma3 wraps each image in text that carries no embeddings, which must be
147 + inside the placeholder range but masked out of it."""
148 + _, result = _process_one_gemma3_image(OffsetsMultiModalProcessor)
149 +
150 + (placeholder,) = result["mm_placeholders"]["image"]
151 + assert placeholder.is_embed is not None
152 + assert 0 < int(placeholder.is_embed.sum()) < placeholder.length
153 +
154 +
155 +def test_legacy_placeholders_hold_only_image_tokens():
156 + """The legacy path spans whatever `mm_token_type_ids` attributes to the image,
157 + which for Gemma3 excludes the text wrapping it, unlike the replacement the offsets
158 + path spans. Gemma3 is also the sharp case for the mask: its `image_token_id` is the
159 + marker in the unexpanded prompt, not the token the expansion repeats."""
160 + hf_processor, result = _process_one_gemma3_image(LegacyMultiModalProcessor)
161 +
162 + (placeholder,) = result["mm_placeholders"]["image"]
163 + assert placeholder.length == hf_processor.image_seq_length
164 + prompt_ids = result["prompt_token_ids"]
165 + covered = prompt_ids[placeholder.offset : placeholder.offset + placeholder.length]
166 + assert set(covered) == {hf_processor.tokenizer.image_token_id}
167 +
168 +
169 +@pytest.mark.parametrize("processor_cls", PROCESSOR_CLASSES)
170 +def test_tokens_structuring_an_image_are_masked_not_dropped(processor_cls):
171 + """SmolVLM splits each image into tiles introduced by tokens carrying no
172 + embeddings. Those belong inside the placeholder and masked out, because the token
173 + count the processor reports is over the whole span. Idefics3 also refuses a prompt
174 + holding `<image>` when no images are passed, which is how the offsets path has to
175 + tokenize it before splicing in the expansion."""
176 + mm_processor = create_processor(
177 + "HuggingFaceTB/SmolVLM-256M-Instruct", processor_cls
178 + )
179 + result = mm_processor(
180 + prompt="<image>What is this?",
181 + mm_items=mm_processor.info.parse_mm_data(
182 + {"image": ImageAsset("cherry_blossom").pil_image}
183 + ),
184 + hf_processor_mm_kwargs={},
185 + )
186 +
187 + (placeholder,) = result["mm_placeholders"]["image"]
188 + assert placeholder.is_embed is not None
189 + assert 0 < int(placeholder.is_embed.sum()) < placeholder.length
190 +
191 +
192 +@offsets_only
193 +def test_missing_replacement_offsets_names_the_processor():
194 + """A processor that reports no replacement offsets cannot be served, which must
195 + be said plainly rather than surfacing later as a field config mismatch."""
196 + model_id = "llava-hf/llava-onevision-qwen2-0.5b-ov-hf"
197 + mm_processor = create_processor(model_id, OffsetsMultiModalProcessor)
198 + hf_processor_cls = type(mm_processor.info.get_hf_processor())
199 + hf_call = hf_processor_cls.__call__
200 +
201 + def without_offsets(self, *args, **kwargs):
202 + hf_inputs = hf_call(self, *args, **kwargs)
203 + hf_inputs.pop("text_replacement_offsets", None)
204 + return hf_inputs
205 +
206 + with (
207 + patch.object(hf_processor_cls, "__call__", without_offsets),
208 + pytest.raises(ValueError, match="LlavaOnevisionProcessor returned no"),
209 + ):
210 + mm_processor(
211 + prompt="<image>\nWhat is the content of this image?",
212 + mm_items=mm_processor.info.parse_mm_data(
213 + {"image": ImageAsset("cherry_blossom").pil_image}
214 + ),
215 + hf_processor_mm_kwargs={},
216 + )
217 +
218 +
219 +@pytest.mark.parametrize("processor_cls", PROCESSOR_CLASSES)
220 +def test_text_only_prompt(processor_cls):
221 """An image model still accepts a prompt with no images."""
222 model_id = "llava-hf/llava-onevision-qwen2-0.5b-ov-hf"
97 - model_config = ModelConfig(model=model_id, model_impl="transformers")
98 - mm_processor = MULTIMODAL_REGISTRY.create_processor(model_config)
223 + mm_processor = create_processor(model_id, processor_cls)
224
225 result = mm_processor(
226 prompt="<|im_start|>user Hello!<|im_end|><|im_start|>assistant\n",
@@ -105,3 +230,87 @@ def test_text_only_prompt():
230
231 assert len(result["prompt_token_ids"]) > 0
232 assert not result["mm_placeholders"]
233 +
234 +
235 +@offsets_only
236 +def test_repeated_image_hits_the_processor_cache():
237 + """Check that mm caching is actually working."""
238 + mm_processor, cache = create_cached_processor(
239 + "llava-hf/llava-onevision-qwen2-0.5b-ov-hf", OffsetsMultiModalProcessor
240 + )
241 + image = ImageAsset("cherry_blossom").pil_image
242 +
243 + def process():
244 + return mm_processor(
245 + prompt="<image>\nWhat is this?",
246 + mm_items=mm_processor.info.parse_mm_data({"image": image}),
247 + hf_processor_mm_kwargs={},
248 + )
249 +
250 + first, second = process(), process()
251 +
252 + assert cache.make_stats().hits > 0
253 + assert first["prompt_token_ids"] == second["prompt_token_ids"]
254 + assert first["mm_hashes"] == second["mm_hashes"]
255 +
256 +
257 +@offsets_only
258 +@pytest.mark.parametrize(
259 + ("model_id", "prompt"),
260 + [
261 + ("llava-hf/llava-onevision-qwen2-0.5b-ov-hf", "<image>\nWhat is this?"),
262 + ("google/gemma-3-4b-it", "<start_of_image> What is this?"),
263 + ("HuggingFaceTB/SmolVLM-256M-Instruct", "<image>What is this?"),
264 + pytest.param(
265 + "BAAI/Emu3-Chat-hf",
266 + "<image> and more text",
267 + marks=pytest.mark.xfail(
268 + reason="Emu3Processor prepends its BOS token only when images are "
269 + "passed, so the unexpanded prompt the offsets path tokenizes never "
270 + "gets one. Fixed by huggingface/transformers#47924, unreleased.",
271 + strict=False,
272 + ),
273 + ),
274 + ],
275 +)
276 +def test_spliced_prompt_matches_hf_expansion(model_id, prompt):
277 + """The offsets path splices the expansion into a prompt tokenized without any
278 + multi-modal data, so its token ids have to come out the same as the ones the HF
279 + processor produces itself, which is what the legacy path returns."""
280 + prompt_ids = []
281 + for processor_cls in (LegacyMultiModalProcessor, OffsetsMultiModalProcessor):
282 + mm_processor = create_processor(model_id, processor_cls)
283 + prompt_ids.append(
284 + mm_processor(
285 + prompt=prompt,
286 + mm_items=mm_processor.info.parse_mm_data(
287 + {"image": ImageAsset("cherry_blossom").pil_image}
288 + ),
289 + hf_processor_mm_kwargs={},
290 + )["prompt_token_ids"]
291 + )
292 +
293 + legacy_ids, offsets_ids = prompt_ids
294 + assert legacy_ids == offsets_ids
295 +
296 +
297 +@pytest.mark.parametrize("processor_cls", PROCESSOR_CLASSES)
298 +def test_nested_image_fields_split_per_image(processor_cls):
299 + """Idefics3 returns image fields with a leading batch dimension, putting the rows
300 + belonging to each image one dimension further in. Slicing the batch dimension
301 + instead handed the first image every row and the second an empty tensor."""
302 + mm_processor = create_processor(
303 + "HuggingFaceTB/SmolVLM-256M-Instruct", processor_cls
304 + )
305 + image = ImageAsset("cherry_blossom").pil_image
306 + result = mm_processor(
307 + prompt="<image> and <image>",
308 + mm_items=mm_processor.info.parse_mm_data({"image": [image, image]}),
309 + hf_processor_mm_kwargs={},
310 + )
311 +
312 + items = result["mm_kwargs"]["image"]
313 + assert len(items) == 2
314 + for item in items:
315 + pixel_values = item["pixel_values"].data
316 + assert pixel_values.shape[1] == int(item["num_image_patches"].data)
tests/models/multimodal/processing/transformers_backend.py new
+51
@@ -0,0 +1,51 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Helpers for testing both processors of the Transformers modelling backend."""
4 +
5 +import pytest
6 +
7 +from vllm.config import ModelConfig
8 +from vllm.model_executor.models.transformers.multimodal import (
9 + LegacyMultiModalProcessor,
10 + MultiModalDummyInputsBuilder,
11 + MultiModalProcessingInfo,
12 + MultiModalProcessor,
13 + OffsetsMultiModalProcessor,
14 +)
15 +from vllm.multimodal.cache import MultiModalProcessorOnlyCache
16 +from vllm.multimodal.processing import InputProcessingContext
17 +from vllm.tokenizers.registry import cached_tokenizer_from_config
18 +
19 +offsets_only = pytest.mark.skipif(
20 + MultiModalProcessor is not OffsetsMultiModalProcessor,
21 + reason="Replacement offsets are only used from transformers 5.15.0 onwards",
22 +)
23 +
24 +PROCESSOR_CLASSES = [
25 + pytest.param(LegacyMultiModalProcessor, id="legacy"),
26 + pytest.param(OffsetsMultiModalProcessor, id="offsets", marks=offsets_only),
27 +]
28 +
29 +
30 +def create_processor(model_id: str, processor_cls):
31 + """Build a processor directly, because the registry only ever builds the one the
32 + installed transformers version selects, leaving the other path untested."""
33 + model_config = ModelConfig(model=model_id, model_impl="transformers")
34 + ctx = InputProcessingContext(
35 + model_config, cached_tokenizer_from_config(model_config)
36 + )
37 + info = MultiModalProcessingInfo(ctx)
38 + return processor_cls(info, MultiModalDummyInputsBuilder(info))
39 +
40 +
41 +def create_cached_processor(model_id: str, processor_cls):
42 + """Build a processor backed by a real multi-modal processor cache, and hand the
43 + cache back so a test can check it was actually used."""
44 + model_config = ModelConfig(model=model_id, model_impl="transformers")
45 + model_config.multimodal_config.mm_processor_cache_gb = 4
46 + ctx = InputProcessingContext(
47 + model_config, cached_tokenizer_from_config(model_config)
48 + )
49 + info = MultiModalProcessingInfo(ctx)
50 + cache = MultiModalProcessorOnlyCache(model_config)
51 + return processor_cls(info, MultiModalDummyInputsBuilder(info), cache=cache), cache
vllm/model_executor/models/transformers/multimodal.py
+528 -111
@@ -22,6 +22,8 @@ from contextlib import ExitStack, contextmanager
22 from typing import TYPE_CHECKING, Any
23
24 import torch
25 +import transformers
26 +from packaging.version import Version
27
28 from vllm.compilation.decorators import should_torch_compile_mm_encoder
29 from vllm.config.utils import getattr_iter
@@ -50,7 +52,9 @@ from vllm.multimodal.processing import (
52 BaseMultiModalProcessor,
53 BaseProcessingInfo,
54 ProcessorInputs,
55 + PromptReplacement,
56 PromptUpdate,
57 + PromptUpdateDetails,
58 TimingContext,
59 )
60 from vllm.sequence import IntermediateTensors
@@ -65,6 +69,12 @@ if TYPE_CHECKING:
69 logger = init_logger(__name__)
70
71 _MODALITY_TO_TOKEN_TYPE_ID = {"image": 1, "video": 2, "audio": 3}
72 +_MODALITY_SIZE_KEYS = {"audio": "num_audio_tokens", "image": "num_image_patches"}
73 +
74 +
75 +def _get_embed_token_id(replacement_ids: torch.Tensor) -> int:
76 + """The token an expansion repeats is the one holding the embeddings."""
77 + return int(replacement_ids.mode().values)
78
79
80 class MultiModalProcessingInfo(BaseProcessingInfo):
@@ -94,21 +104,6 @@ class MultiModalProcessingInfo(BaseProcessingInfo):
104 )
105 return modalities
106
97 - def _get_audio_token_id(self) -> int:
98 - processor = self.get_hf_processor()
99 - if hasattr(processor, "audio_token_id"):
100 - return processor.audio_token_id
101 - config = self.get_hf_config()
102 - val = getattr_iter(config, ("audio_token_id", "audio_token_index"))
103 - if val is not None:
104 - return val
105 - if hasattr(processor, "audio_token"):
106 - tokenizer = self.get_tokenizer()
107 - vocab = tokenizer.get_vocab()
108 - if processor.audio_token in vocab:
109 - return vocab[processor.audio_token]
110 - raise ValueError("Cannot find audio_token_id on processor or model config")
111 -
107 def _get_audio_sampling_rate(self) -> float:
108 sub = self._get_audio_processor()
109 if sub is not None and hasattr(sub, "sampling_rate"):
@@ -169,7 +164,7 @@ class MultiModalDummyInputsBuilder(BaseDummyInputsBuilder[MultiModalProcessingIn
164 if self.info._is_audio_model() and (num_audios := mm_counts.get("audio", 0)):
165 processor = self.info.get_hf_processor()
166 audio_token = getattr(processor, "audio_token", "")
172 - # Separated so that `_apply_audio` can tell the placeholders apart
167 + # Separated so that adjacent placeholders stay distinguishable
168 text += " ".join([audio_token] * num_audios)
169 if self.info._is_image_model() and (num_images := mm_counts.get("image", 0)):
170 processor = self.info.get_hf_processor()
@@ -210,23 +205,26 @@ class MultiModalDummyInputsBuilder(BaseDummyInputsBuilder[MultiModalProcessingIn
205 return data
206
207
213 -class MultiModalProcessor(BaseMultiModalProcessor[MultiModalProcessingInfo]):
214 - def _get_prompt_updates(
208 +class _MultiModalProcessorBase(BaseMultiModalProcessor[MultiModalProcessingInfo]):
209 + """Processing common to both Transformers backend processors: calling the HF
210 + processor, sizing images, and attributing its outputs to a modality.
211 +
212 + Subclasses add the strategy for locating placeholders in the prompt.
213 + """
214 +
215 + def _get_hf_mm_data(
216 self,
217 mm_items: MultiModalDataItems,
217 - hf_processor_mm_kwargs: Mapping[str, object],
218 - out_mm_kwargs: MultiModalKwargsItems,
219 - ) -> Sequence[PromptUpdate]:
220 - """No updates: `apply` locates placeholders via `mm_token_type_ids` instead.
221 -
222 - HF processors have no generic contract for the token sequence they insert,
223 - so it cannot be expressed as a `PromptUpdate`. Returning nothing is only
224 - safe because `apply` is overridden; the base class would reject it.
225 - """
226 - return []
218 + ) -> tuple[Mapping[str, object], Mapping[str, object]]:
219 + """Rename the parser's `audios` key to the `audio` argument HF audio
220 + processors take."""
221 + processor_data, passthrough_data = super()._get_hf_mm_data(mm_items)
222 + if self.info._is_audio_model() and "audios" in processor_data:
223 + processor_data["audio"] = processor_data.pop("audios")
224 + return processor_data, passthrough_data
225
226 def _get_modality_field_names(self, modality: str) -> set[str]:
229 - """Field names the sub-processor for `modality` produces."""
227 + """Names of the fields the sub-processor for `modality` produces."""
228 # TODO: use else branch only once huggingface/transformers#44394 lands.
229 if modality == "audio":
230 sub_processor = self.info._get_audio_processor()
@@ -273,6 +271,18 @@ class MultiModalProcessor(BaseMultiModalProcessor[MultiModalProcessingInfo]):
271
272 return owned
273
274 + def _get_slice_dim(self, data: Any, total_rows: int) -> int:
275 + """Which dimension of a field holds the rows belonging to each item.
276 +
277 + Some processors (e.g., Idefics3) return image fields with a leading batch
278 + dimension, putting the rows one dimension further in.
279 + """
280 + if not isinstance(data, torch.Tensor) or data.ndim < 2:
281 + return 0
282 + if data.shape[0] != total_rows and data.shape[1] == total_rows:
283 + return 1
284 + return 0
285 +
286 def _get_mm_fields_config(
287 self,
288 hf_inputs: "BatchFeature",
@@ -281,29 +291,50 @@ class MultiModalProcessor(BaseMultiModalProcessor[MultiModalProcessingInfo]):
291 # HF Processors always return a mask but vLLM doesn't need it
292 hf_inputs.pop("attention_mask", None)
293
284 - # Written by `_apply_audio`/`_apply_vision`; absent if the modality had no items
294 + # Absent if the modality had no items
295 sizes = {
286 - "audio": hf_inputs.get("num_audio_tokens"),
287 - "image": hf_inputs.get("num_image_patches"),
296 + modality: hf_inputs.get(key)
297 + for modality, key in _MODALITY_SIZE_KEYS.items()
298 }
299 modalities = [m for m, size in sizes.items() if size is not None]
300
291 - size_keys = {"num_audio_tokens", "num_image_patches"}
292 - keys = [key for key in hf_inputs if key not in size_keys]
301 + # Keys we wrote ourselves, rather than ones a sub-processor produced
302 + own_keys = set(_MODALITY_SIZE_KEYS.values()) | {
303 + f"{modality}_replacement_{suffix}"
304 + for modality in modalities
305 + for suffix in ("ids", "sizes")
306 + }
307 + keys = [key for key in hf_inputs if key not in own_keys]
308 owned = self._partition_keys_by_modality(keys, modalities)
309
310 + # Un-padded fields are already one entry per item, so index rather than slice
311 mm_fields: dict[str, MultiModalFieldConfig] = {
296 - key: MultiModalFieldConfig.flat_from_sizes(modality, sizes[modality])
312 + key: MultiModalFieldConfig.batched(modality)
313 + if modality == "audio" or isinstance(hf_inputs[key], list)
314 + else MultiModalFieldConfig.flat_from_sizes(
315 + modality,
316 + sizes[modality],
317 + dim=self._get_slice_dim(hf_inputs[key], int(sizes[modality].sum())),
318 + )
319 for modality in modalities
320 for key in owned[modality]
321 }
322
301 - # Keep these as batched, as they always have batch size as first dim
302 - if "audio" in modalities:
303 - mm_fields["num_audio_tokens"] = MultiModalFieldConfig.batched(
304 - "audio", keep_on_cpu=True
323 + for modality in modalities:
324 + # One row per item, and only ever read on the CPU
325 + mm_fields[_MODALITY_SIZE_KEYS[modality]] = MultiModalFieldConfig.batched(
326 + modality, keep_on_cpu=True
327 )
328 + replacement_sizes = hf_inputs.get(f"{modality}_replacement_sizes")
329 + if replacement_sizes is not None:
330 + mm_fields[f"{modality}_replacement_ids"] = (
331 + MultiModalFieldConfig.flat_from_sizes(
332 + modality, replacement_sizes, keep_on_cpu=True
333 + )
334 + )
335 +
336 if "image" in modalities:
337 + # Always one row per item, whatever they describe
338 mm_fields["image_grid_thw"] = MultiModalFieldConfig.batched(
339 "image", keep_on_cpu=True
340 )
@@ -311,25 +342,154 @@ class MultiModalProcessor(BaseMultiModalProcessor[MultiModalProcessingInfo]):
342 mm_fields["video_grid_thw"] = MultiModalFieldConfig.batched(
343 "image", keep_on_cpu=True
344 )
314 - mm_fields["num_image_patches"] = MultiModalFieldConfig.batched(
315 - "image", keep_on_cpu=True
316 - )
345 +
346 return mm_fields
347
319 - def _get_hf_mm_data(
348 + def _call_hf_processor(
349 self,
321 - mm_items: MultiModalDataItems,
322 - ) -> tuple[Mapping[str, object], Mapping[str, object]]:
350 + prompt: str,
351 + mm_data: Mapping[str, object],
352 + mm_kwargs: Mapping[str, object],
353 + tok_kwargs: Mapping[str, object],
354 + ) -> "BatchFeature":
355 + """Run the HF processor and unpad inputs."""
356 + try:
357 + hf_inputs = super()._call_hf_processor(
358 + prompt, mm_data, mm_kwargs, tok_kwargs
359 + )
360 + except ValueError:
361 + if any(mm_data.values()):
362 + raise
363 + # Some processors reject a prompt holding placeholders with
364 + # no data to go with them, so tokenize it without them
365 + prompt_ids = self.info.get_tokenizer().encode(prompt, **tok_kwargs)
366 + hf_inputs = transformers.BatchFeature(
367 + dict(input_ids=[prompt_ids]), tensor_type="pt"
368 + )
369 + self._unpad_images(hf_inputs)
370 + self._unpad_audios(hf_inputs, mm_data, mm_kwargs, tok_kwargs)
371 + return hf_inputs
372 +
373 + def _unpad_audios(
374 + self,
375 + hf_inputs: "BatchFeature",
376 + mm_data: Mapping[str, object],
377 + mm_kwargs: Mapping[str, object],
378 + tok_kwargs: Mapping[str, object],
379 + ) -> None:
380 + """Replace the audio fields with each audio processed on its own.
381 +
382 + Processors pad every audio up to the longest in the call, which would leave
383 + an item's data dependent on what it was processed with. Unlike images,
384 + nothing in the output states how long each one really is, and processors
385 + pad a lone audio too, so the only way to know what an audio produces by
386 + itself is to process it by itself.
387 + """
388 + audios = mm_data.get("audio")
389 + if not audios or len({len(audio) for audio in audios}) == 1:
390 + return
391 +
392 + alone = [
393 + self.info.ctx.call_hf_processor(
394 + self.info.get_hf_processor(**mm_kwargs),
395 + dict(
396 + text=self.dummy_inputs.get_dummy_text({"audio": 1}), audio=[audio]
397 + ),
398 + dict(**mm_kwargs, **tok_kwargs),
399 + )
400 + for audio in audios
401 + ]
402 +
403 + for key in self._get_modality_field_names("audio"):
404 + if isinstance(hf_inputs.get(key), torch.Tensor):
405 + hf_inputs[key] = [output[key][0] for output in alone]
406 +
407 + def _unpad_images(self, hf_inputs: "BatchFeature") -> None:
408 + """Trim each image back to its own size when the processor padded them all
409 + to the largest in the batch.
410 +
411 + An image's data has to depend on nothing but that image, or the multi-modal
412 + processor cache would store it under that image's hash and later reuse it
413 + beside a different neighbour. Padding is re-applied when the encoder runs.
414 """
324 - In contrast to the base class, this method requests
325 - `return_mm_token_type_ids` and remaps the `audios` key to `audio` for
326 - audio models.
415 + pixel_values = hf_inputs.get("pixel_values")
416 + image_sizes = hf_inputs.get("image_sizes")
417 + if not isinstance(pixel_values, torch.Tensor):
418 + return
419 + if not isinstance(image_sizes, torch.Tensor):
420 + return
421 + if pixel_values.ndim != 4 or len(pixel_values) != len(image_sizes):
422 + return
423 +
424 + # The sizes describe the trailing dimensions only if the largest of them is
425 + # what the batch was padded up to. Otherwise they mean something else, as
426 + # in llava-onevision, where they are the sizes before any processing.
427 + maxima = image_sizes.max(dim=0).values
428 + if maxima.tolist() != list(pixel_values.shape[-2:]):
429 + return
430 +
431 + hf_inputs["pixel_values"] = [
432 + image[..., :height, :width]
433 + for image, (height, width) in zip(pixel_values, image_sizes.tolist())
434 + ]
435 +
436 +
437 +class LegacyMultiModalProcessor(_MultiModalProcessorBase):
438 + """Locates placeholders by searching the prompt the HF processor has already
439 + expanded for the tokens of each modality.
440 +
441 + Serves transformers versions with no `return_text_replacement_offsets`.
442 + Placeholders found this way cannot be rebuilt from an unexpanded prompt, so
443 + this processor overrides `apply` and gets no multi-modal processor cache.
444 + Remove it once `requirements/common.txt` requires `transformers>=5.15.0`.
445 + """
446 +
447 + def _get_prompt_updates(
448 + self,
449 + mm_items: MultiModalDataItems,
450 + hf_processor_mm_kwargs: Mapping[str, object],
451 + out_mm_kwargs: MultiModalKwargsItems,
452 + ) -> Sequence[PromptUpdate]:
453 + """Empty, because `apply` writes the placeholder ranges itself rather than
454 + deriving them from updates."""
455 + return []
456 +
457 + def _call_hf_processor(
458 + self,
459 + prompt: str,
460 + mm_data: Mapping[str, object],
461 + mm_kwargs: Mapping[str, object],
462 + tok_kwargs: Mapping[str, object],
463 + ) -> "BatchFeature":
464 + """Ask which modality owns each token of the expanded prompt, which is the
465 + only marking of the tokens an expansion adds around an item."""
466 + if any(mm_data.values()):
467 + mm_data = {**mm_data, "return_mm_token_type_ids": True}
468 + return super()._call_hf_processor(prompt, mm_data, mm_kwargs, tok_kwargs)
469 +
470 + def _get_mm_token_ids(self, modality: str) -> list[int]:
471 + """Token ids marking where `modality` sits in the prompt, which for some
472 + processors differ from the placeholder written into it.
473 +
474 + The expanded prompt is all this path has to go on, so it takes the
475 + processor at its word about which tokens belong to the modality.
476 """
328 - processor_data, passthrough_data = super()._get_hf_mm_data(mm_items)
329 - if self.info._is_audio_model() and "audios" in processor_data:
330 - processor_data["audio"] = processor_data.pop("audios")
331 - processor_data["return_mm_token_type_ids"] = True
332 - return processor_data, passthrough_data
477 + info = self.info
478 + processor = info.get_hf_processor()
479 + declared = getattr(processor, f"{modality}_token_ids", None) or ()
480 + if ids := [token_id for token_id in declared if token_id is not None]:
481 + return ids
482 + config = info.get_hf_config()
483 + names = (f"{modality}_token_id", f"{modality}_token_index")
484 + token_id = getattr(processor, names[0], getattr_iter(config, names))
485 + if token_id is None:
486 + token = getattr(processor, f"{modality}_token", None)
487 + token_id = info.get_tokenizer().get_vocab().get(token)
488 + if token_id is None:
489 + raise ValueError(
490 + f"Cannot find {modality}_token_id on processor or model config"
491 + )
492 + return [token_id]
493
494 def _apply_audio(
495 self,
@@ -337,12 +497,17 @@ class MultiModalProcessor(BaseMultiModalProcessor[MultiModalProcessingInfo]):
497 processed_data: "BatchFeature",
498 num_audios: int,
499 ) -> dict[str, list[PlaceholderRange]]:
340 - audio_token_id = self.info._get_audio_token_id()
500 + """Take each contiguous run of the audio token as one item's placeholder,
501 + and record how many tokens the run holds."""
502 + audio_token_ids = self._get_mm_token_ids("audio")
503 prompt_tensor = torch.tensor(prompt_ids)
342 - is_audio = prompt_tensor == audio_token_id
504 + is_audio = torch.isin(prompt_tensor, torch.tensor(audio_token_ids))
505
506 if not is_audio.any():
345 - return {}
507 + raise ValueError(
508 + f"{num_audios} audio item(s) were passed but the prompt "
509 + "contains no audio token. Add one placeholder per audio item."
510 + )
511
512 padded = torch.cat([torch.tensor([False]), is_audio, torch.tensor([False])])
513 transitions = padded.int().diff()
@@ -365,6 +530,23 @@ class MultiModalProcessor(BaseMultiModalProcessor[MultiModalProcessingInfo]):
530 processed_data["num_audio_tokens"] = lengths
531 return {"audio": ranges}
532
533 + def _get_num_multimodal_tokens(
534 + self,
535 + mm_items: MultiModalDataItems,
536 + hf_processor_mm_kwargs: Mapping[str, object],
537 + ) -> Mapping[str, list[int]]:
538 + """Ask the HF processor how many tokens and patches each image expands to."""
539 + processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
540 + images = mm_items.get_items("image", ImageProcessorItems)
541 + image_sizes = [
542 + (size.height, size.width)
543 + for size in map(images.get_image_size, range(len(images)))
544 + ]
545 + return processor._get_num_multimodal_tokens(
546 + image_sizes=image_sizes,
547 + **self.info.ctx.get_merged_mm_kwargs({}),
548 + )
549 +
550 def _apply_vision(
551 self,
552 prompt_ids: list[int],
@@ -373,47 +555,46 @@ class MultiModalProcessor(BaseMultiModalProcessor[MultiModalProcessingInfo]):
555 hf_processor_mm_kwargs: Mapping[str, object],
556 mm_token_type_ids: torch.Tensor | None,
557 ) -> dict[str, list[PlaceholderRange]]:
376 - # Placeholders can't be located without them, so give up rather than guess
377 - if mm_token_type_ids is None:
378 - return {}
379 -
558 + """Split the positions the processor marks as image into one placeholder per
559 + item, sized by the token count it reports for each image."""
560 hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
561 + if mm_token_type_ids is None:
562 + raise ValueError(
563 + f"{type(hf_processor).__name__} returned no `mm_token_type_ids`, so "
564 + "the Transformers modeling backend cannot locate the placeholder of "
565 + "each image."
566 + )
567
382 - # We can infer vLLM style placeholder from token type ids, if we split
383 - # it for each input `mm_data`.
384 - mm_positions = torch.where(mm_token_type_ids == 1)[1]
385 - images = mm_items.get_items("image", ImageProcessorItems)
386 - image_sizes = []
387 - for item_idx in range(len(images)):
388 - image_size = images.get_image_size(item_idx)
389 - image_sizes.append((image_size.height, image_size.width))
390 -
391 - mm_tokens_per_modality = hf_processor._get_num_multimodal_tokens(
392 - image_sizes=image_sizes,
393 - **self.info.ctx.get_merged_mm_kwargs({}),
568 + # These mark the tokens structuring an image as well as the image itself,
569 + # which is what the counts below are over
570 + mm_positions = torch.where(mm_token_type_ids[0] == 1)[0]
571 + mm_tokens_per_modality = self._get_num_multimodal_tokens(
572 + mm_items, hf_processor_mm_kwargs
573 )
574
575 mm_placeholders: dict[str, list[PlaceholderRange]] = {}
576 split_sizes = mm_tokens_per_modality["num_image_tokens"]
398 - if split_sizes:
399 - image_token_ids = getattr(hf_processor, "image_token_ids", None)
400 - if image_token_ids is None:
401 - # Transformers <5.10.0
402 - image_token_ids = [hf_processor.image_token_id]
403 - image_token_ids = torch.tensor(
404 - [i for i in image_token_ids if i is not None]
577 + if sum(split_sizes) != len(mm_positions):
578 + raise ValueError(
579 + f"The expanded prompt holds {len(mm_positions)} image token(s) but "
580 + f"{type(hf_processor).__name__} accounts for {sum(split_sizes)} "
581 + f"across {mm_items.get_count('image')} image item(s)."
582 )
583
407 - chunked_mm_positions = torch.split(mm_positions, split_sizes)
408 - mm_tokens = torch.tensor(prompt_ids)[mm_token_type_ids[0].bool()]
409 - chunked_mm_tokens = torch.split(mm_tokens, split_sizes)
584 + if split_sizes:
585 + image_token_ids = torch.tensor(self._get_mm_token_ids("image"))
586 + mm_tokens = torch.tensor(prompt_ids)[mm_positions]
587 ranges = [
588 PlaceholderRange(
589 offset=positions[0].item(),
590 length=positions.shape[0],
414 - is_embed=torch.isin(mm_tokens, image_token_ids),
591 + # Only some of the span carries embeddings
592 + is_embed=torch.isin(tokens, image_token_ids),
593 + )
594 + for positions, tokens in zip(
595 + torch.split(mm_positions, split_sizes),
596 + torch.split(mm_tokens, split_sizes),
597 )
416 - for positions, mm_tokens in zip(chunked_mm_positions, chunked_mm_tokens)
598 ]
599 mm_placeholders = {"image": ranges}
600
@@ -427,12 +608,8 @@ class MultiModalProcessor(BaseMultiModalProcessor[MultiModalProcessingInfo]):
608 inputs: ProcessorInputs,
609 timing_ctx: TimingContext,
610 ) -> MultiModalInput:
430 - """
431 - Process multi-modal inputs to be used in vLLM.
432 -
433 - Apply HF Processor on prompt text and multi-modal data together,
434 - outputting token IDs and processed tensors.
435 - """
611 + """Process the prompt and every multi-modal item in one HF processor call,
612 + then read the placeholder ranges out of the token ids it returns."""
613 prompt = inputs.prompt
614 mm_items = inputs.mm_data_items
615 hf_processor_mm_kwargs = inputs.hf_processor_mm_kwargs
@@ -468,7 +645,7 @@ class MultiModalProcessor(BaseMultiModalProcessor[MultiModalProcessingInfo]):
645 self.info.ctx.get_mm_config().mm_hasher_algorithm,
646 )
647
471 - # For gemma3 we check `token_type_ids` as the key
648 + # Gemma3 reports them under the key the model uses for its own token types
649 mm_token_type_ids = processed_data.pop("token_type_ids", None)
650 mm_token_type_ids = processed_data.pop("mm_token_type_ids", mm_token_type_ids)
651
@@ -516,6 +693,201 @@ class MultiModalProcessor(BaseMultiModalProcessor[MultiModalProcessingInfo]):
693 )
694
695
696 +class OffsetsMultiModalProcessor(_MultiModalProcessorBase):
697 + """Locates placeholders from the `text_replacement_offsets` the HF processor
698 + reports, expressing each one as a `PromptUpdate`.
699 +
700 + Stating the expansion as an update is what lets it be rebuilt from an
701 + unexpanded prompt, so this processor takes the base class's processing path
702 + and with it the multi-modal processor cache.
703 + """
704 +
705 + def _get_prompt_updates(
706 + self,
707 + mm_items: MultiModalDataItems,
708 + hf_processor_mm_kwargs: Mapping[str, object],
709 + out_mm_kwargs: MultiModalKwargsItems,
710 + ) -> Sequence[PromptUpdate]:
711 + """Replace each modality's placeholder token with the token ids that item's
712 + replacement text encodes to, marking which of them hold embeddings."""
713 + hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
714 + updates = []
715 + for modality, items in out_mm_kwargs.items():
716 + # Popped so they are neither cached nor sent to the model; the updates
717 + # they produce are cached alongside the item instead
718 + replacements = [
719 + PromptUpdateDetails.select_token_id(
720 + (ids := item.pop(f"{modality}_replacement_ids").data).tolist(),
721 + _get_embed_token_id(ids),
722 + )
723 + for item in items
724 + ]
725 + updates.append(
726 + PromptReplacement(
727 + modality=modality,
728 + target=getattr(hf_processor, f"{modality}_token"),
729 + replacement=replacements.__getitem__,
730 + )
731 + )
732 + return updates
733 +
734 + def _apply_hf_processor_main(
735 + self,
736 + prompt: str | list[int],
737 + mm_items: MultiModalDataItems,
738 + hf_processor_mm_kwargs: Mapping[str, object],
739 + tokenization_kwargs: Mapping[str, object],
740 + *,
741 + enable_hf_prompt_update: bool,
742 + ) -> tuple[list[int], "BatchFeature", bool]:
743 + """Tokenize the prompt unexpanded, leaving the expansion for vLLM to splice
744 + in, whatever `enable_hf_prompt_update` asks for.
745 +
746 + This differs from `super()` only for a text prompt with
747 + `enable_hf_prompt_update`, where `super()` would keep the expanded token
748 + ids the HF processor returns. Both routes give the same ids, so forcing
749 + this one costs an extra processor call and buys the guarantee that a
750 + request bypassing the cache is identical to a cached one.
751 +
752 + A text prompt only arrives with `enable_hf_prompt_update` when there is
753 + no multi-modal processor cache. Requests carrying pre-computed
754 + embeddings bypass the cache too, but `--enable-mm-embeds` is unsupported
755 + here, so they fail validation shortly afterwards.
756 + """
757 + if isinstance(prompt, str) and enable_hf_prompt_update:
758 + logger.warning_once(
759 + "Disabling the multi-modal processor cache is extra slow with the "
760 + "Transformers modeling backend: the prompt is still tokenized "
761 + "unexpanded and the expansion spliced in, to keep the token ids "
762 + "identical to what the cache produces, which costs an extra HF "
763 + "processor call per request."
764 + )
765 + return super()._apply_hf_processor_main(
766 + prompt,
767 + mm_items,
768 + hf_processor_mm_kwargs,
769 + tokenization_kwargs,
770 + enable_hf_prompt_update=False,
771 + )
772 +
773 + def _get_num_image_patches(
774 + self,
775 + hf_inputs: "BatchFeature",
776 + mm_data: Mapping[str, object],
777 + num_images: int,
778 + ) -> torch.Tensor:
779 + """How many rows of the image fields belong to each image.
780 +
781 + Taken from whichever per-image count the processor reports,
782 + and checked against the data it has to slice.
783 + """
784 + if (grid := hf_inputs.get("image_grid_thw")) is not None:
785 + num_patches = grid.prod(-1)
786 + elif (counts := self._get_num_patches_per_image(mm_data)) is not None:
787 + num_patches = torch.tensor(counts)
788 + else:
789 + num_patches = torch.ones(num_images, dtype=torch.long)
790 +
791 + image_data = hf_inputs.get("pixel_values", hf_inputs.get("image_patches"))
792 + if isinstance(image_data, torch.Tensor):
793 + total = int(num_patches.sum())
794 + rows = image_data.shape[self._get_slice_dim(image_data, total)]
795 + if rows != total:
796 + raise ValueError(
797 + f"{type(self.info.get_hf_processor()).__name__} returned "
798 + f"{rows} row(s) of image data for {num_images} image(s), which "
799 + f"cannot be split into the {num_patches.tolist()} row(s) per "
800 + "image derived from its outputs, so the rows cannot be "
801 + "attributed to an image. Gemma3 does this when "
802 + "`do_pan_and_scan` crops an image."
803 + )
804 + return num_patches
805 +
806 + def _get_num_patches_per_image(
807 + self, mm_data: Mapping[str, object]
808 + ) -> list[int] | None:
809 + """Ask the HF processor how many rows of image data each image produces."""
810 + images = mm_data.get("images")
811 + if not images:
812 + return None
813 + try:
814 + sizes = [(image.height, image.width) for image in images]
815 + mm_tokens = self.info.get_hf_processor()._get_num_multimodal_tokens(
816 + image_sizes=sizes, **self.info.ctx.get_merged_mm_kwargs({})
817 + )
818 + return list(mm_tokens["num_image_patches"])
819 + except (AttributeError, KeyError, TypeError):
820 + return None
821 +
822 + def _call_hf_processor(
823 + self,
824 + prompt: str,
825 + mm_data: Mapping[str, object],
826 + mm_kwargs: Mapping[str, object],
827 + tok_kwargs: Mapping[str, object],
828 + ) -> "BatchFeature":
829 + """Ask for the replacement each placeholder expands to, and record it as
830 + per-item fields: its token ids, and the tokens or patches behind them."""
831 + if has_mm_data := any(mm_data.values()):
832 + mm_data = {**mm_data, "return_text_replacement_offsets": True}
833 + hf_inputs = super()._call_hf_processor(prompt, mm_data, mm_kwargs, tok_kwargs)
834 + # Drop the inputs the model would reject
835 + hf_inputs.pop("mm_token_type_ids", None)
836 + hf_inputs.pop("token_type_ids", None)
837 +
838 + offsets = hf_inputs.pop("text_replacement_offsets", None)
839 + # Some processors return an empty batch as a tensor rather than a list
840 + if offsets is None or len(offsets) == 0 or len(offsets[0]) == 0:
841 + if has_mm_data:
842 + raise ValueError(
843 + f"{type(self.info.get_hf_processor()).__name__} returned no "
844 + "text replacement offsets, so the Transformers modeling backend "
845 + "cannot locate the placeholder of each item. Its `__call__` has "
846 + "to reach `ProcessorMixin.get_text_with_replacements` with one "
847 + "replacement per item, which usually means implementing "
848 + "`replace_<modality>_token`. Please report this to transformers "
849 + "so it can be fixed, and install `transformers<5.15.0` in the "
850 + "meantime to locate placeholders in the expanded prompt instead."
851 + )
852 + return hf_inputs
853 +
854 + tokenizer = self.info.get_tokenizer()
855 + replacements = defaultdict[str, list[list[int]]](list)
856 + for entry in offsets[0]:
857 + replacements[entry["type"]].append(
858 + tokenizer.encode(entry["replacement"], add_special_tokens=False)
859 + )
860 +
861 + for modality, seqs in replacements.items():
862 + hf_inputs[f"{modality}_replacement_ids"] = torch.tensor(
863 + [token_id for seq in seqs for token_id in seq]
864 + )
865 + hf_inputs[f"{modality}_replacement_sizes"] = torch.tensor(
866 + [len(seq) for seq in seqs]
867 + )
868 + if modality == "image":
869 + hf_inputs["num_image_patches"] = self._get_num_image_patches(
870 + hf_inputs, mm_data, len(seqs)
871 + )
872 + elif modality == "audio":
873 + counts = []
874 + for seq in seqs:
875 + ids = torch.tensor(seq)
876 + counts.append(int(ids.eq(_get_embed_token_id(ids)).sum()))
877 + hf_inputs["num_audio_tokens"] = torch.tensor(counts)
878 +
879 + return hf_inputs
880 +
881 +
882 +# From this version on, a processor reporting no offsets is an error rather than a
883 +# fallback to searching the expanded prompt
884 +MultiModalProcessor = (
885 + LegacyMultiModalProcessor
886 + if Version(transformers.__version__) < Version("5.15.0")
887 + else OffsetsMultiModalProcessor
888 +)
889 +
890 +
891 class MultiModalMixin(SupportsMultiModal, SupportsMRoPE):
892 def __init__(self, *, vllm_config: "VllmConfig", prefix: str = ""):
893 # Skip SupportsMRoPE.__init__ and call the next class in MRO
@@ -733,18 +1105,32 @@ class MultiModalMixin(SupportsMultiModal, SupportsMRoPE):
1105 kwargs.pop("token_type_ids", None)
1106 kwargs.pop("mm_token_type_ids", None)
1107
736 - # HuggingFace's `get_audio_features` implementations branch on
737 - # per-sample feature lengths internally.
738 - with gpu_sync_allowed():
739 - audio_output = self.model.get_audio_features(
740 - input_features, return_dict=True, **kwargs
741 - )
742 - audio_embeddings = audio_output.pooler_output
743 -
1108 # Per-audio token counts are needed as Python ints to split.
1109 with gpu_sync_allowed():
1110 split_sizes = num_audio_tokens.flatten().tolist()
747 - return self._split_embeddings(audio_embeddings, split_sizes)
1111 + if isinstance(input_features, torch.Tensor):
1112 + # HuggingFace's `get_audio_features` implementations branch on
1113 + # per-sample feature lengths internally.
1114 + with gpu_sync_allowed():
1115 + audio_output = self.model.get_audio_features(
1116 + input_features, return_dict=True, **kwargs
1117 + )
1118 + return self._split_embeddings(audio_output.pooler_output, split_sizes)
1119 +
1120 + # Audios the processor left un-padded arrive as a list once their
1121 + # lengths differ. Encode them one at a time so that none of them is
1122 + # padded to match another.
1123 + embeddings: list[torch.Tensor] = []
1124 + for index, features in enumerate(input_features):
1125 + audio_output = self.model.get_audio_features(
1126 + features.unsqueeze(0),
1127 + return_dict=True,
1128 + **self._select_item_kwargs(kwargs, index, len(input_features)),
1129 + )
1130 + embeddings.extend(
1131 + self._split_embeddings(audio_output.pooler_output, [split_sizes[index]])
1132 + )
1133 + return embeddings
1134
1135 def _process_image_input(self, **kwargs) -> list[torch.Tensor] | None:
1136 pixel_values: torch.Tensor | None = kwargs.pop("pixel_values", None)
@@ -761,6 +1147,42 @@ class MultiModalMixin(SupportsMultiModal, SupportsMRoPE):
1147
1148 num_image_patches = kwargs.pop("num_image_patches")
1149
1150 + split_sizes = num_image_patches.flatten().tolist()
1151 + if isinstance(pixel_values, torch.Tensor):
1152 + vision_embeddings = self._get_image_features(pixel_values, **kwargs)
1153 + if isinstance(vision_embeddings, torch.Tensor):
1154 + return self._split_embeddings(vision_embeddings, split_sizes)
1155 + return list(vision_embeddings)
1156 +
1157 + # Images the processor left un-padded arrive as a list once their
1158 + # shapes differ. Encode them one at a time so that none of them is
1159 + # padded to match another.
1160 + embeddings: list[torch.Tensor] = []
1161 + for index, image in enumerate(pixel_values):
1162 + features = self._get_image_features(
1163 + image.unsqueeze(0),
1164 + **self._select_item_kwargs(kwargs, index, len(pixel_values)),
1165 + )
1166 + # Encoders which return one entry per image return a single entry
1167 + if not isinstance(features, torch.Tensor):
1168 + features = torch.cat(list(features))
1169 + embeddings.extend(self._split_embeddings(features, [split_sizes[index]]))
1170 + return embeddings
1171 +
1172 + def _select_item_kwargs(
1173 + self, kwargs: dict[str, Any], index: int, num_items: int
1174 + ) -> dict[str, Any]:
1175 + """Narrow the entries of `kwargs` that hold one row per item down to the item
1176 + at `index`. Length is all there is to match on, so an unrelated entry of the
1177 + same length is narrowed too."""
1178 + return {
1179 + key: value[index : index + 1]
1180 + if isinstance(value, (torch.Tensor, list)) and len(value) == num_items
1181 + else value
1182 + for key, value in kwargs.items()
1183 + }
1184 +
1185 + def _get_image_features(self, pixel_values: torch.Tensor, **kwargs) -> Any:
1186 # grid_thw fields are registered keep_on_cpu; restore the on-device
1187 # placement that HF get_image_features implementations expect.
1188 for key, value in kwargs.items():
@@ -772,22 +1194,17 @@ class MultiModalMixin(SupportsMultiModal, SupportsMRoPE):
1194 # padding images via boolean-mask indexing, LlavaOnevision
1195 # branches on per-sample batch counts).
1196 with gpu_sync_allowed():
775 - vision_embeddings = self.model.get_image_features(pixel_values, **kwargs)
1197 + features = self.model.get_image_features(pixel_values, **kwargs)
1198
1199 # Transformers `v5`, `self.get_image_features` returns a tuple
1200 # containing the features and optionally attentions/hidden_states
1201 # After v5 is settled, we can enable qwen3-vl with several outputs
1202 # from `self.get_image_features`
781 - if isinstance(vision_embeddings, tuple):
782 - vision_embeddings = vision_embeddings[0]
783 - elif isinstance(vision_embeddings, dict):
784 - vision_embeddings = vision_embeddings.pooler_output
785 -
786 - if isinstance(vision_embeddings, torch.Tensor):
787 - split_sizes = num_image_patches.flatten().tolist()
788 - return self._split_embeddings(vision_embeddings, split_sizes)
789 -
790 - return list(vision_embeddings)
1203 + if isinstance(features, tuple):
1204 + return features[0]
1205 + if isinstance(features, dict):
1206 + return features.pooler_output
1207 + return features
1208
1209 def embed_multimodal(self, **kwargs) -> MultiModalEmbeddings:
1210 # Each helper detects its own inputs. We are called once per modality, so the