[Refactor] Use dynamic MM cache in processor (#57674)
Signed-off-by: DarkLight1337 <tlleungac@connect.ust.hk>
Cyrus Leung committed
Sep 19, 2026 at 23:38 UTC
751f6807d9cb3de50c27a5f27188c4fb04fe0e2b
23 files changed
+340
-398
tests/models/multimodal/processing/test_audio_in_video.py
+10
-14
@@ -63,36 +63,32 @@ def test_audio_in_video_cache_correctness(model_id: str, num_videos: int) -> Non
63
mm_processor_cache_gb=1,
64
)
65
66
- # Baseline: no cache, always processes from scratch.
67
- baseline_processor = MULTIMODAL_REGISTRY.create_processor(
68
- ctx.model_config, cache=None
69
- )
66
+ processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
67
+
68
# Sender cache: on a cache hit returns (None, prompt_updates) for each
69
# item, setting mm_kwargs["video"] = [None] – the exact condition that
70
# triggered the original bug.
71
sender_cache = MultiModalProcessorSenderCache(ctx.model_config)
74
- cached_processor = MULTIMODAL_REGISTRY.create_processor(
75
- ctx.model_config, cache=sender_cache
76
- )
72
78
- video_token_id = baseline_processor.info.get_hf_config().video_token_id
73
+ video_token_id = processor.info.get_hf_config().video_token_id
74
75
mm_data = create_mm_data(num_videos)
76
hf_processor_mm_kwargs = {"use_audio_in_video": True}
77
83
- def run(processor):
78
+ def run(processor, cache):
79
return processor(
80
[video_token_id] * num_videos,
86
- mm_items=baseline_processor.info.parse_mm_data(mm_data),
81
+ mm_items=processor.info.parse_mm_data(mm_data),
82
hf_processor_mm_kwargs=hf_processor_mm_kwargs,
83
+ cache=cache,
84
)["prompt_token_ids"]
85
90
- baseline_ids = run(baseline_processor)
86
+ baseline_ids = run(processor, cache=None)
87
88
# First call on the sender-cache processor: cache miss.
89
# mm_kwargs["video"] items are real tensors; use_audio_in_video is
90
# detected normally from the item data.
95
- first_ids = run(cached_processor)
91
+ first_ids = run(processor, cache=sender_cache)
92
assert first_ids == baseline_ids, (
93
"Cache-miss call produced different prompt_token_ids than baseline.\n"
94
f" baseline : {baseline_ids}\n"
@@ -103,7 +99,7 @@ def test_audio_in_video_cache_correctness(model_id: str, num_videos: int) -> Non
99
# MultiModalProcessorSenderCache.get_and_update_item returns (None, …),
100
# so mm_kwargs["video"] = [None]. Before the fix, use_audio_in_video was
101
# not detected, yielding wrong token ids.
106
- second_ids = run(cached_processor)
102
+ second_ids = run(processor, cache=sender_cache)
103
assert second_ids == baseline_ids, (
104
"Cache-hit call produced different prompt_token_ids than baseline.\n"
105
"This is the regression introduced when use_audio_in_video detection\n"
@@ -123,7 +119,7 @@ def test_use_audio_in_video_without_audio_track(model_id: str) -> None:
119
model_id,
120
limit_mm_per_prompt={"audio": 1, "image": 0, "video": 1},
121
)
126
- processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config, cache=None)
122
+ processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
123
video_token_id = processor.info.get_hf_config().video_token_id
124
125
rng = np.random.RandomState(0)
tests/models/multimodal/processing/test_common.py
+12
-10
@@ -72,6 +72,8 @@ def glmasr_patch_mm_data(mm_data: MultiModalDataDict) -> MultiModalDataDict:
72
73
74
_IGNORE_MM_KEYS = {
75
+ # Dithering causes minor divergence
76
+ "cohere_asr": {"input_features"},
77
# In Ultravox, the audio_features can be different depending on padding
78
# The slight difference should not be a problem though, since
79
# attention_mask lets us ignore the difference.
@@ -286,8 +288,7 @@ def _test_processing_correctness(
288
}
289
)
290
289
- baseline_processor = factories.build_processor(ctx, cache=None)
290
- cached_processor = factories.build_processor(ctx, cache=cache)
291
+ processor = factories.build_processor(ctx)
292
293
rng = np.random.RandomState(0)
294
@@ -336,41 +337,42 @@ def _test_processing_correctness(
337
_test_processing_correctness_one(
338
model_config,
339
mm_data,
339
- baseline_processor,
340
- cached_processor,
340
+ processor,
341
batch_idx,
342
hit_rate,
343
num_batches,
344
simplify_rate,
345
+ cache=cache,
346
)
347
348
349
def _test_processing_correctness_one(
350
model_config: ModelConfig,
351
mm_data: MultiModalDataDict,
351
- baseline_processor: BaseMultiModalProcessor,
352
- cached_processor: BaseMultiModalProcessor,
352
+ processor: BaseMultiModalProcessor,
353
batch_idx: int,
354
hit_rate: float,
355
num_batches: int,
356
simplify_rate: float,
357
+ cache: MultiModalProcessorOnlyCache,
358
):
359
model_type = model_config.hf_config.model_type
360
360
- token_prompt = get_token_prompt(baseline_processor, mm_data)
361
- mm_items = baseline_processor.info.parse_mm_data(mm_data)
361
+ token_prompt = get_token_prompt(processor, mm_data)
362
+ mm_items = processor.info.parse_mm_data(mm_data)
363
ignore_mm_keys = _IGNORE_MM_KEYS.get(model_type, set[str]())
364
364
- baseline_tokenized_result = baseline_processor(
365
+ baseline_tokenized_result = processor(
366
token_prompt,
367
mm_items=mm_items,
368
hf_processor_mm_kwargs={},
369
)
370
370
- cached_tokenized_result = cached_processor(
371
+ cached_tokenized_result = processor(
372
token_prompt,
373
mm_items=mm_items,
374
hf_processor_mm_kwargs={},
375
+ cache=cache,
376
)
377
378
_assert_inputs_equal(
tests/models/multimodal/processing/test_gemma4.py
+8
-7
@@ -221,7 +221,7 @@ def test_video_cache_is_independent_of_image_kwargs(
221
mm_processor_cache_gb=1,
222
)
223
cache = MultiModalProcessorOnlyCache(ctx.model_config)
224
- processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config, cache=cache)
224
+ processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
225
hf_processor = processor.info.get_hf_processor()
226
image = PILImage.new("RGB", (48, 48), color=(128, 128, 128))
227
frames = np.stack([np.asarray(image)] * 2)
@@ -230,15 +230,16 @@ def test_video_cache_is_independent_of_image_kwargs(
230
{"image": image, "video": [(frames, metadata)]}
231
)
232
233
- def process(mm_kwargs):
233
+ def process(mm_kwargs, cache):
234
return processor(
235
hf_processor.image_token + hf_processor.video_token,
236
mm_items,
237
mm_uuid_items={"video": [video_uuid]},
238
hf_processor_mm_kwargs=mm_kwargs,
239
+ cache=cache,
240
)
241
241
- baseline = process({})
242
+ baseline = process({}, cache=None)
243
if kwargs_on_init:
244
ctx = build_model_context(
245
model_id,
@@ -247,12 +248,12 @@ def test_video_cache_is_independent_of_image_kwargs(
248
mm_processor_cache_gb=1,
249
)
250
cache = MultiModalProcessorOnlyCache(ctx.model_config)
250
- processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config, cache=cache)
251
+ processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
252
request_kwargs = {} if kwargs_on_init else kwargs
252
- process(request_kwargs)
253
- cached = process(request_kwargs)
253
+ process(request_kwargs, cache=cache)
254
+ cached = process(request_kwargs, cache=cache)
255
cache.clear_cache()
255
- fresh = process(request_kwargs)
256
+ fresh = process(request_kwargs, cache=cache)
257
258
def pixels(result, modality, field):
259
return result["mm_kwargs"][modality][0][field].data
tests/models/multimodal/processing/test_mllama4.py
+2
-4
@@ -23,11 +23,9 @@ def test_profiling(model_id: str, max_model_len: int):
23
model_config_kwargs=model_config_kwargs,
24
limit_mm_per_prompt=mm_counts,
25
)
26
+ processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
27
27
- mm_inputs = MULTIMODAL_REGISTRY.get_dummy_mm_inputs(
28
- ctx.model_config,
29
- mm_counts=mm_counts,
30
- )
28
+ mm_inputs = processor.get_dummy_mm_inputs(mm_counts)
29
30
hf_config = ctx.get_hf_config(Llama4Config)
31
image_size = hf_config.vision_config.image_size
tests/models/multimodal/processing/test_moss_audio.py
+4
-2
@@ -168,7 +168,6 @@ def _build_moss_audio_processor(cache=None):
168
MossAudioMultiModalProcessor(
169
info,
170
MossAudioDummyInputsBuilder(info),
171
- cache=cache,
171
),
172
ctx,
173
)
@@ -259,21 +258,24 @@ def test_moss_audio_multimodal_processor_handles_token_and_cache_paths():
258
)
259
260
cache = MultiModalProcessorOnlyCache(ctx.model_config)
262
- cached_processor, _ = _build_moss_audio_processor(cache=cache)
261
+ cached_processor, _ = _build_moss_audio_processor()
262
cached_text_miss = cached_processor(
263
prompt,
264
mm_items=mm_items,
265
hf_processor_mm_kwargs={},
266
+ cache=cache,
267
)
268
cached_text_hit = cached_processor(
269
prompt,
270
mm_items=mm_items,
271
hf_processor_mm_kwargs={},
272
+ cache=cache,
273
)
274
cached_token_hit = cached_processor(
275
token_prompt,
276
mm_items=mm_items,
277
hf_processor_mm_kwargs={},
278
+ cache=cache,
279
)
280
281
expected_audio_tokens = MossAudioEncoder.compute_num_audio_tokens(raw_mel_len)
tests/models/multimodal/processing/test_qwen2_vl.py
+1
-1
@@ -26,7 +26,6 @@ def test_jina_vl_processing_order() -> None:
26
processor = MULTIMODAL_REGISTRY.create_processor(
27
ctx.model_config,
28
tokenizer=ctx.tokenizer,
29
- cache=cache,
29
)
30
31
placeholder = "<|vision_start|><|image_pad|><|vision_end|>"
@@ -37,6 +36,7 @@ def test_jina_vl_processing_order() -> None:
36
return processor(
37
placeholder * len(images),
38
mm_items=processor.info.parse_mm_data({"image": images}),
39
+ cache=cache,
40
)
41
42
query = process([query_image])
tests/models/multimodal/processing/test_tensor_schema.py
+1
-1
@@ -255,7 +255,7 @@ def test_model_tensor_schema(model_id: str):
255
for modality, count in limit_mm_per_prompt.items()
256
}
257
)
258
- processor = factories.build_processor(ctx, cache=None)
258
+ processor = factories.build_processor(ctx)
259
260
with initialize_dummy_model(model_cls, model_config) as model:
261
for modality, _, mm_kwargs in create_batched_mm_kwargs(model_config, processor):
tests/models/multimodal/processing/test_transformers_image.py
+1
@@ -245,6 +245,7 @@ def test_repeated_image_hits_the_processor_cache():
245
prompt="<image>\nWhat is this?",
246
mm_items=mm_processor.info.parse_mm_data({"image": image}),
247
hf_processor_mm_kwargs={},
248
+ cache=cache,
249
)
250
251
first, second = process(), process()
tests/models/multimodal/processing/transformers_backend.py
+1
-1
@@ -48,4 +48,4 @@ def create_cached_processor(model_id: str, processor_cls):
48
)
49
info = MultiModalProcessingInfo(ctx)
50
cache = MultiModalProcessorOnlyCache(model_config)
51
- return processor_cls(info, MultiModalDummyInputsBuilder(info), cache=cache), cache
51
+ return processor_cls(info, MultiModalDummyInputsBuilder(info)), cache
tests/multimodal/test_cache.py
+34
-35
@@ -1,8 +1,6 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
import multiprocessing as mp
4
-from concurrent.futures import ThreadPoolExecutor
5
-from functools import partial
4
from types import SimpleNamespace
5
from unittest.mock import Mock
6
@@ -37,10 +35,13 @@ from vllm.multimodal.inputs import (
35
PlaceholderRange,
36
)
37
from vllm.multimodal.processing import PromptInsertion
40
-from vllm.renderers.base import BaseRenderer
38
+from vllm.renderers import renderer_from_config
39
from vllm.utils.async_utils import make_async
40
from vllm.utils.mem_constants import GiB_bytes, MiB_bytes
41
+from vllm.v1.engine.async_llm import AsyncLLM
42
+from vllm.v1.engine.llm_engine import LLMEngine
43
44
+from ..models.utils import build_model_context
45
from ..utils import create_new_process_for_each_test
46
47
pytestmark = pytest.mark.cpu_test
@@ -773,12 +774,14 @@ def test_processor_cache_shared_across_loras():
774
@pytest.mark.asyncio
775
async def test_release_kv_cache_resends_mm_payload(use_async, release_error):
776
"""Release must not leave a sender hit pointing at a cleared receiver."""
776
- from vllm.v1.engine.async_llm import AsyncLLM
777
- from vllm.v1.engine.llm_engine import LLMEngine
778
-
779
- model_config = SimpleNamespace(
780
- get_multimodal_config=lambda: MultiModalConfig(mm_processor_cache_gb=1)
777
+ ctx = build_model_context(
778
+ "llava-hf/llava-v1.6-mistral-7b-hf",
779
+ mm_processor_kwargs=None,
780
+ limit_mm_per_prompt={"image": 1},
781
+ mm_processor_cache_gb=1,
782
)
783
+ model_config = ctx.model_config
784
+
785
sender = MultiModalProcessorSenderCache(model_config)
786
receiver = MultiModalReceiverCache(model_config)
787
item = _dummy_item({"pixel_values": 16})
@@ -787,43 +790,39 @@ async def test_release_kv_cache_resends_mm_payload(use_async, release_error):
790
assert receiver.get_and_update_item(payload, mm_hash) is item
791
assert sender.get_and_update_item(None, mm_hash)[0] is None
792
790
- renderer = SimpleNamespace(mm_processor_cache=sender, _mm_cache_stats=None)
791
- renderer.clear_mm_cache = partial(BaseRenderer.clear_mm_cache, renderer)
793
+ renderer = renderer_from_config(VllmConfig(model_config=model_config))
794
+ renderer._mm_processor_cache = sender
795
796
def release():
797
if release_error:
798
raise RuntimeError(release_error)
799
receiver.clear_cache()
800
798
- with ThreadPoolExecutor(max_workers=1) as executor:
799
- renderer._clear_mm_cache_async = make_async(
800
- renderer.clear_mm_cache, executor=executor
801
- )
802
- renderer.clear_mm_cache_async = partial(
803
- BaseRenderer.clear_mm_cache_async, renderer
804
- )
805
- engine = SimpleNamespace(
806
- renderer=renderer,
807
- engine_core=SimpleNamespace(
808
- release_kv_cache_memory=release,
809
- release_kv_cache_memory_async=make_async(release, executor=executor),
801
+ engine = SimpleNamespace(
802
+ renderer=renderer,
803
+ engine_core=SimpleNamespace(
804
+ release_kv_cache_memory=release,
805
+ release_kv_cache_memory_async=make_async(
806
+ release,
807
+ executor=renderer._executor,
808
),
811
- logger_manager=Mock(),
812
- )
813
-
814
- async def call_release():
815
- if use_async:
816
- await AsyncLLM.release_kv_cache_memory(engine)
817
- else:
818
- LLMEngine.release_kv_cache_memory(engine)
809
+ ),
810
+ logger_manager=Mock(),
811
+ )
812
820
- if release_error:
821
- with pytest.raises(RuntimeError, match=release_error):
822
- await call_release()
823
- engine.logger_manager.record_sleep_state.assert_not_called()
813
+ async def call_release():
814
+ if use_async:
815
+ await AsyncLLM.release_kv_cache_memory(engine)
816
else:
817
+ LLMEngine.release_kv_cache_memory(engine)
818
+
819
+ if release_error:
820
+ with pytest.raises(RuntimeError, match=release_error):
821
await call_release()
826
- engine.logger_manager.record_sleep_state.assert_called_once_with(1, 0)
822
+ engine.logger_manager.record_sleep_state.assert_not_called()
823
+ else:
824
+ await call_release()
825
+ engine.logger_manager.record_sleep_state.assert_called_once_with(1, 0)
826
827
payload, _ = sender.get_and_update_item((item, []), mm_hash)
828
assert payload is item
tests/multimodal/test_processing.py
+36
-1
@@ -7,7 +7,7 @@ from contextlib import nullcontext
7
import numpy as np
8
import pytest
9
10
-from vllm.config import ModelConfig
10
+from vllm.config import ModelConfig, SchedulerConfig
11
from vllm.exceptions import VLLMValidationError
12
from vllm.multimodal import MULTIMODAL_REGISTRY
13
from vllm.multimodal.hasher import MultiModalHasher
@@ -32,6 +32,7 @@ from vllm.multimodal.processing.processor import (
32
)
33
from vllm.utils.collection_utils import flatten_2d_lists
34
35
+from ..models.utils import build_model_context
36
from .utils import random_image
37
38
pytestmark = pytest.mark.cpu_test
@@ -1361,3 +1362,37 @@ def test_processor_inputs_hashes_distinguish_kwargs_shapes(left, right):
1362
).get_mm_hashes("test-model", "blake3")["image"][0]
1363
1364
assert hash_with(left) != hash_with(right)
1365
+
1366
+
1367
+@pytest.mark.parametrize(
1368
+ ("chunked_prefill", "max_model_len", "expected_seq_len"),
1369
+ [(None, 491520, 491520), (True, 491520, 8192), (True, 128, 128), (False, 128, 128)],
1370
+)
1371
+def test_dummy_inputs_scheduler_budget(
1372
+ chunked_prefill, max_model_len, expected_seq_len
1373
+):
1374
+ ctx = build_model_context(
1375
+ "llava-hf/llava-v1.6-mistral-7b-hf",
1376
+ mm_processor_kwargs=None,
1377
+ limit_mm_per_prompt={"image": 1},
1378
+ )
1379
+ ctx.model_config.max_model_len = max_model_len
1380
+
1381
+ processor = MULTIMODAL_REGISTRY.create_processor(
1382
+ ctx.model_config,
1383
+ tokenizer=ctx.tokenizer,
1384
+ )
1385
+ processor.apply = lambda *args, **kwargs: {"prompt_token_ids": [7]}
1386
+
1387
+ kwargs = {}
1388
+ if chunked_prefill is not None:
1389
+ kwargs["scheduler_config"] = SchedulerConfig(
1390
+ max_model_len=max_model_len,
1391
+ is_encoder_decoder=False,
1392
+ max_num_batched_tokens=8192,
1393
+ max_num_seqs=1,
1394
+ enable_chunked_prefill=chunked_prefill,
1395
+ )
1396
+
1397
+ result = processor.get_dummy_mm_inputs({"image": 1}, **kwargs)
1398
+ assert len(result["prompt_token_ids"]) == expected_seq_len
tests/multimodal/test_registry.py
-30
@@ -5,11 +5,9 @@ Qwen2.5-VL visual component loading behavior.
5
"""
6
7
from types import SimpleNamespace
8
-from unittest.mock import MagicMock
8
9
import pytest
10
12
-from vllm.config import SchedulerConfig
11
from vllm.multimodal import MULTIMODAL_REGISTRY
12
13
from ..models.utils import build_model_context
@@ -17,34 +15,6 @@ from ..models.utils import build_model_context
15
pytestmark = pytest.mark.cpu_test
16
17
20
-@pytest.mark.parametrize(
21
- ("chunked_prefill", "max_model_len", "expected_seq_len"),
22
- [(None, 491520, 491520), (True, 491520, 8192), (True, 128, 128), (False, 128, 128)],
23
-)
24
-def test_dummy_inputs_scheduler_budget(
25
- chunked_prefill, max_model_len, expected_seq_len
26
-):
27
- model_config = MagicMock()
28
- model_config.max_model_len = max_model_len
29
- processor = MagicMock()
30
- processor.apply.return_value = {"prompt_token_ids": [7]}
31
- kwargs = {}
32
- if chunked_prefill is not None:
33
- kwargs["scheduler_config"] = SchedulerConfig(
34
- max_model_len=max_model_len,
35
- is_encoder_decoder=False,
36
- max_num_batched_tokens=8192,
37
- max_num_seqs=1,
38
- enable_chunked_prefill=chunked_prefill,
39
- )
40
- result = MULTIMODAL_REGISTRY.get_dummy_mm_inputs(
41
- model_config, {"image": 1}, processor=processor, **kwargs
42
- )
43
- get_inputs = processor.dummy_inputs.get_dummy_processor_inputs
44
- assert get_inputs.call_args.kwargs["seq_len"] == expected_seq_len
45
- assert len(result["prompt_token_ids"]) == expected_seq_len
46
-
47
-
18
@pytest.mark.parametrize(
19
"model_id,limit_mm_per_prompt,expected",
20
[
tests/renderers/test_warmup.py
+82
-90
@@ -5,6 +5,8 @@
5
These tests exercise:
6
- Zero-limit modalities are filtered from mm_counts passed to
7
get_dummy_processor_inputs (e.g. --limit-mm-per-prompt image=0 ...)
8
+ - The warmup runs through the processor-only cache and clears it afterwards,
9
+ so its dummy inputs never land in the sender cache used by the serving path
10
- MM warmup is skipped entirely when mm_processor is None
11
- The multimodal warmup is launched as a task on the single-worker
12
_mm_executor to overlap engine-core init (future lifecycle, join by
@@ -21,10 +23,13 @@ that acts as the renderer instance.
23
"""
24
25
from concurrent.futures import Future, ThreadPoolExecutor
26
+from types import MethodType, SimpleNamespace
27
from unittest.mock import MagicMock, patch
28
29
import pytest
30
31
+from vllm.entrypoints.chat_utils import ChatTemplateResolutionError
32
+from vllm.multimodal.processing import BaseMultiModalProcessor
33
from vllm.renderers.base import BaseRenderer
34
from vllm.renderers.params import ChatParams
35
@@ -34,9 +39,11 @@ def _make_renderer_mock(mm_limits: dict[str, int]) -> MagicMock:
39
40
render_chat is mocked to raise ChatTemplateResolutionError so the chat
41
warmup block is skipped cleanly, keeping the test focused on MM warmup.
37
- """
38
- from vllm.entrypoints.chat_utils import ChatTemplateResolutionError
42
43
+ The processor is a mock at its I/O boundaries (``dummy_inputs`` and
44
+ ``apply``) but keeps the real ``get_dummy_mm_inputs``, since that is where
45
+ the warmup's seq_len capping and cache plumbing actually live.
46
+ """
47
renderer = MagicMock()
48
49
# chat warmup: make render_chat raise so we skip past it cleanly
@@ -45,13 +52,17 @@ def _make_renderer_mock(mm_limits: dict[str, int]) -> MagicMock:
52
# MM processor with configurable limits
53
mm_processor = MagicMock()
54
mm_processor.info.allowed_mm_limits = mm_limits
55
+ mm_processor.info.ctx.model_config = renderer.model_config
56
mm_processor.apply.return_value = {"prompt_token_ids": [1]}
57
+ mm_processor.get_dummy_mm_inputs = MethodType(
58
+ BaseMultiModalProcessor.get_dummy_mm_inputs, mm_processor
59
+ )
60
renderer.mm_processor = mm_processor
50
- renderer._readonly_mm_processor = None
61
+ renderer._mm_processor_cache = MagicMock(name="sender_cache")
62
+ renderer._mm_processor_only_cache = MagicMock(name="processor_only_cache")
63
renderer._warmup_mm_processor = BaseRenderer._warmup_mm_processor.__get__(
64
renderer, BaseRenderer
65
)
54
- renderer._clear_processor_cache = BaseRenderer._clear_processor_cache
66
renderer.warmup_mm = BaseRenderer.warmup_mm.__get__(renderer, BaseRenderer)
67
renderer.start_mm_warmup_in_background = (
68
BaseRenderer.start_mm_warmup_in_background.__get__(renderer, BaseRenderer)
@@ -64,7 +75,6 @@ def _make_renderer_mock(mm_limits: dict[str, int]) -> MagicMock:
75
renderer._mm_warmup_future = None
76
# MM warmup has not run yet; warmup_mm must actually execute on the mock.
77
renderer._mm_warmup_done = False
67
- renderer.clear_mm_cache = MagicMock()
78
renderer.model_config.max_model_len = 128
79
renderer.config.scheduler_config.max_num_batched_tokens = 8192
80
renderer.config.scheduler_config.enable_chunked_prefill = True
@@ -80,8 +90,7 @@ class TestMmWarmupZeroLimitFiltering:
90
"""A modality with limit=0 must not appear in mm_counts."""
91
renderer = _make_renderer_mock({"image": 1, "video": 0})
92
83
- with patch("vllm.multimodal.processing.TimingContext", autospec=True):
84
- BaseRenderer.warmup(renderer, ChatParams())
93
+ BaseRenderer.warmup(renderer, ChatParams())
94
95
get_inputs = renderer.mm_processor.dummy_inputs.get_dummy_processor_inputs
96
get_inputs.assert_called_once()
@@ -93,8 +102,7 @@ class TestMmWarmupZeroLimitFiltering:
102
"""When all limits are 0, mm_counts must be empty."""
103
renderer = _make_renderer_mock({"image": 0, "video": 0})
104
96
- with patch("vllm.multimodal.processing.TimingContext", autospec=True):
97
- BaseRenderer.warmup(renderer, ChatParams())
105
+ BaseRenderer.warmup(renderer, ChatParams())
106
107
get_inputs = renderer.mm_processor.dummy_inputs.get_dummy_processor_inputs
108
get_inputs.assert_called_once()
@@ -105,8 +113,7 @@ class TestMmWarmupZeroLimitFiltering:
113
"""All modalities with limit > 0 must be present in mm_counts."""
114
renderer = _make_renderer_mock({"image": 2, "video": 1})
115
108
- with patch("vllm.multimodal.processing.TimingContext", autospec=True):
109
- BaseRenderer.warmup(renderer, ChatParams())
116
+ BaseRenderer.warmup(renderer, ChatParams())
117
118
get_inputs = renderer.mm_processor.dummy_inputs.get_dummy_processor_inputs
119
get_inputs.assert_called_once()
@@ -125,8 +132,7 @@ class TestMmWarmupRunsNormally:
132
renderer = _make_renderer_mock({"image": 1, "video": 1})
133
renderer.model_config.max_model_len = max_model_len
134
128
- with patch("vllm.multimodal.processing.TimingContext", autospec=True):
129
- BaseRenderer.warmup(renderer, ChatParams())
135
+ BaseRenderer.warmup(renderer, ChatParams())
136
137
get_inputs = renderer.mm_processor.dummy_inputs.get_dummy_processor_inputs
138
assert get_inputs.call_args.kwargs["seq_len"] == expected_seq_len
@@ -134,26 +140,44 @@ class TestMmWarmupRunsNormally:
140
def test_processor_apply_called(self):
141
renderer = _make_renderer_mock({"image": 1})
142
137
- with patch("vllm.multimodal.processing.TimingContext", autospec=True):
138
- BaseRenderer.warmup(renderer, ChatParams())
143
+ BaseRenderer.warmup(renderer, ChatParams())
144
145
renderer.mm_processor.apply.assert_called_once()
146
142
- def test_mm_cache_cleared_after_warmup(self):
147
+ def test_render_chat_called_with_warmup_message(self):
148
renderer = _make_renderer_mock({"image": 1})
149
145
- with patch("vllm.multimodal.processing.TimingContext", autospec=True):
146
- BaseRenderer.warmup(renderer, ChatParams())
150
+ BaseRenderer.warmup(renderer, ChatParams())
151
148
- renderer.clear_mm_cache.assert_called_once()
152
+ renderer.render_chat.assert_called_once()
153
150
- def test_render_chat_called_with_warmup_message(self):
154
+
155
+class TestMmWarmupCacheRouting:
156
+ # The warmup runs through the processor-only cache and drops it afterwards,
157
+ # so the dummy inputs never reach the sender cache used by the serving path.
158
+
159
+ def test_warmup_passes_processor_only_cache(self):
160
renderer = _make_renderer_mock({"image": 1})
161
153
- with patch("vllm.multimodal.processing.TimingContext", autospec=True):
162
+ with patch.object(
163
+ renderer.mm_processor,
164
+ "get_dummy_mm_inputs",
165
+ wraps=renderer.mm_processor.get_dummy_mm_inputs,
166
+ ) as spy:
167
BaseRenderer.warmup(renderer, ChatParams())
168
156
- renderer.render_chat.assert_called_once()
169
+ assert spy.call_args.kwargs["cache"] is renderer._mm_processor_only_cache
170
+ assert (
171
+ spy.call_args.kwargs["scheduler_config"] is renderer.config.scheduler_config
172
+ )
173
+
174
+ def test_only_cache_cleared_and_sender_cache_untouched(self):
175
+ renderer = _make_renderer_mock({"image": 1})
176
+
177
+ BaseRenderer.warmup(renderer, ChatParams())
178
+
179
+ renderer._mm_processor_only_cache.clear_cache.assert_called_once()
180
+ renderer._mm_processor_cache.clear_cache.assert_not_called()
181
182
183
class TestMmWarmupSkippedWhenNoProcessor:
@@ -168,51 +192,30 @@ class TestMmWarmupSkippedWhenNoProcessor:
192
renderer.model_config.get_multimodal_config.assert_not_called()
193
194
171
-class TestReadonlyMmWarmup:
172
- """Readonly MM processor warmup must mirror the render path behavior."""
173
-
174
- def test_readonly_processor_apply_called_and_cache_cleared(self):
175
- renderer = _make_renderer_mock({"image": 1})
176
- readonly_mm_processor = MagicMock()
177
- readonly_mm_processor.info.allowed_mm_limits = {"image": 1}
178
- renderer._readonly_mm_processor = readonly_mm_processor
179
-
180
- with patch("vllm.multimodal.processing.TimingContext", autospec=True):
181
- BaseRenderer.warmup(renderer, ChatParams())
182
-
183
- readonly_mm_processor.apply.assert_called_once()
184
- readonly_mm_processor.cache.clear_cache.assert_called_once()
185
-
186
-
195
class TestWarmupFaultIsolation:
188
- # A failure during a multimodal processor warmup is caught so it does not
189
- # abort the remaining warmup steps; warmup itself must not raise.
196
+ # A failure during a warmup step is caught so it does not abort the other
197
+ # step; warmup itself must not raise.
198
199
def test_chat_failure_does_not_abort_mm_warmup(self):
200
renderer = _make_renderer_mock({"image": 1})
201
renderer.render_chat.side_effect = RuntimeError("chat boom")
202
195
- with patch("vllm.multimodal.processing.TimingContext", autospec=True):
196
- BaseRenderer.warmup(renderer, ChatParams()) # must not raise
203
+ BaseRenderer.warmup(renderer, ChatParams()) # must not raise
204
205
renderer.mm_processor.apply.assert_called_once()
206
200
- def test_mm_failure_does_not_abort_readonly_warmup(self):
207
+ def test_mm_failure_is_swallowed_and_cache_cleared(self):
208
renderer = _make_renderer_mock({"image": 1})
202
- readonly_mm_processor = MagicMock()
203
- readonly_mm_processor.info.allowed_mm_limits = {"image": 1}
204
- renderer._readonly_mm_processor = readonly_mm_processor
205
- # main processor warmup blows up before apply()
209
+ # the warmup blows up before apply()
210
renderer.mm_processor.dummy_inputs.get_dummy_processor_inputs.side_effect = (
211
RuntimeError("mm boom")
212
)
213
210
- with patch("vllm.multimodal.processing.TimingContext", autospec=True):
211
- BaseRenderer.warmup(renderer, ChatParams()) # must not raise
214
+ BaseRenderer.warmup(renderer, ChatParams()) # must not raise
215
213
- readonly_mm_processor.apply.assert_called_once()
214
- # cache is still cleared in the failed task's finally
215
- renderer.clear_mm_cache.assert_called_once()
216
+ renderer.mm_processor.apply.assert_not_called()
217
+ # the cache is still cleared in the failed warmup's finally
218
+ renderer._mm_processor_only_cache.clear_cache.assert_called_once()
219
220
221
class TestBackgroundMmWarmup:
@@ -234,16 +237,15 @@ class TestBackgroundMmWarmup:
237
def test_start_submits_future_to_mm_executor(self):
238
renderer = self._make_bg_renderer({"image": 1})
239
try:
237
- with patch("vllm.multimodal.processing.TimingContext", autospec=True):
238
- # Spy on the real executor's submit to confirm the warmup is
239
- # dispatched through _mm_executor (not a separate Thread).
240
- with patch.object(
241
- renderer._mm_executor, "submit", wraps=renderer._mm_executor.submit
242
- ) as spy:
243
- renderer.start_mm_warmup_in_background()
244
- assert isinstance(renderer._mm_warmup_future, Future)
245
- assert spy.called
246
- renderer._join_mm_warmup()
240
+ # Spy on the real executor's submit to confirm the warmup is
241
+ # dispatched through _mm_executor (not a separate Thread).
242
+ with patch.object(
243
+ renderer._mm_executor, "submit", wraps=renderer._mm_executor.submit
244
+ ) as spy:
245
+ renderer.start_mm_warmup_in_background()
246
+ assert isinstance(renderer._mm_warmup_future, Future)
247
+ assert spy.called
248
+ renderer._join_mm_warmup()
249
assert renderer._mm_warmup_future is None
250
renderer.mm_processor.apply.assert_called_once()
251
finally:
@@ -252,7 +254,6 @@ class TestBackgroundMmWarmup:
254
def test_start_noop_for_text_only_model(self):
255
renderer = _make_renderer_mock({})
256
renderer.mm_processor = None
255
- # _readonly_mm_processor is already None
257
258
renderer.start_mm_warmup_in_background()
259
@@ -261,12 +262,11 @@ class TestBackgroundMmWarmup:
262
def test_start_is_run_at_most_once(self):
263
renderer = self._make_bg_renderer({"image": 1})
264
try:
264
- with patch("vllm.multimodal.processing.TimingContext", autospec=True):
265
- renderer.start_mm_warmup_in_background()
266
- first = renderer._mm_warmup_future
267
- renderer.start_mm_warmup_in_background() # must not spawn a second
268
- assert renderer._mm_warmup_future is first
269
- renderer._join_mm_warmup()
265
+ renderer.start_mm_warmup_in_background()
266
+ first = renderer._mm_warmup_future
267
+ renderer.start_mm_warmup_in_background() # must not spawn a second
268
+ assert renderer._mm_warmup_future is first
269
+ renderer._join_mm_warmup()
270
finally:
271
renderer._mm_executor.shutdown(wait=True)
272
@@ -275,9 +275,8 @@ class TestBackgroundMmWarmup:
275
# run only the chat warmup — the MM warmup must not run twice.
276
renderer = self._make_bg_renderer({"image": 1})
277
try:
278
- with patch("vllm.multimodal.processing.TimingContext", autospec=True):
279
- renderer.start_mm_warmup_in_background()
280
- BaseRenderer.warmup(renderer, ChatParams())
278
+ renderer.start_mm_warmup_in_background()
279
+ BaseRenderer.warmup(renderer, ChatParams())
280
281
# MM apply called exactly once (by the background warmup task).
282
renderer.mm_processor.apply.assert_called_once()
@@ -294,13 +293,12 @@ class TestBackgroundMmWarmup:
293
# re-run the MM warmup — the _mm_warmup_done flag survives the join.
294
renderer = self._make_bg_renderer({"image": 1})
295
try:
297
- with patch("vllm.multimodal.processing.TimingContext", autospec=True):
298
- renderer.start_mm_warmup_in_background()
299
- # Simulate reset_mm_cache joining the background warmup first
300
- # (clears _mm_warmup_future, sets _mm_warmup_done via the join).
301
- renderer._join_mm_warmup()
302
- assert renderer._mm_warmup_future is None # joined & cleared
303
- BaseRenderer.warmup(renderer, ChatParams())
296
+ renderer.start_mm_warmup_in_background()
297
+ # Simulate reset_mm_cache joining the background warmup first
298
+ # (clears _mm_warmup_future, sets _mm_warmup_done via the join).
299
+ renderer._join_mm_warmup()
300
+ assert renderer._mm_warmup_future is None # joined & cleared
301
+ BaseRenderer.warmup(renderer, ChatParams())
302
303
# MM apply called exactly once (by the background warmup task);
304
# warmup() did not re-run warmup_mm despite the future being cleared.
@@ -313,9 +311,8 @@ class TestBackgroundMmWarmup:
311
# Direct repeated calls to warmup_mm run the MM warmup only once.
312
renderer = _make_renderer_mock({"image": 1})
313
316
- with patch("vllm.multimodal.processing.TimingContext", autospec=True):
317
- renderer.warmup_mm()
318
- renderer.warmup_mm() # second call must be a no-op
314
+ renderer.warmup_mm()
315
+ renderer.warmup_mm() # second call must be a no-op
316
317
renderer.mm_processor.apply.assert_called_once()
318
@@ -324,10 +321,9 @@ class TestBackgroundMmWarmup:
321
# so the mm_processor_cache is never touched concurrently.
322
renderer = self._make_bg_renderer({"image": 1})
323
327
- with patch("vllm.multimodal.processing.TimingContext", autospec=True):
328
- renderer.start_mm_warmup_in_background()
329
- future = renderer._mm_warmup_future
330
- renderer.shutdown()
324
+ renderer.start_mm_warmup_in_background()
325
+ future = renderer._mm_warmup_future
326
+ renderer.shutdown()
327
328
assert renderer._mm_warmup_future is None
329
# The background warmup was allowed to complete (apply ran) and the
@@ -344,10 +340,6 @@ class TestEngineStartWarmupHook:
340
# These tests pin the renderer plumbing contract on EngineCoreClient.
341
342
def _mock_config(self):
347
- from types import SimpleNamespace
348
-
349
- from vllm.v1.engine.core_client import EngineCoreClient # noqa: F401
350
-
343
cfg = SimpleNamespace(
344
parallel_config=SimpleNamespace(
345
data_parallel_size=1,
vllm/model_executor/models/llava.py
-5
@@ -25,7 +25,6 @@ from vllm.model_executor.layers.activation import get_act_fn
25
from vllm.model_executor.layers.linear import ColumnParallelLinear, RowParallelLinear
26
from vllm.model_executor.layers.quantization import QuantizationConfig
27
from vllm.multimodal import MULTIMODAL_REGISTRY
28
-from vllm.multimodal.cache import BaseMultiModalProcessorCache
28
from vllm.multimodal.inputs import (
29
MultiModalFieldConfig,
30
MultiModalKwargsItem,
@@ -407,21 +406,17 @@ def _build_llava_or_pixtral_hf_info(
406
def _build_llava_or_pixtral_hf_processor(
407
info: _I,
408
dummy_inputs: BaseDummyInputsBuilder[_I],
410
- *,
411
- cache: BaseMultiModalProcessorCache | None = None,
409
) -> BaseMultiModalProcessor:
410
if isinstance(info, PixtralHFProcessingInfo):
411
return PixtralHFMultiModalProcessor(
412
info,
413
dummy_inputs, # type: ignore
417
- cache=cache,
414
)
415
416
if isinstance(info, LlavaProcessingInfo):
417
return LlavaMultiModalProcessor(
418
info,
419
dummy_inputs, # type: ignore
424
- cache=cache,
420
)
421
422
raise NotImplementedError(type(info))
vllm/model_executor/models/qwen3_asr_realtime.py
+16
-19
@@ -25,21 +25,10 @@ import torch
25
from vllm.config import ModelConfig, SpeechToTextConfig, VllmConfig
26
from vllm.inputs import PromptType, TokensPrompt
27
from vllm.logger import init_logger
28
-from vllm.model_executor.models.interfaces import (
29
- SupportsRealtime,
30
-)
31
-from vllm.model_executor.models.qwen3_asr import (
32
- Qwen3ASRDummyInputsBuilder,
33
- Qwen3ASRForConditionalGeneration,
34
- Qwen3ASRMultiModalProcessor,
35
- Qwen3ASRProcessingInfo,
36
- _get_feat_extract_output_lengths,
37
-)
28
from vllm.multimodal import MULTIMODAL_REGISTRY
39
-from vllm.multimodal.cache import _I, BaseMultiModalProcessorCache
29
from vllm.multimodal.parse import MultiModalDataItems
30
+from vllm.multimodal.processing import ProcessorInputs, TimingContext
31
from vllm.multimodal.processing.processor import (
42
- BaseDummyInputsBuilder,
32
MultiModalProcessingResult,
33
PlaceholderFeaturesInfo,
34
cached_encode,
@@ -47,6 +36,15 @@ from vllm.multimodal.processing.processor import (
36
from vllm.tokenizers import cached_tokenizer_from_config
37
from vllm.transformers_utils.processor import cached_processor_from_config
38
39
+from .interfaces import SupportsRealtime
40
+from .qwen3_asr import (
41
+ Qwen3ASRDummyInputsBuilder,
42
+ Qwen3ASRForConditionalGeneration,
43
+ Qwen3ASRMultiModalProcessor,
44
+ Qwen3ASRProcessingInfo,
45
+ _get_feat_extract_output_lengths,
46
+)
47
+
48
logger = init_logger(__name__)
49
50
_PRE_ALLOCATE_BUFFER_SIZE_IN_S = 60
@@ -101,14 +99,13 @@ class Qwen3ASRRealtimeBuffer:
99
100
101
class Qwen3ASRRealtimeMultiModalProcessor(Qwen3ASRMultiModalProcessor):
104
- def __init__(
102
+ def _cached_apply_hf_processor(
103
self,
106
- info: _I,
107
- dummy_inputs: BaseDummyInputsBuilder[_I],
108
- *,
109
- cache: BaseMultiModalProcessorCache | None = None,
110
- ) -> None:
111
- super().__init__(info, dummy_inputs, cache=None)
104
+ inputs: ProcessorInputs,
105
+ timing_ctx: TimingContext,
106
+ ) -> MultiModalProcessingResult:
107
+ # realtime can't make use of a cache yet
108
+ return self._apply_hf_processor(inputs, timing_ctx)
109
110
def _maybe_apply_prompt_updates(
111
self,
vllm/model_executor/models/voxtral_realtime.py
+7
-12
@@ -27,9 +27,8 @@ from vllm.model_executor.models.voxtral import (
27
VoxtralProcessingInfo,
28
)
29
from vllm.multimodal import MULTIMODAL_REGISTRY
30
-from vllm.multimodal.cache import _I, BaseMultiModalProcessorCache
30
from vllm.multimodal.parse import MultiModalDataItems
32
-from vllm.multimodal.processing import BaseDummyInputsBuilder
31
+from vllm.multimodal.processing import ProcessorInputs, TimingContext
32
from vllm.multimodal.processing.processor import (
33
MultiModalProcessingResult,
34
PlaceholderFeaturesInfo,
@@ -38,23 +37,19 @@ from vllm.sequence import IntermediateTensors
37
from vllm.tokenizers import cached_tokenizer_from_config
38
from vllm.utils.torch_utils import is_torch_equal_or_newer
39
41
-from .utils import (
42
- _flatten_embeddings,
43
-)
40
+from .utils import _flatten_embeddings
41
42
logger = init_logger(__name__)
43
44
45
class VoxtralRealtimeMultiModalProcessor(VoxtralMultiModalProcessor):
49
- def __init__(
46
+ def _cached_apply_hf_processor(
47
self,
51
- info: _I,
52
- dummy_inputs: BaseDummyInputsBuilder[_I],
53
- *,
54
- cache: BaseMultiModalProcessorCache | None = None,
55
- ) -> None:
48
+ inputs: ProcessorInputs,
49
+ timing_ctx: TimingContext,
50
+ ) -> MultiModalProcessingResult:
51
# realtime can't make use of a cache yet
57
- super().__init__(info, dummy_inputs, cache=None)
52
+ return self._apply_hf_processor(inputs, timing_ctx)
53
54
def _maybe_apply_prompt_updates(
55
self,
vllm/multimodal/encoder_budget.py
+33
-32
@@ -2,8 +2,9 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
from collections.abc import Mapping
4
5
-from vllm.config import ModelConfig, VllmConfig
5
+from vllm.config import VllmConfig
6
from vllm.logger import init_logger
7
+from vllm.multimodal.cache import BaseMultiModalProcessorCache
8
from vllm.multimodal.inputs import MultiModalKwargsItem
9
from vllm.multimodal.processing import BaseMultiModalProcessor
10
from vllm.multimodal.registry import MultiModalRegistry
@@ -14,26 +15,21 @@ logger = init_logger(__name__)
15
16
17
def get_mm_max_toks_per_item(
17
- model_config: ModelConfig,
18
- mm_registry: MultiModalRegistry,
18
processor: BaseMultiModalProcessor,
19
mm_counts: Mapping[str, int],
20
+ cache: BaseMultiModalProcessorCache | None,
21
) -> Mapping[str, int]:
22
"""Get the maximum number of tokens per data item from each modality based
23
on underlying model configuration.
24
"""
25
max_tokens_per_item = processor.info.get_mm_max_tokens_per_item(
26
- seq_len=model_config.max_model_len,
26
+ seq_len=processor.info.ctx.model_config.max_model_len,
27
mm_counts=mm_counts,
28
)
29
if max_tokens_per_item is not None:
30
return max_tokens_per_item
31
32
- mm_inputs = mm_registry.get_dummy_mm_inputs(
33
- model_config,
34
- mm_counts=mm_counts,
35
- processor=processor,
36
- )
32
+ mm_inputs = processor.get_dummy_mm_inputs(mm_counts, cache=cache)
33
34
return {
35
modality: sum(item.get_num_embeds() for item in placeholders)
@@ -65,7 +61,7 @@ class MultiModalBudget:
61
if enable_cache
62
else None
63
)
68
- processor = mm_registry.create_processor(model_config, cache=cache)
64
+ processor = mm_registry.create_processor(model_config)
65
66
self.cache = cache
67
self.processor = processor
@@ -91,9 +87,8 @@ class MultiModalBudget:
87
active_modalities = tower_modalities | embed_only_modalities
88
89
all_mm_max_toks_per_item = get_mm_max_toks_per_item(
94
- model_config,
95
- mm_registry,
90
processor,
91
+ cache=cache,
92
mm_counts=dict.fromkeys(active_modalities, 1),
93
)
94
@@ -194,26 +189,32 @@ class MultiModalBudget:
189
def get_encoder_budget(self) -> int:
190
return min(self.encoder_compute_budget, self.encoder_cache_size)
191
197
- def reset_cache(self) -> None:
198
- if self.cache is not None:
199
- self.cache.clear_cache()
200
-
192
+ def get_dummy_encoder_profile_inputs(
193
+ self,
194
+ modality: str | None = None,
195
+ max_items_per_batch: int | None = None,
196
+ ) -> list[tuple[str, MultiModalKwargsItem]]:
197
+ if self.get_encoder_budget() <= 0 or not self.mm_max_toks_per_item:
198
+ return []
199
+
200
+ if modality is None:
201
+ modality = self.get_modality_with_max_tokens()
202
+ if max_items_per_batch is None:
203
+ max_items_per_batch = self.mm_max_items_per_batch[modality]
204
+
205
+ # Don't use `max_items_per_batch` here to avoid redundant computation
206
+ dummy_mm_inputs = self.processor.get_dummy_mm_inputs(
207
+ mm_counts={modality: 1},
208
+ cache=self.cache,
209
+ )
210
+ dummy_mm_item = dummy_mm_inputs["mm_kwargs"][modality][0]
211
202
-def get_dummy_encoder_profile_inputs(
203
- mm_registry: MultiModalRegistry,
204
- budget: MultiModalBudget,
205
-) -> list[tuple[str, MultiModalKwargsItem]]:
206
- if budget.get_encoder_budget() <= 0 or not budget.mm_max_toks_per_item:
207
- return []
212
+ # We use the cache so that the item is saved to the cache,
213
+ # but not read from the cache
214
+ assert dummy_mm_item is not None, "Item should not already be cached"
215
209
- modality = budget.get_modality_with_max_tokens()
210
- max_items_per_batch = budget.mm_max_items_per_batch[modality]
211
- dummy_mm_inputs = mm_registry.get_dummy_mm_inputs(
212
- budget.model_config,
213
- mm_counts={modality: 1},
214
- processor=budget.processor,
215
- )
216
- dummy_mm_item = dummy_mm_inputs["mm_kwargs"][modality][0]
217
- assert dummy_mm_item is not None, "Dummy item should be generated"
216
+ return [(modality, dummy_mm_item)] * max_items_per_batch
217
219
- return [(modality, dummy_mm_item)] * max_items_per_batch
218
+ def reset_cache(self) -> None:
219
+ if self.cache is not None:
220
+ self.cache.clear_cache()
vllm/multimodal/processing/inputs.py
+3
@@ -6,6 +6,7 @@ from dataclasses import dataclass, field
6
from vllm.config.multimodal import MMHasherAlgorithm
7
from vllm.inputs import MultiModalHashes
8
9
+from ..cache import BaseMultiModalProcessorCache
10
from ..hasher import MultiModalHasher
11
from ..parse import MultiModalDataItems, MultiModalUUIDItems
12
@@ -28,6 +29,8 @@ class ProcessorInputs:
29
hf_processor_mm_kwargs: Mapping[str, object] = field(default_factory=dict)
30
media_io_kwargs: Mapping[str, Mapping[str, object]] = field(default_factory=dict)
31
32
+ cache: BaseMultiModalProcessorCache | None = None
33
+
34
def get_mm_hashes(
35
self,
36
model_id: str,
vllm/multimodal/processing/processor.py
+39
-4
@@ -18,6 +18,7 @@ from typing import (
18
import torch
19
from typing_extensions import TypeVar, assert_never
20
21
+from vllm.config import SchedulerConfig
22
from vllm.inputs import (
23
MultiModalEncDecInput,
24
MultiModalHashes,
@@ -1039,8 +1040,6 @@ class BaseMultiModalProcessor(ABC, Generic[_I]):
1040
self,
1041
info: _I,
1042
dummy_inputs: "BaseDummyInputsBuilder[_I]",
1042
- *,
1043
- cache: BaseMultiModalProcessorCache | None = None,
1043
) -> None:
1044
super().__init__()
1045
@@ -1053,16 +1052,50 @@ class BaseMultiModalProcessor(ABC, Generic[_I]):
1052
1053
self.info = info
1054
self.dummy_inputs = dummy_inputs
1056
- self.cache = cache
1055
1056
self.data_parser = self.info.get_data_parser()
1057
1058
+ def get_dummy_mm_inputs(
1059
+ self,
1060
+ mm_counts: Mapping[str, int],
1061
+ *,
1062
+ cache: BaseMultiModalProcessorCache | None = None,
1063
+ scheduler_config: "SchedulerConfig | None" = None,
1064
+ ) -> MultiModalInput:
1065
+ """Create dummy data for profiling the memory usage of a model."""
1066
+ model_config = self.info.ctx.model_config
1067
+ seq_len = model_config.max_model_len
1068
+ if scheduler_config is not None and scheduler_config.enable_chunked_prefill:
1069
+ seq_len = min(seq_len, scheduler_config.max_num_batched_tokens)
1070
+
1071
+ mm_config = model_config.get_multimodal_config()
1072
+
1073
+ processor_inputs = self.dummy_inputs.get_dummy_processor_inputs(
1074
+ seq_len=seq_len,
1075
+ mm_counts=mm_counts,
1076
+ mm_options=mm_config.limit_per_prompt,
1077
+ )
1078
+ processor_inputs.cache = cache
1079
+
1080
+ mm_inputs = self.apply(
1081
+ processor_inputs,
1082
+ timing_ctx=TimingContext(enabled=False),
1083
+ )
1084
+
1085
+ prompt_token_ids = mm_inputs["prompt_token_ids"]
1086
+ total_len = len(prompt_token_ids)
1087
+ if total_len < seq_len:
1088
+ prompt_token_ids.extend([0] * (seq_len - total_len))
1089
+
1090
+ return mm_inputs
1091
+
1092
def __call__(
1093
self,
1094
prompt: str | list[int],
1095
mm_items: MultiModalDataItems,
1096
mm_uuid_items: MultiModalUUIDItems | None = None,
1097
hf_processor_mm_kwargs: Mapping[str, object] | None = None,
1098
+ cache: BaseMultiModalProcessorCache | None = None,
1099
) -> MultiModalInput:
1100
if isinstance(prompt, str):
1101
tokenizer = self.info.get_tokenizer()
@@ -1076,6 +1109,7 @@ class BaseMultiModalProcessor(ABC, Generic[_I]):
1109
mm_items,
1110
mm_uuid_items,
1111
hf_processor_mm_kwargs=hf_processor_mm_kwargs or {},
1112
+ cache=cache,
1113
)
1114
1115
return self.apply(processor_inputs, TimingContext(enabled=False))
@@ -1420,7 +1454,7 @@ class BaseMultiModalProcessor(ABC, Generic[_I]):
1454
"""Apply the HF processor on the full prompt text,
1455
caching the results and reusing cached results.
1456
"""
1423
- cache = self.cache
1457
+ cache = inputs.cache
1458
has_passthrough_data = any(
1459
len(items.get_passthrough_data()) > 0
1460
for items in inputs.mm_data_items.values()
@@ -1827,6 +1861,7 @@ class EncDecMultiModalProcessor(BaseMultiModalProcessor[_I]):
1861
inputs.mm_uuid_items,
1862
hf_processor_mm_kwargs=inputs.hf_processor_mm_kwargs,
1863
media_io_kwargs=inputs.media_io_kwargs,
1864
+ cache=inputs.cache,
1865
)
1866
1867
encoder_inputs = super().apply(encoder_processor_inputs, timing_ctx)
vllm/multimodal/registry.py
+3
-53
@@ -2,12 +2,10 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
import threading
4
from collections import defaultdict
5
-from collections.abc import Mapping
5
from dataclasses import dataclass
6
from multiprocessing.synchronize import Lock as LockType
7
from typing import TYPE_CHECKING, Generic, Literal, Protocol, TypeVar, cast
8
10
-from vllm.inputs import MultiModalInput
9
from vllm.logger import init_logger
10
from vllm.tokenizers import TokenizerLike, cached_tokenizer_from_config
11
@@ -29,12 +27,7 @@ from .processing import (
27
)
28
29
if TYPE_CHECKING:
32
- from vllm.config import (
33
- ModelConfig,
34
- ObservabilityConfig,
35
- SchedulerConfig,
36
- VllmConfig,
37
- )
30
+ from vllm.config import ModelConfig, ObservabilityConfig, VllmConfig
31
from vllm.model_executor.models.interfaces import SupportsMultiModal
32
33
logger = init_logger(__name__)
@@ -75,8 +68,6 @@ class MultiModalProcessorFactory(Protocol[_I]): # type: ignore[misc]
68
self,
69
info: _I,
70
dummy_inputs: BaseDummyInputsBuilder[_I],
78
- *,
79
- cache: BaseMultiModalProcessorCache | None = None,
71
) -> BaseMultiModalProcessor[_I]: ...
72
73
@@ -89,12 +80,10 @@ class _ProcessorFactories(Generic[_I]):
80
def build_processor(
81
self,
82
ctx: InputProcessingContext,
92
- *,
93
- cache: BaseMultiModalProcessorCache | None = None,
83
):
84
info = self.info(ctx)
85
dummy_inputs_builder = self.dummy_inputs(info)
97
- return self.processor(info, dummy_inputs_builder, cache=cache)
86
+ return self.processor(info, dummy_inputs_builder)
87
88
89
class MultiModalRegistry:
@@ -218,7 +207,6 @@ class MultiModalRegistry:
207
model_config: "ModelConfig",
208
*,
209
tokenizer: TokenizerLike | None = None,
221
- cache: BaseMultiModalProcessorCache | None = None,
210
) -> BaseMultiModalProcessor[BaseProcessingInfo]:
211
"""Create a multi-modal processor for a specific model and tokenizer."""
212
if not model_config.is_multimodal_model:
@@ -230,45 +218,7 @@ class MultiModalRegistry:
218
219
ctx = self._create_processing_ctx(model_config, tokenizer)
220
233
- return factories.build_processor(ctx, cache=cache)
234
-
235
- def get_dummy_mm_inputs(
236
- self,
237
- model_config: "ModelConfig",
238
- mm_counts: Mapping[str, int],
239
- *,
240
- cache: BaseMultiModalProcessorCache | None = None,
241
- processor: BaseMultiModalProcessor | None = None,
242
- scheduler_config: "SchedulerConfig | None" = None,
243
- ) -> MultiModalInput:
244
- """Create dummy data for profiling the memory usage of a model.
245
-
246
- The model is identified by `model_config`.
247
- """
248
- seq_len = model_config.max_model_len
249
- if scheduler_config is not None and scheduler_config.enable_chunked_prefill:
250
- seq_len = min(seq_len, scheduler_config.max_num_batched_tokens)
251
-
252
- if processor is None:
253
- processor = self.create_processor(model_config, cache=cache)
254
-
255
- mm_config = model_config.get_multimodal_config()
256
- processor_inputs = processor.dummy_inputs.get_dummy_processor_inputs(
257
- seq_len=seq_len,
258
- mm_counts=mm_counts,
259
- mm_options=mm_config.limit_per_prompt,
260
- )
261
- mm_inputs = processor.apply(
262
- processor_inputs,
263
- timing_ctx=TimingContext(enabled=False),
264
- )
265
-
266
- prompt_token_ids = mm_inputs["prompt_token_ids"]
267
- total_len = len(prompt_token_ids)
268
- if total_len < seq_len:
269
- prompt_token_ids.extend([0] * (seq_len - total_len))
270
-
271
- return mm_inputs
221
+ return factories.build_processor(ctx)
222
223
def _get_cache_type(
224
self,
vllm/renderers/base.py
+42
-62
@@ -127,8 +127,7 @@ class BaseRenderer(ABC, Generic[_T]):
127
self._async_tokenizer_decode = make_async(self._decode, executor=self._executor)
128
129
self.mm_processor: BaseMultiModalProcessor | None = None
130
- self._readonly_mm_processor: BaseMultiModalProcessor | None = None
131
- self._mm_cache_stats: MultiModalCacheStats | None = None
130
+
131
self._clear_mm_cache_async = make_async(
132
self.clear_mm_cache, executor=self._mm_executor
133
)
@@ -138,6 +137,16 @@ class BaseRenderer(ABC, Generic[_T]):
137
self._safe_load_prompt_embeds_async = make_async(
138
safe_load_prompt_embeds, executor=self._executor
139
)
140
+
141
+ self._mm_processor_cache: BaseMultiModalProcessorCache | None = None
142
+
143
+ # A second processor with its own processor-only cache.
144
+ # Used by the tokenize endpoint so that tokenize-only
145
+ # requests don't pollute the sender cache.
146
+ self._mm_processor_only_cache: BaseMultiModalProcessorCache | None = None
147
+
148
+ self._mm_cache_stats: MultiModalCacheStats | None = None
149
+
150
if mm_registry.supports_multimodal_inputs(config.model_config):
151
# Install the process-global GPU memory pool used to gate
152
# frontend GPU-side multimodal decoding (no-op when the budget
@@ -149,30 +158,16 @@ class BaseRenderer(ABC, Generic[_T]):
158
config.parallel_config._api_process_count,
159
)
160
152
- mm_processor_cache = mm_registry.processor_cache_from_config(config)
153
-
161
with set_default_torch_num_threads():
162
self.mm_processor = mm_registry.create_processor(
163
config.model_config,
164
tokenizer=self.tokenizer,
158
- cache=mm_processor_cache,
165
)
166
161
- if mm_processor_cache:
162
- self._mm_cache_stats = MultiModalCacheStats()
163
- self._resources.callback(mm_processor_cache.close)
164
-
165
- # A second processor with its own processor-only cache.
166
- # Used by the tokenize endpoint so that tokenize-only
167
- # requests don't pollute the sender cache.
168
- ro_cache = mm_registry.processor_only_cache_from_config(config)
169
- if ro_cache is not None:
170
- with set_default_torch_num_threads():
171
- self._readonly_mm_processor = mm_registry.create_processor(
172
- config.model_config,
173
- tokenizer=self.tokenizer,
174
- cache=ro_cache,
175
- )
167
+ self._mm_processor_cache = mm_registry.processor_cache_from_config(config)
168
+ self._mm_processor_only_cache = (
169
+ mm_registry.processor_only_cache_from_config(config)
170
+ )
171
172
# This is used to generate internal request ID for MM processing
173
# It has no relation to the request ID for engine core
@@ -181,6 +176,12 @@ class BaseRenderer(ABC, Generic[_T]):
176
config.observability_config
177
)
178
179
+ if self._mm_processor_cache:
180
+ self._mm_cache_stats = MultiModalCacheStats()
181
+ self._resources.callback(self._mm_processor_cache.close)
182
+ if self._mm_processor_only_cache:
183
+ self._resources.callback(self._mm_processor_only_cache.close)
184
+
185
def get_tokenizer(self) -> _T:
186
tokenizer = self.tokenizer
187
if tokenizer is None:
@@ -198,11 +199,8 @@ class BaseRenderer(ABC, Generic[_T]):
199
return self.mm_processor
200
201
@property
201
- def mm_processor_cache(self) -> "BaseMultiModalProcessorCache | None":
202
- if self.mm_processor is None:
203
- return None
204
-
205
- return self.mm_processor.cache
202
+ def mm_processor_cache(self) -> BaseMultiModalProcessorCache | None:
203
+ return self._mm_processor_cache
204
205
def stat_mm_cache(self) -> MultiModalCacheStats | None:
206
mm_cache_stats = self._mm_cache_stats
@@ -214,7 +212,7 @@ class BaseRenderer(ABC, Generic[_T]):
212
return mm_cache_stats
213
214
def update_mm_cache_stats(self) -> None:
217
- mm_processor_cache = self.mm_processor_cache
215
+ mm_processor_cache = self._mm_processor_cache
216
mm_cache_stats = self._mm_cache_stats
217
218
if mm_processor_cache and mm_cache_stats:
@@ -222,38 +220,26 @@ class BaseRenderer(ABC, Generic[_T]):
220
mm_cache_stats.record(delta.total, delta.hits)
221
222
def clear_mm_cache(self) -> None:
225
- mm_processor_cache = self.mm_processor_cache
223
+ mm_processor_cache = self._mm_processor_cache
224
if mm_processor_cache is not None:
225
mm_processor_cache.clear_cache()
226
227
if self._mm_cache_stats is not None:
228
self._mm_cache_stats.reset = True
229
232
- @staticmethod
233
- def _clear_processor_cache(
234
- processor: "BaseMultiModalProcessor | None",
235
- ) -> None:
236
- if processor is None:
237
- return
238
-
239
- processor_cache = processor.cache
240
- if processor_cache is not None:
241
- processor_cache.clear_cache()
242
-
230
def _warmup_mm_processor(
231
self,
245
- processor: "BaseMultiModalProcessor",
232
+ processor: BaseMultiModalProcessor,
233
+ cache: BaseMultiModalProcessorCache | None,
234
*,
235
log_prefix: str,
236
) -> None:
249
- model_config = self.model_config
237
mm_limits = {k: v for k, v in processor.info.allowed_mm_limits.items() if v > 0}
238
239
start_time = time.perf_counter()
253
- _ = mm_registry.get_dummy_mm_inputs(
254
- model_config,
255
- mm_counts=dict.fromkeys(mm_limits, 1),
256
- processor=processor,
240
+ _ = processor.get_dummy_mm_inputs(
241
+ dict.fromkeys(mm_limits, 1),
242
+ cache=cache,
243
scheduler_config=self.config.scheduler_config,
244
)
245
@@ -274,28 +260,20 @@ class BaseRenderer(ABC, Generic[_T]):
260
# prevent MM processor hangs
261
with set_default_torch_num_threads(1):
262
if self.mm_processor:
263
+ mm_processor_only_cache = self._mm_processor_only_cache
264
+
265
try:
266
logger.debug("Warming up multi-modal processing...")
267
self._warmup_mm_processor(
268
self.mm_processor,
269
+ mm_processor_only_cache,
270
log_prefix="Multi-modal",
271
)
272
except Exception:
273
logger.warning("Multi-modal warmup failed")
274
finally:
286
- self.clear_mm_cache()
287
-
288
- if self._readonly_mm_processor is not None:
289
- try:
290
- logger.debug("Warming up readonly multi-modal processing...")
291
- self._warmup_mm_processor(
292
- self._readonly_mm_processor,
293
- log_prefix="Readonly multi-modal",
294
- )
295
- except Exception:
296
- logger.warning("Readonly multi-modal warmup failed")
297
- finally:
298
- self._clear_processor_cache(self._readonly_mm_processor)
275
+ if mm_processor_only_cache:
276
+ mm_processor_only_cache.clear_cache()
277
finally:
278
self._mm_warmup_done = True
279
@@ -323,7 +301,7 @@ class BaseRenderer(ABC, Generic[_T]):
301
# which is not designed for concurrent access (its get_and_update must
302
# stay ordered with the engine-core cache) — is never touched
303
# concurrently by the warmup and the serving path.
326
- if self.mm_processor is None and self._readonly_mm_processor is None:
304
+ if self.mm_processor is None:
305
return # text-only model: nothing to warm up
306
if self._mm_warmup_future is not None:
307
return # already launched
@@ -853,10 +831,7 @@ class BaseRenderer(ABC, Generic[_T]):
831
*,
832
skip_mm_cache: bool = False,
833
) -> "MultiModalInput":
856
- if skip_mm_cache and self._readonly_mm_processor is not None:
857
- mm_processor = self._readonly_mm_processor
858
- else:
859
- mm_processor = self.get_mm_processor()
834
+ mm_processor = self.get_mm_processor()
835
836
mm_req_id = f"renderer{self.api_process_rank}-mm-{self._mm_req_counter.inc(1)}"
837
@@ -873,6 +848,11 @@ class BaseRenderer(ABC, Generic[_T]):
848
mm_uuid_items,
849
hf_processor_mm_kwargs=mm_processor_kwargs or {},
850
media_io_kwargs=media_io_kwargs or {},
851
+ cache=(
852
+ self._mm_processor_only_cache
853
+ if skip_mm_cache
854
+ else self._mm_processor_cache
855
+ ),
856
)
857
mm_timing_ctx = self._mm_timing_registry.get(mm_req_id)
858
vllm/v1/worker/gpu/model_runner.py
+1
-5
@@ -57,7 +57,6 @@ from vllm.model_executor.warmup.jit_warmup import JitWarmupRegistry
57
from vllm.multimodal import MULTIMODAL_REGISTRY
58
from vllm.multimodal.encoder_budget import (
59
MultiModalBudget,
60
- get_dummy_encoder_profile_inputs,
60
)
61
from vllm.sequence import IntermediateTensors
62
from vllm.tasks import SupportedTask
@@ -935,10 +934,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
934
self.mm_registry,
935
enable_cache=False,
936
)
938
- dummy_mm_inputs = get_dummy_encoder_profile_inputs(
939
- self.mm_registry,
940
- mm_budget,
941
- )
937
+ dummy_mm_inputs = mm_budget.get_dummy_encoder_profile_inputs()
938
self.model_state.encoder_runner.profile_encoder_cache(
939
dummy_mm_inputs, mm_budget
940
)
vllm/v1/worker/gpu_model_runner.py
+4
-10
@@ -5837,21 +5837,15 @@ class GPUModelRunner(
5837
assert self.mm_budget is not None
5838
5839
# Don't use `max_items_per_batch` here to avoid redundant computation
5840
- dummy_mm_inputs = self.mm_registry.get_dummy_mm_inputs(
5841
- self.model_config,
5842
- mm_counts={modality: 1},
5843
- cache=self.mm_budget.cache,
5840
+ dummy_mm_inputs = self.mm_budget.get_dummy_encoder_profile_inputs(
5841
+ modality,
5842
+ max_items_per_batch,
5843
)
5845
- dummy_mm_item = dummy_mm_inputs["mm_kwargs"][modality][0]
5846
-
5847
- # We use the cache so that the item is saved to the cache,
5848
- # but not read from the cache
5849
- assert dummy_mm_item is not None, "Item should not already be cached"
5844
5845
return next(
5846
mm_kwargs_batch
5847
for _, _, mm_kwargs_batch in group_and_batch_mm_kwargs(
5854
- [(modality, dummy_mm_item)] * max_items_per_batch,
5848
+ dummy_mm_inputs,
5849
device=self.device,
5850
pin_memory=PIN_MEMORY,
5851
)