A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 15, 03:30 UTC
[Frontend] Support configurable mm placeholder strings & flexible video sampling policies via CLI flags. (#20105)
Signed-off-by: Chenheli Hua <huachenheli@outlook.com>
Chenheli Hua committed
Jul 1, 2025 at 23:34 UTC
2e7cbf2d7da1030d141dd32e0271697b49b84e54
12 files changed
+199
-29
tests/async_engine/test_async_llm_engine.py
+4
-2
@@ -6,8 +6,8 @@ import os
6
import uuid
7
from asyncio import CancelledError
8
from copy import copy
9
-from dataclasses import dataclass
10
-from typing import Optional
9
+from dataclasses import dataclass, field
10
+from typing import Any, Optional
11
12
import pytest
13
import pytest_asyncio
@@ -32,6 +32,8 @@ class RequestOutput:
32
@dataclass
33
class MockModelConfig:
34
use_async_output_proc = True
35
+ media_io_kwargs: dict[str, dict[str, Any]] = field(default_factory=dict)
36
+ mm_placeholder_str_override: dict[str, str] = field(default_factory=dict)
37
38
39
class MockEngine:
tests/engine/test_arg_utils.py
+52
@@ -231,6 +231,58 @@ def test_limit_mm_per_prompt_parser(arg, expected):
231
assert args.limit_mm_per_prompt == expected
232
233
234
+@pytest.mark.parametrize(
235
+ ("arg", "expected"),
236
+ [
237
+ (None, dict()),
238
+ ('{"video": {"num_frames": 123} }', {
239
+ "video": {
240
+ "num_frames": 123
241
+ }
242
+ }),
243
+ (
244
+ '{"video": {"num_frames": 123, "fps": 1.0, "foo": "bar"}, "image": {"foo": "bar"} }', # noqa
245
+ {
246
+ "video": {
247
+ "num_frames": 123,
248
+ "fps": 1.0,
249
+ "foo": "bar"
250
+ },
251
+ "image": {
252
+ "foo": "bar"
253
+ }
254
+ }),
255
+ ])
256
+def test_media_io_kwargs_parser(arg, expected):
257
+ parser = EngineArgs.add_cli_args(FlexibleArgumentParser())
258
+ if arg is None:
259
+ args = parser.parse_args([])
260
+ else:
261
+ args = parser.parse_args(["--media-io-kwargs", arg])
262
+
263
+ assert args.media_io_kwargs == expected
264
+
265
+
266
+@pytest.mark.parametrize(("arg", "expected"), [
267
+ (None, dict()),
268
+ ('{"video":"<|video_placeholder|>"}', {
269
+ "video": "<|video_placeholder|>"
270
+ }),
271
+ ('{"video":"<|video_placeholder|>", "image": "<|image_placeholder|>"}', {
272
+ "video": "<|video_placeholder|>",
273
+ "image": "<|image_placeholder|>"
274
+ }),
275
+])
276
+def test_mm_placeholder_str_override_parser(arg, expected):
277
+ parser = EngineArgs.add_cli_args(FlexibleArgumentParser())
278
+ if arg is None:
279
+ args = parser.parse_args([])
280
+ else:
281
+ args = parser.parse_args(["--mm-placeholder-str-override", arg])
282
+
283
+ assert args.mm_placeholder_str_override == expected
284
+
285
+
286
def test_compilation_config():
287
parser = EngineArgs.add_cli_args(FlexibleArgumentParser())
288
tests/entrypoints/openai/test_serving_chat.py
+4
-2
@@ -3,8 +3,8 @@
3
4
import asyncio
5
from contextlib import suppress
6
-from dataclasses import dataclass
7
-from typing import Optional
6
+from dataclasses import dataclass, field
7
+from typing import Any, Optional
8
from unittest.mock import MagicMock
9
10
from vllm.config import MultiModalConfig
@@ -40,6 +40,8 @@ class MockModelConfig:
40
allowed_local_media_path: str = ""
41
encoder_config = None
42
generation_config: str = "auto"
43
+ media_io_kwargs: dict[str, dict[str, Any]] = field(default_factory=dict)
44
+ mm_placeholder_str_override: dict[str, str] = field(default_factory=dict)
45
46
def get_diff_sampling_param(self):
47
return self.diff_sampling_param or {}
tests/multimodal/test_utils.py
+6
-6
@@ -167,14 +167,14 @@ async def test_fetch_image_error_conversion():
167
@pytest.mark.parametrize("video_url", TEST_VIDEO_URLS)
168
@pytest.mark.parametrize("num_frames", [-1, 32, 1800])
169
async def test_fetch_video_http(video_url: str, num_frames: int):
170
- connector = MediaConnector()
170
+ connector = MediaConnector(
171
+ media_io_kwargs={"video": {
172
+ "num_frames": num_frames,
173
+ }})
174
172
- video_sync = connector.fetch_video(video_url, num_frames=num_frames)
173
- video_async = await connector.fetch_video_async(video_url,
174
- num_frames=num_frames)
175
- # Check that the video frames are equal and metadata are same
175
+ video_sync = connector.fetch_video(video_url)
176
+ video_async = await connector.fetch_video_async(video_url)
177
assert np.array_equal(video_sync[0], video_async[0])
177
- assert video_sync[1] == video_async[1]
178
179
180
# Used for the next two tests related to `merge_and_sort_multimodal_metadata`.
tests/multimodal/test_video.py
+47
-1
@@ -4,7 +4,10 @@ import numpy as np
4
import numpy.typing as npt
5
import pytest
6
7
-from vllm.multimodal.video import VIDEO_LOADER_REGISTRY, VideoLoader
7
+from vllm import envs
8
+from vllm.multimodal.image import ImageMediaIO
9
+from vllm.multimodal.video import (VIDEO_LOADER_REGISTRY, VideoLoader,
10
+ VideoMediaIO)
11
12
NUM_FRAMES = 10
13
FAKE_OUTPUT_1 = np.random.rand(NUM_FRAMES, 1280, 720, 3)
@@ -40,3 +43,46 @@ def test_video_loader_registry():
43
def test_video_loader_type_doesnt_exist():
44
with pytest.raises(AssertionError):
45
VIDEO_LOADER_REGISTRY.load("non_existing_video_loader")
46
+
47
+
48
+@VIDEO_LOADER_REGISTRY.register("assert_10_frames_1_fps")
49
+class Assert10Frames1FPSVideoLoader(VideoLoader):
50
+
51
+ @classmethod
52
+ def load_bytes(cls,
53
+ data: bytes,
54
+ num_frames: int = -1,
55
+ fps: float = -1.0,
56
+ **kwargs) -> npt.NDArray:
57
+ assert num_frames == 10, "bad num_frames"
58
+ assert fps == 1.0, "bad fps"
59
+ return FAKE_OUTPUT_2
60
+
61
+
62
+def test_video_media_io_kwargs():
63
+ envs.VLLM_VIDEO_LOADER_BACKEND = "assert_10_frames_1_fps"
64
+ imageio = ImageMediaIO()
65
+
66
+ # Verify that different args pass/fail assertions as expected.
67
+ videoio = VideoMediaIO(imageio, **{"num_frames": 10, "fps": 1.0})
68
+ _ = videoio.load_bytes(b"test")
69
+
70
+ videoio = VideoMediaIO(
71
+ imageio, **{
72
+ "num_frames": 10,
73
+ "fps": 1.0,
74
+ "not_used": "not_used"
75
+ })
76
+ _ = videoio.load_bytes(b"test")
77
+
78
+ with pytest.raises(AssertionError, match="bad num_frames"):
79
+ videoio = VideoMediaIO(imageio, **{})
80
+ _ = videoio.load_bytes(b"test")
81
+
82
+ with pytest.raises(AssertionError, match="bad num_frames"):
83
+ videoio = VideoMediaIO(imageio, **{"num_frames": 9, "fps": 1.0})
84
+ _ = videoio.load_bytes(b"test")
85
+
86
+ with pytest.raises(AssertionError, match="bad fps"):
87
+ videoio = VideoMediaIO(imageio, **{"num_frames": 10, "fps": 2.0})
88
+ _ = videoio.load_bytes(b"test")
vllm/config.py
+16
@@ -346,6 +346,12 @@ class ModelConfig:
346
limit_mm_per_prompt: dict[str, int] = field(default_factory=dict)
347
"""Maximum number of data items per modality per prompt. Only applicable
348
for multimodal models."""
349
+ media_io_kwargs: dict[str, dict[str, Any]] = field(default_factory=dict)
350
+ """Additional args passed to process media inputs, keyed by modalities.
351
+ For example, to set num_frames for video, set
352
+ `--media-io-kwargs '{"video": {"num_frames": 40} }'` """
353
+ mm_placeholder_str_override: dict[str, str] = field(default_factory=dict)
354
+ """Optionally override placeholder string for given modalities."""
355
use_async_output_proc: bool = True
356
"""Whether to use async output processor."""
357
config_format: Union[str, ConfigFormat] = ConfigFormat.AUTO.value
@@ -694,6 +700,8 @@ class ModelConfig:
700
if self.registry.is_multimodal_model(self.architectures):
701
return MultiModalConfig(
702
limit_per_prompt=self.limit_mm_per_prompt,
703
+ media_io_kwargs=self.media_io_kwargs,
704
+ mm_placeholder_str_override=self.mm_placeholder_str_override,
705
mm_processor_kwargs=self.mm_processor_kwargs,
706
disable_mm_preprocessor_cache=self.
707
disable_mm_preprocessor_cache)
@@ -3063,6 +3071,14 @@ class MultiModalConfig:
3071
`{"images": 16, "videos": 2}`
3072
"""
3073
3074
+ media_io_kwargs: dict[str, dict[str, Any]] = field(default_factory=dict)
3075
+ """Additional args passed to process media inputs, keyed by modalities.
3076
+ For example, to set num_frames for video, set
3077
+ `--media-io-kwargs '{"video": {"num_frames": 40} }'` """
3078
+
3079
+ mm_placeholder_str_override: dict[str, str] = field(default_factory=dict)
3080
+ """Optionally override placeholder string for given modalities."""
3081
+
3082
mm_processor_kwargs: Optional[dict[str, object]] = None
3083
"""
3084
Overrides for the multi-modal processor obtained from
vllm/engine/arg_utils.py
+12
@@ -369,6 +369,11 @@ class EngineArgs:
369
get_field(TokenizerPoolConfig, "extra_config")
370
limit_mm_per_prompt: dict[str, int] = \
371
get_field(MultiModalConfig, "limit_per_prompt")
372
+ media_io_kwargs: dict[str, dict[str,
373
+ Any]] = get_field(MultiModalConfig,
374
+ "media_io_kwargs")
375
+ mm_placeholder_str_override: dict[str, str] = \
376
+ get_field(MultiModalConfig, "mm_placeholder_str_override")
377
mm_processor_kwargs: Optional[Dict[str, Any]] = \
378
MultiModalConfig.mm_processor_kwargs
379
disable_mm_preprocessor_cache: bool = \
@@ -745,6 +750,11 @@ class EngineArgs:
750
)
751
multimodal_group.add_argument("--limit-mm-per-prompt",
752
**multimodal_kwargs["limit_per_prompt"])
753
+ multimodal_group.add_argument("--media-io-kwargs",
754
+ **multimodal_kwargs["media_io_kwargs"])
755
+ multimodal_group.add_argument(
756
+ "--mm-placeholder-str-override",
757
+ **multimodal_kwargs["mm_placeholder_str_override"])
758
multimodal_group.add_argument(
759
"--mm-processor-kwargs",
760
**multimodal_kwargs["mm_processor_kwargs"])
@@ -969,6 +979,8 @@ class EngineArgs:
979
enable_prompt_embeds=self.enable_prompt_embeds,
980
served_model_name=self.served_model_name,
981
limit_mm_per_prompt=self.limit_mm_per_prompt,
982
+ media_io_kwargs=self.media_io_kwargs,
983
+ mm_placeholder_str_override=self.mm_placeholder_str_override,
984
use_async_output_proc=not self.disable_async_output_proc,
985
config_format=self.config_format,
986
mm_processor_kwargs=self.mm_processor_kwargs,
vllm/entrypoints/chat_utils.py
+8
-3
@@ -507,6 +507,9 @@ class BaseMultiModalItemTracker(ABC, Generic[_T]):
507
508
def _placeholder_str(self, modality: ModalityStr,
509
current_count: int) -> Optional[str]:
510
+ if modality in self._model_config.mm_placeholder_str_override:
511
+ return self._model_config.mm_placeholder_str_override[modality]
512
+
513
# TODO: Let user specify how to insert image tokens into prompt
514
# (similar to chat template)
515
hf_config = self._model_config.hf_config
@@ -725,6 +728,7 @@ class MultiModalContentParser(BaseMultiModalContentParser):
728
self._tracker = tracker
729
730
self._connector = MediaConnector(
731
+ media_io_kwargs=self._tracker._model_config.media_io_kwargs,
732
allowed_local_media_path=tracker.allowed_local_media_path,
733
)
734
@@ -763,7 +767,7 @@ class MultiModalContentParser(BaseMultiModalContentParser):
767
return self.parse_audio(audio_url)
768
769
def parse_video(self, video_url: str) -> None:
766
- video = self._connector.fetch_video(video_url)
770
+ video = self._connector.fetch_video(video_url=video_url)
771
772
placeholder = self._tracker.add("video", video)
773
self._add_placeholder(placeholder)
@@ -776,7 +780,8 @@ class AsyncMultiModalContentParser(BaseMultiModalContentParser):
780
781
self._tracker = tracker
782
self._connector = MediaConnector(
779
- allowed_local_media_path=tracker.allowed_local_media_path,
783
+ media_io_kwargs=self._tracker._model_config.media_io_kwargs,
784
+ allowed_local_media_path=tracker.allowed_local_media_path
785
)
786
787
def parse_image(self, image_url: str) -> None:
@@ -818,7 +823,7 @@ class AsyncMultiModalContentParser(BaseMultiModalContentParser):
823
return self.parse_audio(audio_url)
824
825
def parse_video(self, video_url: str) -> None:
821
- video = self._connector.fetch_video_async(video_url)
826
+ video = self._connector.fetch_video_async(video_url=video_url)
827
828
placeholder = self._tracker.add("video", video)
829
self._add_placeholder(placeholder)
vllm/multimodal/audio.py
+10
@@ -83,6 +83,16 @@ class AudioResampler:
83
84
class AudioMediaIO(MediaIO[tuple[npt.NDArray, float]]):
85
86
+ def __init__(self, **kwargs) -> None:
87
+ super().__init__()
88
+
89
+ # `kwargs` contains custom arguments from
90
+ # --media-io-kwargs for this modality.
91
+ # They can be passed to the underlying
92
+ # media loaders (e.g. custom implementations)
93
+ # for flexible control.
94
+ self.kwargs = kwargs
95
+
96
def load_bytes(self, data: bytes) -> tuple[npt.NDArray, float]:
97
return librosa.load(BytesIO(data), sr=None)
98
vllm/multimodal/image.py
+7
-1
@@ -44,10 +44,16 @@ def convert_image_mode(image: Image.Image, to_mode: str):
44
45
class ImageMediaIO(MediaIO[Image.Image]):
46
47
- def __init__(self, *, image_mode: str = "RGB") -> None:
47
+ def __init__(self, image_mode: str = "RGB", **kwargs) -> None:
48
super().__init__()
49
50
self.image_mode = image_mode
51
+ # `kwargs` contains custom arguments from
52
+ # --media-io-kwargs for this modality.
53
+ # They can be passed to the underlying
54
+ # media loaders (e.g. custom implementations)
55
+ # for flexible control.
56
+ self.kwargs = kwargs
57
58
def load_bytes(self, data: bytes) -> Image.Image:
59
image = Image.open(BytesIO(data))
vllm/multimodal/utils.py
+17
-10
@@ -38,12 +38,15 @@ class MediaConnector:
38
39
def __init__(
40
self,
41
+ media_io_kwargs: Optional[dict[str, dict[str, Any]]] = None,
42
connection: HTTPConnection = global_http_connection,
43
*,
44
allowed_local_media_path: str = "",
45
) -> None:
46
super().__init__()
47
48
+ self.media_io_kwargs: dict[str, dict[
49
+ str, Any]] = media_io_kwargs if media_io_kwargs else {}
50
self.connection = connection
51
52
if allowed_local_media_path:
@@ -149,7 +152,7 @@ class MediaConnector:
152
"""
153
Load audio from a URL.
154
"""
152
- audio_io = AudioMediaIO()
155
+ audio_io = AudioMediaIO(**self.media_io_kwargs.get("audio", {}))
156
157
return self.load_from_url(
158
audio_url,
@@ -164,7 +167,7 @@ class MediaConnector:
167
"""
168
Asynchronously fetch audio from a URL.
169
"""
167
- audio_io = AudioMediaIO()
170
+ audio_io = AudioMediaIO(**self.media_io_kwargs.get("audio", {}))
171
172
return await self.load_from_url_async(
173
audio_url,
@@ -183,7 +186,8 @@ class MediaConnector:
186
187
By default, the image is converted into RGB format.
188
"""
186
- image_io = ImageMediaIO(image_mode=image_mode)
189
+ image_io = ImageMediaIO(image_mode=image_mode,
190
+ **self.media_io_kwargs.get("image", {}))
191
192
try:
193
return self.load_from_url(
@@ -206,7 +210,8 @@ class MediaConnector:
210
211
By default, the image is converted into RGB format.
212
"""
209
- image_io = ImageMediaIO(image_mode=image_mode)
213
+ image_io = ImageMediaIO(image_mode=image_mode,
214
+ **self.media_io_kwargs.get("image", {}))
215
216
try:
217
return await self.load_from_url_async(
@@ -223,13 +228,14 @@ class MediaConnector:
228
video_url: str,
229
*,
230
image_mode: str = "RGB",
226
- num_frames: int = 32,
231
) -> npt.NDArray:
232
"""
233
Load video from a HTTP or base64 data URL.
234
"""
231
- image_io = ImageMediaIO(image_mode=image_mode)
232
- video_io = VideoMediaIO(image_io, num_frames=num_frames)
235
+ image_io = ImageMediaIO(image_mode=image_mode,
236
+ **self.media_io_kwargs.get("image", {}))
237
+ video_io = VideoMediaIO(image_io,
238
+ **self.media_io_kwargs.get("video", {}))
239
240
return self.load_from_url(
241
video_url,
@@ -242,15 +248,16 @@ class MediaConnector:
248
video_url: str,
249
*,
250
image_mode: str = "RGB",
245
- num_frames: int = 32,
251
) -> npt.NDArray:
252
"""
253
Asynchronously load video from a HTTP or base64 data URL.
254
255
By default, the image is converted into RGB format.
256
"""
252
- image_io = ImageMediaIO(image_mode=image_mode)
253
- video_io = VideoMediaIO(image_io, num_frames=num_frames)
257
+ image_io = ImageMediaIO(image_mode=image_mode,
258
+ **self.media_io_kwargs.get("image", {}))
259
+ video_io = VideoMediaIO(image_io,
260
+ **self.media_io_kwargs.get("video", {}))
261
262
return await self.load_from_url_async(
263
video_url,
vllm/multimodal/video.py
+16
-4
@@ -54,7 +54,10 @@ class VideoLoader:
54
55
@classmethod
56
@abstractmethod
57
- def load_bytes(cls, data: bytes, num_frames: int = -1) -> npt.NDArray:
57
+ def load_bytes(cls,
58
+ data: bytes,
59
+ num_frames: int = -1,
60
+ **kwargs) -> npt.NDArray:
61
raise NotImplementedError
62
63
@@ -102,7 +105,8 @@ class OpenCVVideoBackend(VideoLoader):
105
@classmethod
106
def load_bytes(cls,
107
data: bytes,
105
- num_frames: int = -1) -> tuple[npt.NDArray, dict]:
108
+ num_frames: int = -1,
109
+ **kwargs) -> npt.NDArray:
110
import cv2
111
112
backend = cls().get_cv2_video_api()
@@ -159,18 +163,26 @@ class VideoMediaIO(MediaIO[npt.NDArray]):
163
def __init__(
164
self,
165
image_io: ImageMediaIO,
162
- *,
166
num_frames: int = 32,
167
+ **kwargs,
168
) -> None:
169
super().__init__()
170
171
self.image_io = image_io
172
self.num_frames = num_frames
173
+ # `kwargs` contains custom arguments from
174
+ # --media-io-kwargs for this modality.
175
+ # They can be passed to the underlying
176
+ # media loaders (e.g. custom implementations)
177
+ # for flexible control.
178
+ self.kwargs = kwargs
179
video_loader_backend = envs.VLLM_VIDEO_LOADER_BACKEND
180
self.video_loader = VIDEO_LOADER_REGISTRY.load(video_loader_backend)
181
182
def load_bytes(self, data: bytes) -> npt.NDArray:
173
- return self.video_loader.load_bytes(data, self.num_frames)
183
+ return self.video_loader.load_bytes(data,
184
+ num_frames=self.num_frames,
185
+ **self.kwargs)
186
187
def load_base64(self, media_type: str, data: str) -> npt.NDArray:
188
if media_type.lower() == "video/jpeg":