main
py 349 lines 13.4 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Inkling multimodal preprocessing."""
4
5 from __future__ import annotations
6
7 from collections.abc import Iterable, Mapping, Sequence
8 from typing import Any, cast
9
10 import numpy as np
11 import regex as re
12 import torch
13 from transformers.feature_extraction_utils import BatchFeature
14
15 from vllm.config.multimodal import (
16 MultiModalDummyOptions,
17 )
18 from vllm.inputs import MultiModalDataDict
19 from vllm.multimodal.inputs import (
20 MultiModalFieldConfig,
21 MultiModalKwargsItems,
22 )
23 from vllm.multimodal.parse import MultiModalDataItems, MultiModalDataParser
24 from vllm.multimodal.processing import (
25 BaseDummyInputsBuilder,
26 BaseMultiModalProcessor,
27 BaseProcessingInfo,
28 PromptReplacement,
29 PromptUpdate,
30 PromptUpdateDetails,
31 )
32 from vllm.transformers_utils.processors.inkling import (
33 AUDIO_MARKER_ID,
34 AUDIO_TOKEN_ID,
35 IMAGE_MARKER_ID,
36 IMAGE_TOKEN_ID,
37 InklingAudioFeatureExtractor,
38 InklingImageProcessor,
39 InklingProcessor,
40 )
41
42 from ..configs import InklingMMConfig
43
44 # Maximum audio tokens accepted per clip. At the dMel rate of 20 tokens/s
45 # (50 ms hop) this is ~10 minutes of audio. It bounds the persistent per-request
46 # buffers and the encoder/memory budget; longer clips are rejected up front.
47 MAX_AUDIO_TOKENS = 12_000
48
49
50 class InklingMultiModalDataParser(MultiModalDataParser):
51 def _parse_audio_data(self, data: Any) -> Any:
52 if isinstance(data, (np.ndarray, torch.Tensor)) and data.ndim == 2:
53 raise ValueError(
54 "Inkling raw 2-D audio has an ambiguous channel layout. "
55 "Provide encoded audio or a list of mono waveforms."
56 )
57 return super()._parse_audio_data(data)
58
59
60 def inkling_vision_enabled(config: InklingMMConfig) -> bool:
61 return getattr(config.vision_config, "decoder_dmodel", None) is not None
62
63
64 def inkling_audio_enabled(config: InklingMMConfig) -> bool:
65 return getattr(config.audio_config, "decoder_dmodel", None) is not None
66
67
68 class InklingProcessingInfo(BaseProcessingInfo):
69 def get_hf_config(self) -> InklingMMConfig:
70 return self.ctx.get_hf_config(InklingMMConfig)
71
72 def get_hf_processor(self, **kwargs: object) -> InklingProcessor:
73 config = self.get_hf_config()
74 vision_config = config.vision_config
75 audio_config = config.audio_config
76
77 image_processor = InklingImageProcessor(
78 patch_size=getattr(vision_config, "patch_size", None) or 40,
79 )
80
81 if inkling_audio_enabled(config):
82 audio_params = {
83 "n_mels": audio_config.n_mel_bins,
84 "num_dmel_bins": audio_config.mel_vocab_size,
85 "dmel_min_value": audio_config.dmel_min_value,
86 "dmel_max_value": audio_config.dmel_max_value,
87 }
88 else:
89 audio_params = {}
90 audio_extractor = InklingAudioFeatureExtractor(params=audio_params)
91
92 return InklingProcessor(
93 image_processor=image_processor,
94 audio_feature_extractor=audio_extractor,
95 tokenizer=self.get_tokenizer(),
96 )
97
98 def get_data_parser(self) -> MultiModalDataParser:
99 # Audio inputs must be resampled to the dMel feature extractor's rate
100 # before `process_audios` (see InklingAudioFeatureExtractor._decode_one).
101 # Without a target_sr the default parser raises on any audio input.
102 extractor = self.get_hf_processor().audio_feature_extractor
103 return InklingMultiModalDataParser(
104 target_sr=extractor.params.sample_rate,
105 target_channels=1,
106 expected_hidden_size=self._get_expected_hidden_size(),
107 )
108
109 def get_supported_mm_limits(self) -> Mapping[str, int | None]:
110 config = self.get_hf_config()
111 limits: dict[str, int | None] = {}
112 if inkling_vision_enabled(config):
113 limits["image"] = None
114 if inkling_audio_enabled(config):
115 limits["audio"] = None
116 return limits
117
118 def get_mm_max_tokens_per_item(
119 self,
120 seq_len: int,
121 mm_counts: Mapping[str, int],
122 ) -> Mapping[str, int] | None:
123 # Let vLLM profile dummy inputs to determine the max token counts; the
124 # image patch count is data-dependent, and the dummy audio is sized to
125 # MAX_AUDIO_TOKENS so audio is profiled/budgeted at its allowed maximum.
126 return None
127
128
129 class InklingDummyInputsBuilder(BaseDummyInputsBuilder[InklingProcessingInfo]):
130 def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
131 # One placeholder per media item; the processor expands each into N
132 # copies once feature row counts are known.
133 num_images = mm_counts.get("image", 0)
134 num_audios = mm_counts.get("audio", 0)
135 # Use spellings the renderer would emit; tokenization is bypassed in
136 # _apply_hf_processor_main (we build input_ids directly), so the exact
137 # text only needs to be a stable per-item marker.
138 return ("<|content_image|>" * num_images) + (
139 "<|content_audio_input|>" * num_audios
140 )
141
142 def get_dummy_mm_data(
143 self,
144 seq_len: int,
145 mm_counts: Mapping[str, int],
146 mm_options: MultiModalDummyOptions,
147 ) -> MultiModalDataDict:
148 config = self.info.get_hf_config()
149 num_images = mm_counts.get("image", 0)
150 num_audios = mm_counts.get("audio", 0)
151
152 mm_data: dict[str, Any] = {}
153 if num_images:
154 patch_size = getattr(config.vision_config, "patch_size", 40)
155 # A square image ~4 patches wide so the dummy emits several patches.
156 side = patch_size * 4
157 mm_data["image"] = self._get_dummy_images(
158 width=side,
159 height=side,
160 num_images=num_images,
161 overrides=mm_options.get("image"),
162 )
163 if num_audios:
164 # Size the dummy at the maximum allowed audio so memory/encoder
165 # budgeting reflects the largest clip we accept (MAX_AUDIO_TOKENS).
166 params = self.info.get_hf_processor().audio_feature_extractor.params
167 hop = int(round(params.audio_token_duration_s * params.sample_rate))
168 audio_len = MAX_AUDIO_TOKENS * hop
169 mm_data["audio"] = self._get_dummy_audios(
170 length=audio_len,
171 num_audios=num_audios,
172 overrides=mm_options.get("audio"),
173 )
174 return mm_data
175
176
177 class InklingMultiModalProcessor(BaseMultiModalProcessor[InklingProcessingInfo]):
178 def _apply_hf_processor_main(
179 self,
180 mm_items: MultiModalDataItems,
181 hf_kwargs: Mapping[str, object],
182 ) -> BatchFeature:
183 mm_data, hf_kwargs, passthrough_data = self._get_hf_mm_inputs(
184 mm_items, hf_kwargs
185 )
186
187 prompt_text = self.dummy_inputs.get_dummy_text(mm_items.get_all_counts())
188
189 processor = self.info.get_hf_processor(**hf_kwargs)
190 tokenizer = self.info.get_tokenizer()
191
192 images = mm_data.get("images") or []
193 audios = mm_data.get("audio") or []
194 if not isinstance(images, list):
195 images = list(cast(Iterable[Any], images))
196 if not isinstance(audios, list):
197 audios = list(cast(Iterable[Any], audios))
198
199 prompt_ids = self._tokenize_with_placeholders(
200 prompt_text, tokenizer, len(images), len(audios)
201 )
202
203 data: dict[str, Any] = {"input_ids": [prompt_ids]}
204
205 if images:
206 img_feat = processor.process_images(images)
207 data["pixel_values"] = img_feat["vision_patches_bthwc"]
208 data["num_patches"] = torch.tensor(
209 img_feat["num_patches"], dtype=torch.int64
210 )
211
212 if audios:
213 aud_feat = processor.process_audios(audios)
214 per_clip = aud_feat["dmel_bins"]
215 num_audio_tokens = aud_feat["num_audio_tokens"]
216 for i, n in enumerate(num_audio_tokens):
217 if int(n) > MAX_AUDIO_TOKENS:
218 raise ValueError(
219 f"Audio clip {i} produces {int(n)} tokens, exceeding the "
220 f"maximum of {MAX_AUDIO_TOKENS} (~10 min at 20 tokens/s). "
221 "Provide a shorter clip."
222 )
223 if per_clip:
224 input_audio_features = torch.cat(
225 [torch.as_tensor(c) for c in per_clip], dim=0
226 )
227 else:
228 input_audio_features = torch.empty(0)
229 data["input_audio_features"] = input_audio_features
230 data["num_audio_tokens"] = torch.tensor(num_audio_tokens, dtype=torch.int64)
231
232 processed_data = BatchFeature(data=data, tensor_type=None)
233 return self._finalize_hf_mm_data(
234 mm_data, hf_kwargs, passthrough_data, processed_data
235 )
236
237 def _tokenize_with_placeholders(
238 self,
239 prompt: str,
240 tokenizer: Any,
241 num_images: int,
242 num_audios: int,
243 ) -> list[int]:
244 """Tokenize `prompt`, emitting the block-start marker id per media item.
245
246 Each marker (kept verbatim) is later expanded by ``_get_prompt_updates``
247 into ``<marker> + <placeholder> * N``.
248 """
249 image_marker = "<|content_image|>"
250 audio_marker = "<|content_audio_input|>"
251
252 pattern = f"({re.escape(image_marker)}|{re.escape(audio_marker)})"
253 chunks = re.split(pattern, prompt)
254
255 ids: list[int] = []
256 seen_img = seen_aud = 0
257 for chunk in chunks:
258 if chunk == image_marker:
259 ids.append(IMAGE_MARKER_ID)
260 seen_img += 1
261 elif chunk == audio_marker:
262 ids.append(AUDIO_MARKER_ID)
263 seen_aud += 1
264 elif chunk:
265 ids.extend(tokenizer.encode(chunk, add_special_tokens=False))
266
267 # Reconcile against the declared media counts only when media is
268 # present. With no media items, emit the markers verbatim; the
269 # marker<->item correspondence is enforced later by
270 # ``_get_prompt_updates`` once the media features are available.
271 if num_images or num_audios:
272 # Fail clearly on a placeholder/media-count mismatch instead of
273 # crashing with an IndexError deep in the per-item replacement logic.
274 if num_images and seen_img != num_images:
275 raise ValueError(
276 f"Prompt contains {seen_img} image placeholder(s), but only "
277 f"{num_images} image(s) were provided."
278 )
279 if num_audios and seen_aud != num_audios:
280 raise ValueError(
281 f"Prompt contains {seen_aud} audio placeholder(s), but only "
282 f"{num_audios} audio input(s) were provided."
283 )
284 return ids
285
286 def _get_mm_fields_config(
287 self,
288 hf_inputs: BatchFeature,
289 hf_processor_mm_kwargs: Mapping[str, object],
290 ) -> Mapping[str, MultiModalFieldConfig]:
291 num_patches = hf_inputs.get("num_patches", torch.empty(0, dtype=torch.int64))
292 num_audio_tokens = hf_inputs.get(
293 "num_audio_tokens", torch.empty(0, dtype=torch.int64)
294 )
295 return dict(
296 # Ragged per-image patches, grouped by num_patches.
297 pixel_values=MultiModalFieldConfig.flat_from_sizes("image", num_patches),
298 num_patches=MultiModalFieldConfig.batched("image", keep_on_cpu=True),
299 # Ragged per-audio frames, grouped by num_audio_tokens.
300 input_audio_features=MultiModalFieldConfig.flat_from_sizes(
301 "audio", num_audio_tokens
302 ),
303 num_audio_tokens=MultiModalFieldConfig.batched("audio", keep_on_cpu=True),
304 )
305
306 def _get_prompt_updates(
307 self,
308 mm_items: Any,
309 hf_processor_mm_kwargs: Mapping[str, Any],
310 out_mm_kwargs: MultiModalKwargsItems,
311 ) -> Sequence[PromptUpdate]:
312 out_mm_data = out_mm_kwargs.get_data()
313 num_patches: Any = out_mm_data.get("num_patches")
314 num_audio_tokens: Any = out_mm_data.get("num_audio_tokens")
315
316 # Keep the block-start marker and append N placeholder tokens after it;
317 # only the placeholder positions are flagged as embeddings (is_embed), so
318 # the marker stays a normal text token while the tower features scatter
319 # into the placeholders.
320 def image_replacement(item_idx: int) -> PromptUpdateDetails:
321 n = int(num_patches[item_idx])
322 return PromptUpdateDetails.select_token_id(
323 [IMAGE_MARKER_ID] + [IMAGE_TOKEN_ID] * n, IMAGE_TOKEN_ID
324 )
325
326 def audio_replacement(item_idx: int) -> PromptUpdateDetails:
327 n = int(num_audio_tokens[item_idx])
328 return PromptUpdateDetails.select_token_id(
329 [AUDIO_MARKER_ID] + [AUDIO_TOKEN_ID] * n, AUDIO_TOKEN_ID
330 )
331
332 updates: list[PromptUpdate] = []
333 if num_patches is not None and len(num_patches) > 0:
334 updates.append(
335 PromptReplacement(
336 modality="image",
337 target=[IMAGE_MARKER_ID],
338 replacement=image_replacement,
339 )
340 )
341 if num_audio_tokens is not None and len(num_audio_tokens) > 0:
342 updates.append(
343 PromptReplacement(
344 modality="audio",
345 target=[AUDIO_MARKER_ID],
346 replacement=audio_replacement,
347 )
348 )
349 return updates