[MM] Move `supports_multimodal_inputs` and cache out of registry (#57913)
Signed-off-by: DarkLight1337 <tlleungac@connect.ust.hk>
Cyrus Leung committed
Sep 21, 2026 at 22:43 UTC
c8824a1e42bbd674751f808d657d58024c68fb0f
36 files changed
+784
-707
tests/config/test_multimodal_config.py
+46
-54
@@ -1,12 +1,14 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+import copy
5
from unittest.mock import MagicMock, patch
6
7
import pytest
8
import torch
9
from transformers import PretrainedConfig
10
11
+from tests.models.utils import build_model_context
12
from vllm.config.ec_transfer import ECRole, ECTransferConfig
13
from vllm.config.model import ModelConfig
14
from vllm.config.multimodal import MultiModalConfig
@@ -17,6 +19,26 @@ from vllm.transformers_utils.model_arch_config_convertor import (
19
from vllm.v1.attention.backends.registry import AttentionBackendEnum
20
21
22
+@pytest.mark.parametrize(
23
+ "model_id,limit_mm_per_prompt,expected",
24
+ [
25
+ ("Qwen/Qwen2-0.5B-Instruct", {}, False),
26
+ ("Qwen/Qwen2.5-VL-3B-Instruct", {}, True),
27
+ ("Qwen/Qwen2.5-VL-3B-Instruct", {"image": 0, "video": 0}, False),
28
+ ("Qwen/Qwen2.5-VL-3B-Instruct", {"image": 0}, True),
29
+ ],
30
+)
31
+@pytest.mark.core_model
32
+def test_supports_multimodal_inputs(model_id, limit_mm_per_prompt, expected):
33
+ """Test supports_multimodal_inputs returns correct boolean for various
34
+ configs."""
35
+ ctx = build_model_context(
36
+ model_id,
37
+ limit_mm_per_prompt=limit_mm_per_prompt,
38
+ )
39
+ assert ctx.model_config.supports_multimodal_inputs is expected
40
+
41
+
42
def test_mm_encoder_attn_backend_str_conversion():
43
config = MultiModalConfig(mm_encoder_attn_backend="FLASH_ATTN") # type: ignore[arg-type]
44
assert config.mm_encoder_attn_backend == AttentionBackendEnum.FLASH_ATTN
@@ -84,59 +106,33 @@ def test_mm_encoder_attn_dtype_hash_updates(tmp_path):
106
assert fp8_hash != fp8_static_hash
107
108
109
+_MULTIMODAL_MODEL = "llava-hf/llava-1.5-7b-hf"
110
+_TEXT_ONLY_MODEL = "TinyLlama/TinyLlama-1.1B-Chat-v1.0"
111
+
112
+
113
def _make_mm_prefix_model_config(
114
+ model: str = _MULTIMODAL_MODEL,
115
*,
116
language_model_only: bool = False,
117
) -> ModelConfig:
91
- model_config = MagicMock(spec=ModelConfig)
92
- model_config.multimodal_config = MultiModalConfig(
93
- language_model_only=language_model_only
94
- )
95
- # Bind real helper methods onto the mock.
96
- model_config._supports_multimodal_for_mm_prefix = (
97
- ModelConfig._supports_multimodal_for_mm_prefix.__get__(
98
- model_config, ModelConfig
99
- )
100
- )
101
- return model_config
102
-
103
-
104
-@pytest.mark.parametrize("supports_mm", [True, False])
105
-def test_supports_multimodal_for_mm_prefix_uses_registry(supports_mm: bool):
106
- model_config = _make_mm_prefix_model_config()
107
-
108
- with patch(
109
- "vllm.multimodal.MULTIMODAL_REGISTRY.supports_multimodal_inputs",
110
- return_value=supports_mm,
111
- ) as mocked:
112
- assert model_config._supports_multimodal_for_mm_prefix() is supports_mm
113
- mocked.assert_called_once_with(model_config)
114
-
115
- # Sticky cache — registry must not be consulted again.
116
- with patch(
117
- "vllm.multimodal.MULTIMODAL_REGISTRY.supports_multimodal_inputs",
118
- side_effect=AssertionError("should use cache"),
119
- ):
120
- assert model_config._supports_multimodal_for_mm_prefix() is supports_mm
118
+ return ModelConfig(model, language_model_only=language_model_only)
119
120
121
def test_supports_multimodal_for_mm_prefix_before_multimodal_config():
124
- model_config = _make_mm_prefix_model_config()
125
- model_config.multimodal_config = None
122
+ """A text-only model never builds a multimodal config, so the early
123
+ return must neither clear mm_prefix nor write the sticky cache."""
124
+ model_config = _make_mm_prefix_model_config(_TEXT_ONLY_MODEL)
125
+ assert model_config.multimodal_config is None
126
127
assert model_config._supports_multimodal_for_mm_prefix() is True
128
- assert not hasattr(model_config, "_supports_multimodal_inputs_cached")
128
+ assert not getattr(model_config, "_supports_multimodal_inputs_cache", None)
129
130
131
def test_language_model_only_disables_via_supports_multimodal_inputs():
132
"""language_model_only zeros all limits, so registry reports text-only."""
133
model_config = _make_mm_prefix_model_config(language_model_only=True)
134
135
- with patch(
136
- "vllm.multimodal.MULTIMODAL_REGISTRY.supports_multimodal_inputs",
137
- return_value=False,
138
- ):
139
- assert model_config._supports_multimodal_for_mm_prefix() is False
135
+ assert model_config._supports_multimodal_for_mm_prefix() is False
136
137
138
def test_convertor_clears_mm_prefix_when_multimodal_disabled():
@@ -159,23 +155,19 @@ def test_convertor_clears_mm_prefix_when_multimodal_disabled():
155
def test_sticky_cache_survives_text_subconfig_regeneration():
156
"""with_hf_config deepcopies the cached decision onto text submodules."""
157
model_config = _make_mm_prefix_model_config()
162
- with patch(
163
- "vllm.multimodal.MULTIMODAL_REGISTRY.supports_multimodal_inputs",
164
- return_value=False,
165
- ):
166
- assert model_config._supports_multimodal_for_mm_prefix() is False
158
+ assert model_config._supports_multimodal_for_mm_prefix() is True
159
168
- # Simulate deepcopy onto a Gemma4ForCausalLM-like config that would
169
- # otherwise fail registry lookup / return False incorrectly.
170
- text_config = _make_mm_prefix_model_config()
171
- text_config._supports_multimodal_inputs_cached = (
172
- model_config._supports_multimodal_inputs_cached
173
- )
174
- with patch(
175
- "vllm.multimodal.MULTIMODAL_REGISTRY.supports_multimodal_inputs",
176
- side_effect=AssertionError("must not re-query registry"),
177
- ):
178
- assert text_config._supports_multimodal_for_mm_prefix() is False
160
+ # `with_hf_config` deep-copies this config and swaps `hf_config` for a
161
+ # text-only submodule (e.g. Gemma4ForCausalLM).
162
+ text_config = copy.deepcopy(model_config)
163
+ text_config.hf_config = model_config.hf_text_config
164
+ assert text_config._supports_multimodal_for_mm_prefix() is True
165
+
166
+ # Without the copied cache the submodule architecture has no registered
167
+ # multimodal processor, so re-querying the registry wrongly settles on
168
+ # text-only and would clear mm_prefix.
169
+ del text_config._supports_multimodal_inputs_cache
170
+ assert text_config._supports_multimodal_for_mm_prefix() is False
171
172
173
@pytest.mark.parametrize(
tests/entrypoints/generate/generative_scoring/test_generative_scoring.py
+4
-1
@@ -60,7 +60,10 @@ class MockModelConfig:
60
encoder_config = None
61
generation_config: str = "auto"
62
media_io_kwargs: dict[str, dict[str, Any]] = field(default_factory=dict)
63
- skip_tokenizer_init = False
63
+ skip_tokenizer_init: bool = False
64
+ is_encoder_decoder: bool = False
65
+ is_multimodal_model: bool = False
66
+ supports_multimodal_inputs: bool = False
67
vocab_size = 151936
68
69
def get_diff_sampling_param(self):
tests/entrypoints/openai/chat_completion/test_chat_error.py
+2
-1
@@ -56,9 +56,10 @@ class MockModelConfig:
56
encoder_config = None
57
generation_config: str = "auto"
58
media_io_kwargs: dict[str, dict[str, Any]] = field(default_factory=dict)
59
- skip_tokenizer_init = False
59
+ skip_tokenizer_init: bool = False
60
is_encoder_decoder: bool = False
61
is_multimodal_model: bool = False
62
+ supports_multimodal_inputs: bool = False
63
renderer_num_workers: int = 1
64
65
def get_diff_sampling_param(self):
tests/entrypoints/openai/chat_completion/test_serving_chat.py
+1
@@ -556,6 +556,7 @@ class MockModelConfig:
556
skip_tokenizer_init: bool = False
557
is_encoder_decoder: bool = False
558
is_multimodal_model: bool = False
559
+ supports_multimodal_inputs: bool = False
560
renderer_num_workers: int = 1
561
enable_prompt_embeds: bool = False
562
tests/entrypoints/openai/completion/test_completion_error.py
+2
-1
@@ -61,9 +61,10 @@ class MockModelConfig:
61
encoder_config = None
62
generation_config: str = "auto"
63
media_io_kwargs: dict[str, dict[str, Any]] = field(default_factory=dict)
64
- skip_tokenizer_init = False
64
+ skip_tokenizer_init: bool = False
65
is_encoder_decoder: bool = False
66
is_multimodal_model: bool = False
67
+ supports_multimodal_inputs: bool = False
68
renderer_num_workers: int = 1
69
70
def get_diff_sampling_param(self):
tests/entrypoints/openai/completion/test_lora_resolvers.py
+1
@@ -54,6 +54,7 @@ class MockModelConfig:
54
skip_tokenizer_init: bool = False
55
is_encoder_decoder: bool = False
56
is_multimodal_model: bool = False
57
+ supports_multimodal_inputs: bool = False
58
renderer_num_workers: int = 1
59
60
def get_diff_sampling_param(self):
tests/entrypoints/openai/test_render_parity.py
+2
-1
@@ -86,9 +86,10 @@ class MockModelConfig:
86
generation_config: str = "auto"
87
override_generation_config: dict[str, Any] = field(default_factory=dict)
88
media_io_kwargs: dict[str, dict[str, Any]] = field(default_factory=dict)
89
- skip_tokenizer_init = False
89
+ skip_tokenizer_init: bool = False
90
is_encoder_decoder: bool = False
91
is_multimodal_model: bool = False
92
+ supports_multimodal_inputs: bool = False
93
renderer_num_workers: int = 1
94
enable_prompt_embeds: bool = False
95
tests/entrypoints/scale_out/token_in_token_out/test_generate_stream.py
+2
-1
@@ -61,9 +61,10 @@ class MockModelConfig:
61
encoder_config = None
62
generation_config: str = "auto"
63
media_io_kwargs: dict[str, dict[str, Any]] = field(default_factory=dict)
64
- skip_tokenizer_init = False
64
+ skip_tokenizer_init: bool = False
65
is_encoder_decoder: bool = False
66
is_multimodal_model: bool = False
67
+ supports_multimodal_inputs: bool = False
68
renderer_num_workers: int = 1
69
70
def get_diff_sampling_param(self):
tests/entrypoints/serve/tokenize/test_serving_tokenization.py
+2
-1
@@ -56,9 +56,10 @@ class MockModelConfig:
56
encoder_config = None
57
generation_config: str = "auto"
58
media_io_kwargs: dict[str, dict[str, Any]] = field(default_factory=dict)
59
- skip_tokenizer_init = False
59
+ skip_tokenizer_init: bool = False
60
is_encoder_decoder: bool = False
61
is_multimodal_model: bool = False
62
+ supports_multimodal_inputs: bool = False
63
renderer_num_workers: int = 1
64
65
def get_diff_sampling_param(self):
tests/models/multimodal/processing/test_audio_in_video.py
+2
-2
@@ -22,7 +22,7 @@ import numpy as np
22
import pytest
23
24
from vllm.multimodal import MULTIMODAL_REGISTRY
25
-from vllm.multimodal.cache import MultiModalProcessorSenderCache
25
+from vllm.multimodal.cache import LruKeyReplicatedSenderCache
26
27
from ....multimodal.utils import random_audio, random_video
28
from ...utils import build_model_context
@@ -68,7 +68,7 @@ def test_audio_in_video_cache_correctness(model_id: str, num_videos: int) -> Non
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)
71
+ sender_cache = LruKeyReplicatedSenderCache(ctx.model_config)
72
73
video_token_id = processor.info.get_hf_config().video_token_id
74
tests/multimodal/test_cache.py
+26
-23
@@ -10,20 +10,23 @@ import torch
10
11
from vllm.config import ModelConfig, ParallelConfig, VllmConfig
12
from vllm.config.multimodal import MultiModalConfig
13
-from vllm.multimodal import MULTIMODAL_REGISTRY
13
from vllm.multimodal.cache import (
14
BaseMultiModalProcessorCache,
15
BaseMultiModalReceiverCache,
17
- MultiModalCache,
16
+ LruKeyReplicatedReceiverCache,
17
+ LruKeyReplicatedSenderCache,
18
MultiModalCacheMissError,
19
- MultiModalProcessorCacheInItem,
20
- MultiModalProcessorCacheItem,
21
- MultiModalProcessorCacheItemMetadata,
19
MultiModalProcessorOnlyCache,
23
- MultiModalProcessorSenderCache,
24
- MultiModalReceiverCache,
20
ShmObjectStoreReceiverCache,
21
ShmObjectStoreSenderCache,
22
+ engine_receiver_cache_from_config,
23
+ processor_cache_from_config,
24
+)
25
+from vllm.multimodal.cache.base import (
26
+ MultiModalCache,
27
+ MultiModalProcessorCacheInItem,
28
+ MultiModalProcessorCacheItem,
29
+ MultiModalProcessorCacheItemMetadata,
30
)
31
from vllm.multimodal.hasher import MultiModalHasher
32
from vllm.multimodal.inputs import (
@@ -137,10 +140,10 @@ def _compare_caches(
140
n_iter: int = 100,
141
seed: int = 0,
142
):
140
- cache_0_p0 = MULTIMODAL_REGISTRY.processor_cache_from_config(config_0)
141
- cache_0_p1 = MULTIMODAL_REGISTRY.engine_receiver_cache_from_config(config_0)
142
- cache_1_p0 = MULTIMODAL_REGISTRY.processor_cache_from_config(config_1)
143
- cache_1_p1 = MULTIMODAL_REGISTRY.engine_receiver_cache_from_config(config_1)
143
+ cache_0_p0 = processor_cache_from_config(config_0)
144
+ cache_0_p1 = engine_receiver_cache_from_config(config_0)
145
+ cache_1_p0 = processor_cache_from_config(config_1)
146
+ cache_1_p1 = engine_receiver_cache_from_config(config_1)
147
148
cache_size_gb = max(
149
config_0.model_config.multimodal_config.mm_processor_cache_gb,
@@ -275,8 +278,8 @@ def test_oversized_item_is_served_uncached():
278
assert p0_only.get_and_update_item((small, []), "small")[0] is small
279
assert p0_only.is_cached_item("small")
280
278
- p0 = MultiModalProcessorSenderCache(model_config) # type: ignore[arg-type]
279
- p1 = MultiModalReceiverCache(model_config) # type: ignore[arg-type]
281
+ p0 = LruKeyReplicatedSenderCache(model_config) # type: ignore[arg-type]
282
+ p1 = LruKeyReplicatedReceiverCache(model_config) # type: ignore[arg-type]
283
assert p0.get_and_update_item((item, []), "big")[0] is item
284
assert not p0.is_cached_item("big")
285
assert p1.get_and_update_item(item, "big") is item
@@ -296,8 +299,8 @@ def test_mm_cache_miss_raises_and_recovers():
299
model or network.
300
"""
301
model_config = _StubModelConfig(mm_processor_cache_gb=1)
299
- p0 = MultiModalProcessorSenderCache(model_config) # type: ignore[arg-type]
300
- p1 = MultiModalReceiverCache(model_config) # type: ignore[arg-type]
302
+ p0 = LruKeyReplicatedSenderCache(model_config) # type: ignore[arg-type]
303
+ p1 = LruKeyReplicatedReceiverCache(model_config) # type: ignore[arg-type]
304
305
mm_hash = "image_A"
306
item = MultiModalKwargsItem.dummy(nbytes=64)
@@ -338,7 +341,7 @@ def test_receiver_cache_replaces_stale_item_when_payload_resent():
341
and kills EngineCore. Prefer the fresh payload and keep it for later hits.
342
"""
343
model_config = _StubModelConfig(mm_processor_cache_gb=1)
341
- p1 = MultiModalReceiverCache(model_config) # type: ignore[arg-type]
344
+ p1 = LruKeyReplicatedReceiverCache(model_config) # type: ignore[arg-type]
345
small = MultiModalKwargsItem.dummy(nbytes=64)
346
large = MultiModalKwargsItem.dummy(nbytes=256)
347
mm_hash = "shared-id"
@@ -351,7 +354,7 @@ def test_receiver_cache_replaces_stale_item_when_payload_resent():
354
def test_receiver_cache_features_keep_resent_payload():
355
"""EngineCore updates features through get_and_update_features."""
356
model_config = _StubModelConfig(mm_processor_cache_gb=1)
354
- p1 = MultiModalReceiverCache(model_config) # type: ignore[arg-type]
357
+ p1 = LruKeyReplicatedReceiverCache(model_config) # type: ignore[arg-type]
358
small = MultiModalKwargsItem.dummy(nbytes=64)
359
large = MultiModalKwargsItem.dummy(nbytes=256)
360
mm_hash = "shared-id"
@@ -384,7 +387,7 @@ def test_mm_cache_miss_batches_all_drifted_hashes():
387
non-drifted items in the same request are still ingested.
388
"""
389
model_config = _StubModelConfig(mm_processor_cache_gb=1)
387
- p1 = MultiModalReceiverCache(model_config) # type: ignore[arg-type]
390
+ p1 = LruKeyReplicatedReceiverCache(model_config) # type: ignore[arg-type]
391
item = MultiModalKwargsItem.dummy(nbytes=64)
392
393
def _feature(
@@ -582,8 +585,8 @@ def test_cache_eviction_lru_cache():
585
model="llava-hf/llava-onevision-qwen2-0.5b-ov-hf",
586
mm_processor_cache_gb=6 / GiB_bytes,
587
)
585
- sender_cache = MultiModalProcessorSenderCache(model_config)
586
- receiver_cache = MultiModalReceiverCache(model_config)
588
+ sender_cache = LruKeyReplicatedSenderCache(model_config)
589
+ receiver_cache = LruKeyReplicatedReceiverCache(model_config)
590
591
_run_test_cache_eviction_lru(sender_cache, receiver_cache, base_item_size=1)
592
@@ -775,7 +778,7 @@ def test_processor_cache_shared_across_loras():
778
model="llava-hf/llava-onevision-qwen2-0.5b-ov-hf",
779
mm_processor_cache_gb=1,
780
)
778
- receiver_cache = MultiModalReceiverCache(model_config)
781
+ receiver_cache = LruKeyReplicatedReceiverCache(model_config)
782
783
base_mm_hash = "image_hash_abc123"
784
lora_a_identifier = f"12345:{base_mm_hash}"
@@ -827,8 +830,8 @@ async def test_release_kv_cache_resends_mm_payload(use_async, release_error):
830
)
831
model_config = ctx.model_config
832
830
- sender = MultiModalProcessorSenderCache(model_config)
831
- receiver = MultiModalReceiverCache(model_config)
833
+ sender = LruKeyReplicatedSenderCache(model_config)
834
+ receiver = LruKeyReplicatedReceiverCache(model_config)
835
item = _dummy_item({"pixel_values": 16})
836
mm_hash = "image_A"
837
payload, _ = sender.get_and_update_item((item, []), mm_hash)
tests/multimodal/test_registry.py
-22
@@ -10,31 +10,9 @@ import pytest
10
11
from vllm.multimodal import MULTIMODAL_REGISTRY
12
13
-from ..models.utils import build_model_context
14
-
13
pytestmark = pytest.mark.cpu_test
14
15
18
-@pytest.mark.parametrize(
19
- "model_id,limit_mm_per_prompt,expected",
20
- [
21
- ("Qwen/Qwen2-0.5B-Instruct", {}, False),
22
- ("Qwen/Qwen2.5-VL-3B-Instruct", {}, True),
23
- ("Qwen/Qwen2.5-VL-3B-Instruct", {"image": 0, "video": 0}, False),
24
- ("Qwen/Qwen2.5-VL-3B-Instruct", {"image": 0}, True),
25
- ],
26
-)
27
-@pytest.mark.core_model
28
-def test_supports_multimodal_inputs(model_id, limit_mm_per_prompt, expected):
29
- """Test supports_multimodal_inputs returns correct boolean for various
30
- configs."""
31
- ctx = build_model_context(
32
- model_id,
33
- limit_mm_per_prompt=limit_mm_per_prompt,
34
- )
35
- assert MULTIMODAL_REGISTRY.supports_multimodal_inputs(ctx.model_config) is expected
36
-
37
-
16
def test_create_processor_error_uses_served_model_name():
17
model_config = SimpleNamespace(
18
is_multimodal_model=False,
tests/renderers/test_cohere.py
+1
@@ -997,6 +997,7 @@ class _MockModelConfig:
997
skip_tokenizer_init: bool = True
998
is_encoder_decoder: bool = False
999
is_multimodal_model: bool = False
1000
+ supports_multimodal_inputs: bool = False
1001
renderer_num_workers: int = 1
1002
1003
tests/renderers/test_completions.py
+1
@@ -39,6 +39,7 @@ class MockModelConfig:
39
skip_tokenizer_init: bool = False
40
is_encoder_decoder: bool = False
41
is_multimodal_model: bool = False
42
+ supports_multimodal_inputs: bool = False
43
renderer_num_workers: int = 1
44
hidden_size: int = 768
45
dtype: torch.dtype = torch.float32
tests/renderers/test_kimi_k3.py
+1
@@ -40,6 +40,7 @@ class MockHFConfig:
40
class MockModelConfig:
41
runner_type: str = "generate"
42
is_multimodal_model: bool = False
43
+ supports_multimodal_inputs: bool = False
44
multimodal_config: Any = None
45
hf_config: MockHFConfig = field(default_factory=MockHFConfig)
46
allowed_local_media_path: str = ""
tests/renderers/test_mistral.py
+1
@@ -37,6 +37,7 @@ class MockModelConfig:
37
skip_tokenizer_init: bool = False
38
is_encoder_decoder: bool = False
39
is_multimodal_model: bool = False
40
+ supports_multimodal_inputs: bool = False
41
renderer_num_workers: int = 1
42
43
tests/v1/kv_connector/unit/test_handshake_pp_aggregation.py
+5
-6
@@ -79,11 +79,6 @@ def _run_engine_core_handshake(
79
"resolve_kv_cache_block_sizes",
80
lambda kv_cache_config, vllm_config: (16, 16),
81
)
82
- monkeypatch.setattr(
83
- engine_core_module,
84
- "MULTIMODAL_REGISTRY",
85
- SimpleNamespace(engine_receiver_cache_from_config=lambda vllm_config: None),
86
- )
82
monkeypatch.setattr(engine_core_module, "freeze_gc_heap", lambda: None)
83
monkeypatch.setattr(
84
engine_core_module, "maybe_attach_gc_debug_callback", lambda: None
@@ -105,7 +100,11 @@ def _run_engine_core_handshake(
100
speculative_config=None,
101
ec_transfer_config=None,
102
max_concurrent_batches=1,
108
- model_config=SimpleNamespace(runner_type="generate", is_diffusion=False),
103
+ model_config=SimpleNamespace(
104
+ runner_type="generate",
105
+ is_diffusion=False,
106
+ supports_multimodal_inputs=False,
107
+ ),
108
cache_config=SimpleNamespace(
109
enable_prefix_caching=False,
110
prefix_caching_hash_algo="builtin",
tests/v1/streaming_input/test_scheduler_streaming.py
+1
@@ -53,6 +53,7 @@ def create_scheduler() -> Scheduler:
53
vllm_config.model_config = MagicMock()
54
vllm_config.model_config.skip_tokenizer_init = True
55
vllm_config.model_config.is_multimodal_model = False
56
+ vllm_config.model_config.supports_multimodal_inputs = False
57
vllm_config.model_config.is_encoder_decoder = False
58
vllm_config.model_config.is_diffusion = False
59
vllm_config.model_config.max_model_len = 1024
tests/v1/worker/test_gpu_autoregressive_speculator.py
+19
-31
@@ -148,8 +148,16 @@ def test_speculator_uses_draft_model_hidden_size(monkeypatch, hc_mult, expected)
148
149
150
def test_mm_support_configured_after_model_load(monkeypatch):
151
- target_model_config = object()
152
- draft_model_config = object()
151
+ checked_configs = []
152
+
153
+ class _ModelConfig:
154
+ @property
155
+ def supports_multimodal_inputs(self):
156
+ checked_configs.append(self)
157
+ return True
158
+
159
+ target_model_config = _ModelConfig()
160
+ draft_model_config = _ModelConfig()
161
vllm_config = SimpleNamespace(model_config=target_model_config)
162
draft_model = _MultimodalDraftModel()
163
@@ -164,19 +172,8 @@ def test_mm_support_configured_after_model_load(monkeypatch):
172
speculator.supports_mm_inputs = False
173
speculator.use_acceptance_estimator = False
174
167
- checked_configs = []
168
-
169
- def supports_multimodal_inputs(model_config):
170
- checked_configs.append(model_config)
171
- return True
172
-
175
monkeypatch.setattr(DraftModelSpeculator, "__init__", init_base)
176
_mock_base_model_load(monkeypatch)
175
- monkeypatch.setattr(
176
- base_spec_module.MULTIMODAL_REGISTRY,
177
- "supports_multimodal_inputs",
178
- supports_multimodal_inputs,
179
- )
177
178
speculator = _TestSpeculator(vllm_config, torch.device("cpu"))
179
@@ -198,7 +195,9 @@ def test_load_model_keeps_mm_support_for_capable_drafter(monkeypatch):
195
speculator.supports_mm_inputs = False
196
speculator.inputs_embeds = None
197
speculator.use_acceptance_estimator = False
201
- speculator.vllm_config = SimpleNamespace(model_config=object())
198
+ speculator.vllm_config = SimpleNamespace(
199
+ model_config=SimpleNamespace(supports_multimodal_inputs=True)
200
+ )
201
speculator.max_num_tokens = 4
202
speculator.hidden_size = 3
203
speculator.dtype = torch.float32
@@ -206,11 +205,6 @@ def test_load_model_keeps_mm_support_for_capable_drafter(monkeypatch):
205
draft_model = _MultimodalDraftModel()
206
speculator.test_draft_model = draft_model
207
_mock_base_model_load(monkeypatch)
209
- monkeypatch.setattr(
210
- base_spec_module.MULTIMODAL_REGISTRY,
211
- "supports_multimodal_inputs",
212
- lambda model_config: True,
213
- )
208
209
speculator.load_model(torch.nn.Module())
210
@@ -223,16 +217,13 @@ def test_load_model_disables_mm_support_for_text_only_drafter(monkeypatch):
217
speculator.supports_mm_inputs = False
218
speculator.inputs_embeds = None
219
speculator.use_acceptance_estimator = False
226
- speculator.vllm_config = SimpleNamespace(model_config=object())
220
+ speculator.vllm_config = SimpleNamespace(
221
+ model_config=SimpleNamespace(supports_multimodal_inputs=True)
222
+ )
223
draft_model = _TextOnlyDraftModel()
224
speculator.test_draft_model = draft_model
225
warning_messages = []
226
_mock_base_model_load(monkeypatch)
231
- monkeypatch.setattr(
232
- base_spec_module.MULTIMODAL_REGISTRY,
233
- "supports_multimodal_inputs",
234
- lambda model_config: True,
235
- )
227
monkeypatch.setattr(
228
base_spec_module.logger,
229
"warning_once",
@@ -257,7 +248,9 @@ def test_multi_module_mm_support_configured_after_model_load(monkeypatch):
248
speculator.inputs_embeds = None
249
speculator.use_acceptance_estimator = False
250
speculator.cached_draft_input_embeds = None
260
- speculator.vllm_config = SimpleNamespace(model_config=object())
251
+ speculator.vllm_config = SimpleNamespace(
252
+ model_config=SimpleNamespace(supports_multimodal_inputs=True)
253
+ )
254
speculator.max_num_tokens = 4
255
speculator.max_num_reqs = 2
256
speculator.num_speculative_steps = 3
@@ -271,11 +264,6 @@ def test_multi_module_mm_support_configured_after_model_load(monkeypatch):
264
"load_draft_model",
265
lambda self, target_model, target_attn_layer_names: draft_model,
266
)
274
- monkeypatch.setattr(
275
- base_spec_module.MULTIMODAL_REGISTRY,
276
- "supports_multimodal_inputs",
277
- lambda model_config: True,
278
- )
267
268
speculator.load_model(torch.nn.Module())
269
vllm/config/model.py
+80
-8
@@ -865,6 +865,80 @@ class ModelConfig:
865
self._verify_quantization()
866
self._verify_cuda_graph()
867
868
+ def _supports_multimodal_inputs(self) -> bool:
869
+ """Checks if the model supports multimodal inputs.
870
+ Returns True if the model is multimodal with any non-zero supported
871
+ modalities, otherwise returns False, effectively running in
872
+ text-only mode.
873
+ """
874
+ if not self.is_multimodal_model:
875
+ return False
876
+
877
+ from vllm.multimodal import MULTIMODAL_REGISTRY
878
+
879
+ mm_config = self.get_multimodal_config()
880
+ try:
881
+ info = MULTIMODAL_REGISTRY.get_processing_info(self)
882
+ except ValueError:
883
+ # Speculative drafters for multimodal targets (e.g. Qwen3_5MTP,
884
+ # Exaone4_5_MTP, MiMoV2OmniMTP) declare `SupportsMultiModal` so
885
+ # that they can consume the embeddings merged by the target model,
886
+ # but they never run a multi-modal processor of their own. Running
887
+ # in text-only mode is the expected outcome for them, not a
888
+ # misconfiguration worth warning about.
889
+ if self.runner_type != "draft":
890
+ logger.warning_once(
891
+ "Model %s is treated as multimodal but has no registered "
892
+ "multimodal processor; running in text-only mode.",
893
+ self.model,
894
+ )
895
+ return False
896
+
897
+ # Check if all supported modalities have limit == 0
898
+ if all(
899
+ mm_config.get_limit_per_prompt(modality) == 0
900
+ for modality in info.supported_mm_limits
901
+ ):
902
+ # If enable_mm_embeds is True, we still need MM infrastructure
903
+ # to process pre-computed embeddings even though encoder won't run
904
+ if mm_config.enable_mm_embeds:
905
+ return True
906
+
907
+ logger.info_once(
908
+ "All limits of multimodal modalities supported by the model "
909
+ "are set to 0, running in text-only mode."
910
+ )
911
+ return False
912
+
913
+ return True
914
+
915
+ def _cached_supports_multimodal_inputs(self) -> bool:
916
+ cache = getattr(self, "_supports_multimodal_inputs_cache", None)
917
+ if cache is None:
918
+ self._supports_multimodal_inputs_cache = cache = {}
919
+
920
+ mm_config = self.multimodal_config
921
+ if mm_config is None:
922
+ mm_cache_key = None
923
+ else:
924
+ limits_per_prompt = {
925
+ modality: mm_config.get_limit_per_prompt(modality)
926
+ for modality in mm_config.limit_per_prompt
927
+ }
928
+ mm_cache_key = (
929
+ mm_config.language_model_only,
930
+ tuple(limits_per_prompt.items()),
931
+ mm_config.enable_mm_embeds,
932
+ )
933
+
934
+ cache_key = (self.is_multimodal_model, self.runner_type, mm_cache_key)
935
+ if cache_key in cache:
936
+ return cache[cache_key]
937
+
938
+ supports_mm = self._supports_multimodal_inputs()
939
+ cache[cache_key] = supports_mm
940
+ return supports_mm
941
+
942
def _supports_multimodal_for_mm_prefix(self) -> bool:
943
"""Whether multimodal inputs can still appear for this deployment.
944
@@ -881,24 +955,18 @@ class ModelConfig:
955
vision modality is still enabled (e.g. ``image=0`` but video allowed).
956
The deep-copied cache preserves the top-level decision instead.
957
"""
884
- cached = getattr(self, "_supports_multimodal_inputs_cached", None)
885
- if cached is not None:
886
- return cached
887
-
958
if self.multimodal_config is None:
959
# Early call before multimodal init — do not clear mm_prefix yet.
960
return True
961
892
- from vllm.multimodal import MULTIMODAL_REGISTRY
893
-
894
- supports_mm = MULTIMODAL_REGISTRY.supports_multimodal_inputs(self)
895
- self._supports_multimodal_inputs_cached = supports_mm
962
+ supports_mm = self._cached_supports_multimodal_inputs()
963
if not supports_mm:
964
logger.info_once(
965
"Disabled mm_prefix attention mode because multimodal inputs "
966
"are configuration-disabled. Attention backends without "
967
"mm_prefix support may now be selected."
968
)
969
+
970
return supports_mm
971
972
def get_model_arch_config(self) -> ModelArchitectureConfig:
@@ -1859,6 +1927,10 @@ class ModelConfig:
1927
def is_multimodal_raw_input_only_model(self) -> bool:
1928
return self._model_info.supports_multimodal_raw_input_only
1929
1930
+ @property
1931
+ def supports_multimodal_inputs(self) -> bool:
1932
+ return self._cached_supports_multimodal_inputs()
1933
+
1934
@property
1935
def requires_raw_input_tokens(self) -> bool:
1936
return self._model_info.requires_raw_input_tokens
vllm/multimodal/cache/__init__.py
new
+38
@@ -0,0 +1,38 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from .base import (
5
+ BaseMultiModalProcessorCache,
6
+ BaseMultiModalReceiverCache,
7
+ MultiModalCacheMissError,
8
+ MultiModalProcessorOnlyCache,
9
+)
10
+from .factories import (
11
+ engine_receiver_cache_from_config,
12
+ processor_cache_from_config,
13
+ processor_only_cache_from_config,
14
+ worker_receiver_cache_from_config,
15
+)
16
+from .lru import (
17
+ LruKeyReplicatedReceiverCache,
18
+ LruKeyReplicatedSenderCache,
19
+)
20
+from .shm import (
21
+ ShmObjectStoreReceiverCache,
22
+ ShmObjectStoreSenderCache,
23
+)
24
+
25
+__all__ = [
26
+ "MultiModalCacheMissError",
27
+ "BaseMultiModalProcessorCache",
28
+ "BaseMultiModalReceiverCache",
29
+ "MultiModalProcessorOnlyCache",
30
+ "LruKeyReplicatedSenderCache",
31
+ "LruKeyReplicatedReceiverCache",
32
+ "ShmObjectStoreReceiverCache",
33
+ "ShmObjectStoreSenderCache",
34
+ "processor_cache_from_config",
35
+ "processor_only_cache_from_config",
36
+ "engine_receiver_cache_from_config",
37
+ "worker_receiver_cache_from_config",
38
+]
vllm/multimodal/cache/base.py
renamed
+6
-361
@@ -4,26 +4,19 @@ import operator
4
import sys
5
from abc import ABC, abstractmethod
6
from collections.abc import Mapping, Sequence
7
-from multiprocessing.synchronize import Lock as LockType
8
-from typing import TYPE_CHECKING, Generic, TypeAlias, TypeVar, cast
7
+from typing import TYPE_CHECKING, Generic, TypeAlias, TypeVar
8
9
import torch
10
from typing_extensions import override
11
13
-import vllm.envs as envs
14
-from vllm.distributed.device_communicators.shm_object_storage import (
15
- MsgpackSerde,
16
- SingleWriterShmObjectStorage,
17
- SingleWriterShmRingBuffer,
18
-)
12
+from vllm.config import ModelConfig
13
from vllm.logger import init_logger
14
from vllm.utils.cache import CacheInfo, LRUCache
15
from vllm.utils.jsontree import json_count_leaves, json_map_leaves, json_reduce_leaves
22
-from vllm.utils.mem_constants import GiB_bytes, MiB_bytes
16
+from vllm.utils.mem_constants import GiB_bytes
17
from vllm.utils.mem_utils import format_gib
18
25
-from .inputs import (
26
- MultiModalBatchedField,
19
+from ..inputs import (
20
MultiModalFeatureSpec,
21
MultiModalFieldElem,
22
MultiModalKwargsItem,
@@ -32,9 +25,7 @@ from .inputs import (
25
)
26
27
if TYPE_CHECKING:
35
- from vllm.config import ModelConfig, VllmConfig
36
-
37
- from .processing.processor import ResolvedPromptUpdate
28
+ from ..processing.processor import ResolvedPromptUpdate
29
30
logger = init_logger(__name__)
31
@@ -399,7 +390,7 @@ class MultiModalProcessorOnlyCache(BaseMultiModalProcessorCache):
390
tensor data and metadata) into the cache, and return the input.
391
"""
392
402
- def __init__(self, model_config: "ModelConfig") -> None:
393
+ def __init__(self, model_config: ModelConfig) -> None:
394
super().__init__()
395
396
mm_config = model_config.get_multimodal_config()
@@ -440,216 +431,6 @@ class MultiModalProcessorOnlyCache(BaseMultiModalProcessorCache):
431
return self._cache.stat(delta=delta)
432
433
443
-class MultiModalProcessorSenderCache(BaseMultiModalProcessorCache):
444
- """The cache which is used on P0 when IPC caching is enabled.
445
-
446
- How to update each item:
447
-
448
- - If the item is already in the cache, clear the input to avoid
449
- unnecessary IPC.
450
-
451
- - If the item is not in the cache, store the metadata of that item so
452
- that the eviction policy remains the same as the cache on P1,
453
- and return the input.
454
- By only storing the metadata, we avoid keeping the data itself in
455
- memory inside P0.
456
- """
457
-
458
- def __init__(self, model_config: "ModelConfig") -> None:
459
- super().__init__()
460
-
461
- mm_config = model_config.get_multimodal_config()
462
-
463
- self._cache = MultiModalCache.get_lru_cache(
464
- mm_config.mm_processor_cache_gb,
465
- MultiModalProcessorCacheItemMetadata,
466
- )
467
-
468
- @override
469
- def is_cached_item(self, mm_hash: str) -> bool:
470
- return mm_hash in self._cache
471
-
472
- @override
473
- def get_and_update_item(
474
- self,
475
- mm_item: MultiModalProcessorCacheInItem,
476
- mm_hash: str,
477
- ) -> MultiModalProcessorCacheOutItem:
478
- if (cached_item := self._cache.get(mm_hash)) is not None:
479
- return None, cached_item.prompt_updates
480
-
481
- assert mm_item is not None, f"Expected a cached item for {mm_hash=}"
482
-
483
- self.cache_if_fits(
484
- self._cache, mm_hash, MultiModalProcessorCacheItemMetadata(*mm_item)
485
- )
486
- return mm_item
487
-
488
- @override
489
- def touch_sender_cache_item(self, mm_hash: str) -> None:
490
- self._cache.touch(mm_hash)
491
-
492
- @override
493
- def clear_cache(self) -> None:
494
- self._cache.clear()
495
-
496
- @override
497
- def make_stats(self, *, delta: bool = False) -> CacheInfo:
498
- return self._cache.stat(delta=delta)
499
-
500
- @override
501
- def invalidate(self, mm_hash: str) -> None:
502
- # Drop our stale shadow entry so the next request for this hash re-sends the
503
- # data and repopulates P1 (see MultiModalCacheMissError).
504
- self._cache.pop(mm_hash, None)
505
-
506
-
507
-class ShmObjectStoreSenderCache(BaseMultiModalProcessorCache):
508
- """The cache which is used on P0 when IPC caching is enabled.
509
-
510
- How to update each item:
511
-
512
- - If the item is already in the cache, clear the input to avoid
513
- unnecessary IPC.
514
-
515
- - If the item is not in the cache, store the data in shared memory.
516
- """
517
-
518
- def __init__(self, vllm_config: "VllmConfig") -> None:
519
- super().__init__()
520
-
521
- self.world_size = vllm_config.parallel_config.world_size
522
- mm_config = vllm_config.model_config.get_multimodal_config()
523
-
524
- ring_buffer = SingleWriterShmRingBuffer(
525
- data_buffer_size=int(mm_config.mm_processor_cache_gb * GiB_bytes),
526
- name=envs.VLLM_OBJECT_STORAGE_SHM_BUFFER_NAME,
527
- create=True, # sender is the writer
528
- )
529
- self._shm_cache = SingleWriterShmObjectStorage(
530
- max_object_size=mm_config.mm_shm_cache_max_object_size_mb * MiB_bytes,
531
- n_readers=self.world_size,
532
- ring_buffer=ring_buffer,
533
- serde_class=MsgpackSerde,
534
- )
535
- # cache prompt_updates for P0 only
536
- self._p0_cache: dict[str, Sequence[ResolvedPromptUpdate]] = {}
537
-
538
- self._hits = 0
539
- self._total = 0
540
- self._last_info = CacheInfo(hits=0, total=0)
541
-
542
- def _stat(self, *, delta: bool = False) -> CacheInfo:
543
- info = CacheInfo(hits=self._hits, total=self._total)
544
-
545
- if delta:
546
- info_delta = info - self._last_info
547
- self._last_info = info
548
- info = info_delta
549
-
550
- return info
551
-
552
- @override
553
- def is_cached_item(self, mm_hash: str) -> bool:
554
- return self._shm_cache.is_cached(mm_hash)
555
-
556
- @override
557
- def get_and_update_item(
558
- self,
559
- mm_item: MultiModalProcessorCacheInItem,
560
- mm_hash: str,
561
- ) -> MultiModalProcessorCacheOutItem:
562
- if self._shm_cache.is_cached(mm_hash):
563
- self._hits += 1
564
- self._total += 1
565
-
566
- address, monotonic_id = self._shm_cache.get_cached(mm_hash)
567
- prompt_updates = self._p0_cache[mm_hash]
568
- return self.address_as_item(address, monotonic_id), prompt_updates
569
-
570
- assert mm_item is not None, f"Expected a cached item for {mm_hash=}"
571
- item, prompt_updates = mm_item
572
-
573
- self._total += 1
574
-
575
- try:
576
- address, monotonic_id = self._shm_cache.put(mm_hash, item)
577
- # Try to remove dangling items if p0 cache is too large.
578
- if len(self._p0_cache) >= 2 * len(self._shm_cache.key_index):
579
- self.remove_dangling_items()
580
-
581
- self._p0_cache[mm_hash] = prompt_updates
582
- return self.address_as_item(address, monotonic_id), prompt_updates
583
- except ValueError as e:
584
- # `put` raises ValueError either for an oversize item or for a
585
- # duplicate key (concurrent insert); the latter is benign so we
586
- # only warn on the oversize case. Subsequent UUID-only requests
587
- # for an oversize item will fail with a cache miss.
588
- if "already exists" not in str(e):
589
- logger.warning_once(
590
- "mm_input %s too large to cache; "
591
- "raise --mm-shm-cache-max-object-size-mb. (%s)",
592
- mm_hash,
593
- str(e),
594
- )
595
- return mm_item
596
- except MemoryError as e:
597
- # Cache full and protected items prevent eviction.
598
- logger.debug(
599
- "mm_input %s not cached; shm cache full, "
600
- "consider raising --mm-processor-cache-gb. (%s)",
601
- mm_hash,
602
- str(e),
603
- )
604
- return mm_item
605
-
606
- @override
607
- def touch_sender_cache_item(self, mm_hash: str) -> None:
608
- """Touch the item in shared memory cache to prevent eviction.
609
- Increments writer_flag on sender side."""
610
- self._shm_cache.touch(mm_hash)
611
-
612
- @override
613
- def clear_cache(self) -> None:
614
- self._shm_cache.clear()
615
- self._p0_cache.clear()
616
-
617
- self._hits = 0
618
- self._total = 0
619
- self._last_info = CacheInfo(hits=0, total=0)
620
-
621
- @override
622
- def make_stats(self, *, delta: bool = False) -> CacheInfo:
623
- return self._stat(delta=delta)
624
-
625
- @override
626
- def close(self) -> None:
627
- self._shm_cache.close()
628
-
629
- def remove_dangling_items(self) -> None:
630
- """Remove items that are no longer in the shared memory cache."""
631
- cached_hashes = self._shm_cache.key_index.keys()
632
- dangling_hashes = set(self._p0_cache.keys()) - cached_hashes
633
- for mm_hash in dangling_hashes:
634
- del self._p0_cache[mm_hash]
635
-
636
- def address_as_item(
637
- self,
638
- address: int,
639
- monotonic_id: int,
640
- ) -> MultiModalKwargsItem:
641
- addr_elem = MultiModalFieldElem(
642
- data=address,
643
- field=MultiModalBatchedField(),
644
- )
645
- id_elem = MultiModalFieldElem(
646
- data=monotonic_id,
647
- field=MultiModalBatchedField(),
648
- )
649
-
650
- return MultiModalKwargsItem({"address": addr_elem, "monotonic_id": id_elem})
651
-
652
-
434
class BaseMultiModalReceiverCache(
435
BaseMultiModalCache[MultiModalKwargsItem | None, MultiModalKwargsItem]
436
):
@@ -703,139 +484,3 @@ class BaseMultiModalReceiverCache(
484
485
"""
486
raise NotImplementedError
706
-
707
-
708
-class MultiModalReceiverCache(BaseMultiModalReceiverCache):
709
- """The cache which is used on P1 when IPC caching is enabled.
710
-
711
- How to update each item:
712
-
713
- - If the caller sent tensor data, store it (replacing any cached item
714
- under the same key) and return that data. P0 can miss after independent
715
- LRU eviction and resend a different item for the same identity.
716
- - If the caller sent no data and the item is cached, return the cached item.
717
- - If the caller sent no data and the item is not cached, raise
718
- ``MultiModalCacheMissError``.
719
- """
720
-
721
- def __init__(self, model_config: "ModelConfig") -> None:
722
- super().__init__()
723
-
724
- mm_config = model_config.get_multimodal_config()
725
-
726
- self._cache = MultiModalCache.get_lru_cache(
727
- mm_config.mm_processor_cache_gb,
728
- MultiModalKwargsItem,
729
- )
730
-
731
- @override
732
- def get_and_update_item(
733
- self,
734
- mm_item: MultiModalKwargsItem | None,
735
- mm_hash: str,
736
- ) -> MultiModalKwargsItem:
737
- if mm_item is not None:
738
- # P0 sent a payload. Never keep a stale cached tensor under this
739
- # identity: a later P0 hit would pair new placeholders with the old
740
- # item and crash the engine. Drop first so a too-large replacement
741
- # cannot leave the previous entry in place.
742
- self._cache.pop(mm_hash, None)
743
- self.cache_if_fits(self._cache, mm_hash, mm_item)
744
- return mm_item
745
-
746
- if (cached_item := self._cache.get(mm_hash)) is not None:
747
- return cached_item
748
-
749
- # No data and not cached here: P0 sent data=None trusting its shadow, but
750
- # the P0/P1 caches have drifted. Raise a retryable error (not assert) so the
751
- # engine can have P0 drop the stale entry and the client resend the data.
752
- raise MultiModalCacheMissError([mm_hash])
753
-
754
- @override
755
- def touch_receiver_cache_item(
756
- self,
757
- mm_hash: str,
758
- mm_item: MultiModalKwargsItem | None = None,
759
- ) -> None:
760
- self._cache.touch(mm_hash)
761
-
762
- @override
763
- def clear_cache(self) -> None:
764
- self._cache.clear()
765
-
766
-
767
-class ShmObjectStoreReceiverCache(BaseMultiModalReceiverCache):
768
- """The cache which is used on P1 Worker Process when IPC caching is enabled.
769
-
770
- How to update each item:
771
-
772
- - If the item has an address, replace the input with the cached item.
773
- - If not, return the input.
774
- """
775
-
776
- def __init__(
777
- self,
778
- vllm_config: "VllmConfig",
779
- shared_worker_lock: LockType,
780
- ) -> None:
781
- super().__init__()
782
-
783
- self.world_size = vllm_config.parallel_config.world_size
784
- mm_config = vllm_config.model_config.get_multimodal_config()
785
-
786
- ring_buffer = SingleWriterShmRingBuffer(
787
- data_buffer_size=int(mm_config.mm_processor_cache_gb * GiB_bytes),
788
- name=envs.VLLM_OBJECT_STORAGE_SHM_BUFFER_NAME,
789
- create=False, # Server is a reader
790
- )
791
- self._shm_cache = SingleWriterShmObjectStorage(
792
- max_object_size=mm_config.mm_shm_cache_max_object_size_mb * MiB_bytes,
793
- n_readers=self.world_size,
794
- ring_buffer=ring_buffer,
795
- serde_class=MsgpackSerde,
796
- reader_lock=shared_worker_lock,
797
- )
798
-
799
- @override
800
- def get_and_update_features(
801
- self, mm_features: list["MultiModalFeatureSpec"]
802
- ) -> list["MultiModalFeatureSpec"]:
803
- # strip_covered_mm_data preserves address items, so None here represents
804
- # a stripped uncached payload and has no SHM reference to acknowledge.
805
- features_with_data = [
806
- feature for feature in mm_features if feature.data is not None
807
- ]
808
- super().get_and_update_features(features_with_data)
809
- return mm_features
810
-
811
- @override
812
- def get_and_update_item(
813
- self,
814
- mm_item: MultiModalKwargsItem | None,
815
- mm_hash: str,
816
- ) -> MultiModalKwargsItem:
817
- assert mm_item is not None, f"Expected an address item for {mm_hash=}"
818
- if "address" in mm_item:
819
- address = cast(int, mm_item["address"].data)
820
- monotonic_id = cast(int, mm_item["monotonic_id"].data)
821
- return self._shm_cache.get(address, monotonic_id)
822
-
823
- return mm_item
824
-
825
- @override
826
- def touch_receiver_cache_item(
827
- self,
828
- mm_hash: str,
829
- mm_item: MultiModalKwargsItem | None = None,
830
- ) -> None:
831
- """Touch the item in shared memory cache to prevent eviction.
832
- Increments reader_count on receiver side."""
833
- assert mm_item is not None
834
- if "address" in mm_item:
835
- address = cast(int, mm_item["address"].data)
836
- monotonic_id = cast(int, mm_item["monotonic_id"].data)
837
- self._shm_cache.touch(mm_hash, address=address, monotonic_id=monotonic_id)
838
-
839
- @override
840
- def clear_cache(self) -> None:
841
- self._shm_cache.clear()
vllm/multimodal/cache/factories.py
new
+107
@@ -0,0 +1,107 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+from multiprocessing.synchronize import Lock as LockType
4
+from typing import Literal
5
+
6
+from vllm.config import VllmConfig
7
+
8
+from .base import (
9
+ BaseMultiModalProcessorCache,
10
+ BaseMultiModalReceiverCache,
11
+ MultiModalProcessorOnlyCache,
12
+)
13
+from .lru import (
14
+ LruKeyReplicatedReceiverCache,
15
+ LruKeyReplicatedSenderCache,
16
+)
17
+from .shm import (
18
+ ShmObjectStoreReceiverCache,
19
+ ShmObjectStoreSenderCache,
20
+)
21
+
22
+
23
+def _get_cache_type(
24
+ vllm_config: VllmConfig,
25
+) -> Literal[None, "processor_only", "lru", "shm"]:
26
+ model_config = vllm_config.model_config
27
+ if not model_config.supports_multimodal_inputs:
28
+ return None
29
+
30
+ # Check if the cache is disabled.
31
+ mm_config = model_config.get_multimodal_config()
32
+ if mm_config.mm_processor_cache_gb <= 0:
33
+ return None
34
+
35
+ # Check if IPC caching is supported.
36
+ parallel_config = vllm_config.parallel_config
37
+ is_ipc_supported = parallel_config._api_process_count == 1 and (
38
+ parallel_config.data_parallel_size == 1
39
+ or parallel_config.data_parallel_external_lb
40
+ )
41
+
42
+ if not is_ipc_supported:
43
+ return "processor_only"
44
+
45
+ mm_config = model_config.get_multimodal_config()
46
+ return mm_config.mm_processor_cache_type
47
+
48
+
49
+def processor_cache_from_config(
50
+ vllm_config: VllmConfig,
51
+) -> BaseMultiModalProcessorCache | None:
52
+ """Return a `BaseMultiModalProcessorCache`, if enabled."""
53
+ cache_type = _get_cache_type(vllm_config)
54
+ if cache_type is None:
55
+ return None
56
+ elif cache_type == "processor_only":
57
+ return MultiModalProcessorOnlyCache(vllm_config.model_config)
58
+ elif cache_type == "lru":
59
+ return LruKeyReplicatedSenderCache(vllm_config.model_config)
60
+ elif cache_type == "shm":
61
+ return ShmObjectStoreSenderCache(vllm_config)
62
+ else:
63
+ raise ValueError(f"Unknown cache type: {cache_type!r}")
64
+
65
+
66
+def processor_only_cache_from_config(
67
+ vllm_config: VllmConfig,
68
+) -> MultiModalProcessorOnlyCache | None:
69
+ """Return a `MultiModalProcessorOnlyCache`, if enabled."""
70
+ cache_type = _get_cache_type(vllm_config)
71
+ if cache_type is None:
72
+ return None
73
+
74
+ return MultiModalProcessorOnlyCache(vllm_config.model_config)
75
+
76
+
77
+def engine_receiver_cache_from_config(
78
+ vllm_config: VllmConfig,
79
+) -> BaseMultiModalReceiverCache | None:
80
+ """Return a `BaseMultiModalReceiverCache` for the engine process."""
81
+ cache_type = _get_cache_type(vllm_config)
82
+ if cache_type in (None, "processor_only", "shm"):
83
+ return None
84
+ elif cache_type == "lru":
85
+ return LruKeyReplicatedReceiverCache(vllm_config.model_config)
86
+ else:
87
+ raise ValueError(f"Unknown cache type: {cache_type!r}")
88
+
89
+
90
+def worker_receiver_cache_from_config(
91
+ vllm_config: VllmConfig,
92
+ shared_worker_lock: LockType | None,
93
+) -> BaseMultiModalReceiverCache | None:
94
+ """Return a `BaseMultiModalReceiverCache` for the worker process."""
95
+ cache_type = _get_cache_type(vllm_config)
96
+ if cache_type in (None, "processor_only", "lru"):
97
+ return None
98
+ elif cache_type == "shm":
99
+ if shared_worker_lock is None:
100
+ raise ValueError(
101
+ "Missing `shared_worker_lock` argument from executor. "
102
+ "This argument is needed for mm_processor_cache_type='shm'."
103
+ )
104
+
105
+ return ShmObjectStoreReceiverCache(vllm_config, shared_worker_lock)
106
+ else:
107
+ raise ValueError(f"Unknown cache type: {cache_type!r}")
vllm/multimodal/cache/lru.py
new
+142
@@ -0,0 +1,142 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Implementation of Key-Replicated Cache (see docs/configuration/optimization.md)."""
4
+
5
+from typing_extensions import override
6
+
7
+from vllm.config import ModelConfig
8
+from vllm.utils.cache import CacheInfo
9
+
10
+from ..inputs import MultiModalKwargsItem
11
+from .base import (
12
+ BaseMultiModalProcessorCache,
13
+ BaseMultiModalReceiverCache,
14
+ MultiModalCache,
15
+ MultiModalCacheMissError,
16
+ MultiModalProcessorCacheInItem,
17
+ MultiModalProcessorCacheItemMetadata,
18
+ MultiModalProcessorCacheOutItem,
19
+)
20
+
21
+
22
+class LruKeyReplicatedSenderCache(BaseMultiModalProcessorCache):
23
+ """The cache which is used on P0 when LRU caching is enabled.
24
+
25
+ How to update each item:
26
+
27
+ - If the item is already in the cache, clear the input to avoid
28
+ unnecessary IPC.
29
+
30
+ - If the item is not in the cache, store the metadata of that item so
31
+ that the eviction policy remains the same as the cache on P1,
32
+ and return the input.
33
+ By only storing the metadata, we avoid keeping the data itself in
34
+ memory inside P0.
35
+ """
36
+
37
+ def __init__(self, model_config: ModelConfig) -> None:
38
+ super().__init__()
39
+
40
+ mm_config = model_config.get_multimodal_config()
41
+
42
+ self._cache = MultiModalCache.get_lru_cache(
43
+ mm_config.mm_processor_cache_gb,
44
+ MultiModalProcessorCacheItemMetadata,
45
+ )
46
+
47
+ @override
48
+ def is_cached_item(self, mm_hash: str) -> bool:
49
+ return mm_hash in self._cache
50
+
51
+ @override
52
+ def get_and_update_item(
53
+ self,
54
+ mm_item: MultiModalProcessorCacheInItem,
55
+ mm_hash: str,
56
+ ) -> MultiModalProcessorCacheOutItem:
57
+ if (cached_item := self._cache.get(mm_hash)) is not None:
58
+ return None, cached_item.prompt_updates
59
+
60
+ assert mm_item is not None, f"Expected a cached item for {mm_hash=}"
61
+
62
+ self.cache_if_fits(
63
+ self._cache, mm_hash, MultiModalProcessorCacheItemMetadata(*mm_item)
64
+ )
65
+ return mm_item
66
+
67
+ @override
68
+ def touch_sender_cache_item(self, mm_hash: str) -> None:
69
+ self._cache.touch(mm_hash)
70
+
71
+ @override
72
+ def clear_cache(self) -> None:
73
+ self._cache.clear()
74
+
75
+ @override
76
+ def make_stats(self, *, delta: bool = False) -> CacheInfo:
77
+ return self._cache.stat(delta=delta)
78
+
79
+ @override
80
+ def invalidate(self, mm_hash: str) -> None:
81
+ # Drop our stale shadow entry so the next request for this hash re-sends the
82
+ # data and repopulates P1 (see MultiModalCacheMissError).
83
+ self._cache.pop(mm_hash, None)
84
+
85
+
86
+class LruKeyReplicatedReceiverCache(BaseMultiModalReceiverCache):
87
+ """The cache which is used on P1 when LRU caching is enabled.
88
+
89
+ How to update each item:
90
+
91
+ - If the caller sent tensor data, store it (replacing any cached item
92
+ under the same key) and return that data. P0 can miss after independent
93
+ LRU eviction and resend a different item for the same identity.
94
+ - If the caller sent no data and the item is cached, return the cached item.
95
+ - If the caller sent no data and the item is not cached, raise
96
+ `MultiModalCacheMissError`.
97
+ """
98
+
99
+ def __init__(self, model_config: ModelConfig) -> None:
100
+ super().__init__()
101
+
102
+ mm_config = model_config.get_multimodal_config()
103
+
104
+ self._cache = MultiModalCache.get_lru_cache(
105
+ mm_config.mm_processor_cache_gb,
106
+ MultiModalKwargsItem,
107
+ )
108
+
109
+ @override
110
+ def get_and_update_item(
111
+ self,
112
+ mm_item: MultiModalKwargsItem | None,
113
+ mm_hash: str,
114
+ ) -> MultiModalKwargsItem:
115
+ if mm_item is not None:
116
+ # P0 sent a payload. Never keep a stale cached tensor under this
117
+ # identity: a later P0 hit would pair new placeholders with the old
118
+ # item and crash the engine. Drop first so a too-large replacement
119
+ # cannot leave the previous entry in place.
120
+ self._cache.pop(mm_hash, None)
121
+ self.cache_if_fits(self._cache, mm_hash, mm_item)
122
+ return mm_item
123
+
124
+ if (cached_item := self._cache.get(mm_hash)) is not None:
125
+ return cached_item
126
+
127
+ # No data and not cached here: P0 sent data=None trusting its shadow, but
128
+ # the P0/P1 caches have drifted. Raise a retryable error (not assert) so the
129
+ # engine can have P0 drop the stale entry and the client resend the data.
130
+ raise MultiModalCacheMissError([mm_hash])
131
+
132
+ @override
133
+ def touch_receiver_cache_item(
134
+ self,
135
+ mm_hash: str,
136
+ mm_item: MultiModalKwargsItem | None = None,
137
+ ) -> None:
138
+ self._cache.touch(mm_hash)
139
+
140
+ @override
141
+ def clear_cache(self) -> None:
142
+ self._cache.clear()
vllm/multimodal/cache/shm.py
new
+261
@@ -0,0 +1,261 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Implementation of Shared Memory Cache (see docs/configuration/optimization.md)."""
4
+
5
+from collections.abc import Sequence
6
+from multiprocessing.synchronize import Lock as LockType
7
+from typing import TYPE_CHECKING, cast
8
+
9
+from typing_extensions import override
10
+
11
+import vllm.envs as envs
12
+from vllm.config import VllmConfig
13
+from vllm.distributed.device_communicators.shm_object_storage import (
14
+ MsgpackSerde,
15
+ SingleWriterShmObjectStorage,
16
+ SingleWriterShmRingBuffer,
17
+)
18
+from vllm.logger import init_logger
19
+from vllm.utils.cache import CacheInfo
20
+from vllm.utils.mem_constants import GiB_bytes, MiB_bytes
21
+
22
+from ..inputs import (
23
+ MultiModalBatchedField,
24
+ MultiModalFeatureSpec,
25
+ MultiModalFieldElem,
26
+ MultiModalKwargsItem,
27
+)
28
+from .base import (
29
+ BaseMultiModalProcessorCache,
30
+ BaseMultiModalReceiverCache,
31
+ MultiModalProcessorCacheInItem,
32
+ MultiModalProcessorCacheOutItem,
33
+)
34
+
35
+if TYPE_CHECKING:
36
+ from ..processing.processor import ResolvedPromptUpdate
37
+
38
+logger = init_logger(__name__)
39
+
40
+
41
+class ShmObjectStoreSenderCache(BaseMultiModalProcessorCache):
42
+ """The cache which is used on P0 when SHM caching is enabled.
43
+
44
+ How to update each item:
45
+
46
+ - If the item is already in the cache, clear the input to avoid
47
+ unnecessary IPC.
48
+
49
+ - If the item is not in the cache, store the data in shared memory.
50
+ """
51
+
52
+ def __init__(self, vllm_config: VllmConfig) -> None:
53
+ super().__init__()
54
+
55
+ self.world_size = vllm_config.parallel_config.world_size
56
+ mm_config = vllm_config.model_config.get_multimodal_config()
57
+
58
+ ring_buffer = SingleWriterShmRingBuffer(
59
+ data_buffer_size=int(mm_config.mm_processor_cache_gb * GiB_bytes),
60
+ name=envs.VLLM_OBJECT_STORAGE_SHM_BUFFER_NAME,
61
+ create=True, # sender is the writer
62
+ )
63
+ self._shm_cache = SingleWriterShmObjectStorage(
64
+ max_object_size=mm_config.mm_shm_cache_max_object_size_mb * MiB_bytes,
65
+ n_readers=self.world_size,
66
+ ring_buffer=ring_buffer,
67
+ serde_class=MsgpackSerde,
68
+ )
69
+ # cache prompt_updates for P0 only
70
+ self._p0_cache: dict[str, Sequence[ResolvedPromptUpdate]] = {}
71
+
72
+ self._hits = 0
73
+ self._total = 0
74
+ self._last_info = CacheInfo(hits=0, total=0)
75
+
76
+ def _stat(self, *, delta: bool = False) -> CacheInfo:
77
+ info = CacheInfo(hits=self._hits, total=self._total)
78
+
79
+ if delta:
80
+ info_delta = info - self._last_info
81
+ self._last_info = info
82
+ info = info_delta
83
+
84
+ return info
85
+
86
+ @override
87
+ def is_cached_item(self, mm_hash: str) -> bool:
88
+ return self._shm_cache.is_cached(mm_hash)
89
+
90
+ @override
91
+ def get_and_update_item(
92
+ self,
93
+ mm_item: MultiModalProcessorCacheInItem,
94
+ mm_hash: str,
95
+ ) -> MultiModalProcessorCacheOutItem:
96
+ if self._shm_cache.is_cached(mm_hash):
97
+ self._hits += 1
98
+ self._total += 1
99
+
100
+ address, monotonic_id = self._shm_cache.get_cached(mm_hash)
101
+ prompt_updates = self._p0_cache[mm_hash]
102
+ return self.address_as_item(address, monotonic_id), prompt_updates
103
+
104
+ assert mm_item is not None, f"Expected a cached item for {mm_hash=}"
105
+ item, prompt_updates = mm_item
106
+
107
+ self._total += 1
108
+
109
+ try:
110
+ address, monotonic_id = self._shm_cache.put(mm_hash, item)
111
+ # Try to remove dangling items if p0 cache is too large.
112
+ if len(self._p0_cache) >= 2 * len(self._shm_cache.key_index):
113
+ self.remove_dangling_items()
114
+
115
+ self._p0_cache[mm_hash] = prompt_updates
116
+ return self.address_as_item(address, monotonic_id), prompt_updates
117
+ except ValueError as e:
118
+ # `put` raises ValueError either for an oversize item or for a
119
+ # duplicate key (concurrent insert); the latter is benign so we
120
+ # only warn on the oversize case. Subsequent UUID-only requests
121
+ # for an oversize item will fail with a cache miss.
122
+ if "already exists" not in str(e):
123
+ logger.warning_once(
124
+ "mm_input %s too large to cache; "
125
+ "raise --mm-shm-cache-max-object-size-mb. (%s)",
126
+ mm_hash,
127
+ str(e),
128
+ )
129
+ return mm_item
130
+ except MemoryError as e:
131
+ # Cache full and protected items prevent eviction.
132
+ logger.debug(
133
+ "mm_input %s not cached; shm cache full, "
134
+ "consider raising --mm-processor-cache-gb. (%s)",
135
+ mm_hash,
136
+ str(e),
137
+ )
138
+ return mm_item
139
+
140
+ @override
141
+ def touch_sender_cache_item(self, mm_hash: str) -> None:
142
+ """Touch the item in shared memory cache to prevent eviction.
143
+ Increments writer_flag on sender side."""
144
+ self._shm_cache.touch(mm_hash)
145
+
146
+ @override
147
+ def clear_cache(self) -> None:
148
+ self._shm_cache.clear()
149
+ self._p0_cache.clear()
150
+
151
+ self._hits = 0
152
+ self._total = 0
153
+ self._last_info = CacheInfo(hits=0, total=0)
154
+
155
+ @override
156
+ def make_stats(self, *, delta: bool = False) -> CacheInfo:
157
+ return self._stat(delta=delta)
158
+
159
+ @override
160
+ def close(self) -> None:
161
+ self._shm_cache.close()
162
+
163
+ def remove_dangling_items(self) -> None:
164
+ """Remove items that are no longer in the shared memory cache."""
165
+ cached_hashes = self._shm_cache.key_index.keys()
166
+ dangling_hashes = set(self._p0_cache.keys()) - cached_hashes
167
+ for mm_hash in dangling_hashes:
168
+ del self._p0_cache[mm_hash]
169
+
170
+ def address_as_item(
171
+ self,
172
+ address: int,
173
+ monotonic_id: int,
174
+ ) -> MultiModalKwargsItem:
175
+ addr_elem = MultiModalFieldElem(
176
+ data=address,
177
+ field=MultiModalBatchedField(),
178
+ )
179
+ id_elem = MultiModalFieldElem(
180
+ data=monotonic_id,
181
+ field=MultiModalBatchedField(),
182
+ )
183
+
184
+ return MultiModalKwargsItem({"address": addr_elem, "monotonic_id": id_elem})
185
+
186
+
187
+class ShmObjectStoreReceiverCache(BaseMultiModalReceiverCache):
188
+ """The cache which is used on P1 Worker Process when SHM caching is enabled.
189
+
190
+ How to update each item:
191
+
192
+ - If the item has an address, replace the input with the cached item.
193
+ - If not, return the input.
194
+ """
195
+
196
+ def __init__(
197
+ self,
198
+ vllm_config: VllmConfig,
199
+ shared_worker_lock: LockType,
200
+ ) -> None:
201
+ super().__init__()
202
+
203
+ self.world_size = vllm_config.parallel_config.world_size
204
+ mm_config = vllm_config.model_config.get_multimodal_config()
205
+
206
+ ring_buffer = SingleWriterShmRingBuffer(
207
+ data_buffer_size=int(mm_config.mm_processor_cache_gb * GiB_bytes),
208
+ name=envs.VLLM_OBJECT_STORAGE_SHM_BUFFER_NAME,
209
+ create=False, # Server is a reader
210
+ )
211
+ self._shm_cache = SingleWriterShmObjectStorage(
212
+ max_object_size=mm_config.mm_shm_cache_max_object_size_mb * MiB_bytes,
213
+ n_readers=self.world_size,
214
+ ring_buffer=ring_buffer,
215
+ serde_class=MsgpackSerde,
216
+ reader_lock=shared_worker_lock,
217
+ )
218
+
219
+ @override
220
+ def get_and_update_features(
221
+ self, mm_features: list["MultiModalFeatureSpec"]
222
+ ) -> list["MultiModalFeatureSpec"]:
223
+ # strip_covered_mm_data preserves address items, so None here represents
224
+ # a stripped uncached payload and has no SHM reference to acknowledge.
225
+ features_with_data = [
226
+ feature for feature in mm_features if feature.data is not None
227
+ ]
228
+ super().get_and_update_features(features_with_data)
229
+ return mm_features
230
+
231
+ @override
232
+ def get_and_update_item(
233
+ self,
234
+ mm_item: MultiModalKwargsItem | None,
235
+ mm_hash: str,
236
+ ) -> MultiModalKwargsItem:
237
+ assert mm_item is not None, f"Expected an address item for {mm_hash=}"
238
+ if "address" in mm_item:
239
+ address = cast(int, mm_item["address"].data)
240
+ monotonic_id = cast(int, mm_item["monotonic_id"].data)
241
+ return self._shm_cache.get(address, monotonic_id)
242
+
243
+ return mm_item
244
+
245
+ @override
246
+ def touch_receiver_cache_item(
247
+ self,
248
+ mm_hash: str,
249
+ mm_item: MultiModalKwargsItem | None = None,
250
+ ) -> None:
251
+ """Touch the item in shared memory cache to prevent eviction.
252
+ Increments reader_count on receiver side."""
253
+ assert mm_item is not None
254
+ if "address" in mm_item:
255
+ address = cast(int, mm_item["address"].data)
256
+ monotonic_id = cast(int, mm_item["monotonic_id"].data)
257
+ self._shm_cache.touch(mm_hash, address=address, monotonic_id=monotonic_id)
258
+
259
+ @override
260
+ def clear_cache(self) -> None:
261
+ self._shm_cache.clear()
vllm/multimodal/encoder_budget.py
+5
-4
@@ -4,7 +4,10 @@ from collections.abc import Mapping
4
5
from vllm.config import VllmConfig
6
from vllm.logger import init_logger
7
-from vllm.multimodal.cache import BaseMultiModalProcessorCache
7
+from vllm.multimodal.cache import (
8
+ BaseMultiModalProcessorCache,
9
+ processor_only_cache_from_config,
10
+)
11
from vllm.multimodal.inputs import MultiModalKwargsItem
12
from vllm.multimodal.processing import BaseMultiModalProcessor
13
from vllm.multimodal.registry import MultiModalRegistry
@@ -57,9 +60,7 @@ class MultiModalBudget:
60
61
with set_default_torch_num_threads(): # Avoid hang during startup
62
cache = (
60
- mm_registry.processor_only_cache_from_config(vllm_config)
61
- if enable_cache
62
- else None
63
+ processor_only_cache_from_config(vllm_config) if enable_cache else None
64
)
65
processor = mm_registry.create_processor(model_config)
66
vllm/multimodal/registry.py
+2
-138
@@ -3,21 +3,11 @@
3
import threading
4
from collections import defaultdict
5
from dataclasses import dataclass
6
-from multiprocessing.synchronize import Lock as LockType
7
-from typing import TYPE_CHECKING, Generic, Literal, Protocol, TypeVar, cast
6
+from typing import TYPE_CHECKING, Generic, Protocol, TypeVar, cast
7
8
from vllm.logger import init_logger
9
from vllm.tokenizers import TokenizerLike, cached_tokenizer_from_config
10
12
-from .cache import (
13
- BaseMultiModalProcessorCache,
14
- BaseMultiModalReceiverCache,
15
- MultiModalProcessorOnlyCache,
16
- MultiModalProcessorSenderCache,
17
- MultiModalReceiverCache,
18
- ShmObjectStoreReceiverCache,
19
- ShmObjectStoreSenderCache,
20
-)
11
from .processing import (
12
BaseDummyInputsBuilder,
13
BaseMultiModalProcessor,
@@ -27,7 +17,7 @@ from .processing import (
17
)
18
19
if TYPE_CHECKING:
30
- from vllm.config import ModelConfig, ObservabilityConfig, VllmConfig
20
+ from vllm.config import ModelConfig, ObservabilityConfig
21
from vllm.model_executor.models.interfaces import SupportsMultiModal
22
23
logger = init_logger(__name__)
@@ -89,51 +79,6 @@ class _ProcessorFactories(Generic[_I]):
79
class MultiModalRegistry:
80
"""A registry that dispatches data processing according to the model."""
81
92
- def supports_multimodal_inputs(self, model_config: "ModelConfig") -> bool:
93
- """Checks if the model supports multimodal inputs.
94
- Returns True if the model is multimodal with any non-zero supported
95
- modalities, otherwise returns False, effectively running in
96
- text-only mode.
97
- """
98
- if not model_config.is_multimodal_model:
99
- return False
100
-
101
- mm_config = model_config.get_multimodal_config()
102
- try:
103
- info = self._create_processing_info(model_config, tokenizer=None)
104
- except ValueError:
105
- # Speculative drafters for multimodal targets (e.g. Qwen3_5MTP,
106
- # Exaone4_5_MTP, MiMoV2OmniMTP) declare `SupportsMultiModal` so
107
- # that they can consume the embeddings merged by the target model,
108
- # but they never run a multi-modal processor of their own. Running
109
- # in text-only mode is the expected outcome for them, not a
110
- # misconfiguration worth warning about.
111
- if model_config.runner_type != "draft":
112
- logger.warning_once(
113
- "Model %s is treated as multimodal but has no registered "
114
- "multimodal processor; running in text-only mode.",
115
- model_config.model,
116
- )
117
- return False
118
-
119
- # Check if all supported modalities have limit == 0
120
- if all(
121
- mm_config.get_limit_per_prompt(modality) == 0
122
- for modality in info.supported_mm_limits
123
- ):
124
- # If enable_mm_embeds is True, we still need MM infrastructure
125
- # to process pre-computed embeddings even though encoder won't run
126
- if mm_config.enable_mm_embeds:
127
- return True
128
-
129
- logger.info_once(
130
- "All limits of multimodal modalities supported by the model "
131
- "are set to 0, running in text-only mode."
132
- )
133
- return False
134
-
135
- return True
136
-
82
def register_processor(
83
self,
84
processor: MultiModalProcessorFactory[_I],
@@ -220,87 +165,6 @@ class MultiModalRegistry:
165
166
return factories.build_processor(ctx)
167
223
- def _get_cache_type(
224
- self,
225
- vllm_config: "VllmConfig",
226
- ) -> Literal[None, "processor_only", "lru", "shm"]:
227
- model_config = vllm_config.model_config
228
- if not self.supports_multimodal_inputs(model_config):
229
- return None
230
-
231
- # Check if the cache is disabled.
232
- mm_config = model_config.get_multimodal_config()
233
- if mm_config.mm_processor_cache_gb <= 0:
234
- return None
235
-
236
- # Check if IPC caching is supported.
237
- parallel_config = vllm_config.parallel_config
238
- is_ipc_supported = parallel_config._api_process_count == 1 and (
239
- parallel_config.data_parallel_size == 1
240
- or parallel_config.data_parallel_external_lb
241
- )
242
-
243
- if not is_ipc_supported:
244
- return "processor_only"
245
-
246
- mm_config = model_config.get_multimodal_config()
247
- return mm_config.mm_processor_cache_type
248
-
249
- def processor_cache_from_config(
250
- self,
251
- vllm_config: "VllmConfig",
252
- ) -> BaseMultiModalProcessorCache | None:
253
- """Return a `BaseMultiModalProcessorCache`, if enabled."""
254
- cache_type = self._get_cache_type(vllm_config)
255
- if cache_type is None:
256
- return None
257
- elif cache_type == "processor_only":
258
- return MultiModalProcessorOnlyCache(vllm_config.model_config)
259
- elif cache_type == "lru":
260
- return MultiModalProcessorSenderCache(vllm_config.model_config)
261
- elif cache_type == "shm":
262
- return ShmObjectStoreSenderCache(vllm_config)
263
- else:
264
- raise ValueError(f"Unknown cache type: {cache_type!r}")
265
-
266
- def processor_only_cache_from_config(
267
- self,
268
- vllm_config: "VllmConfig",
269
- ) -> MultiModalProcessorOnlyCache | None:
270
- """Return a `MultiModalProcessorOnlyCache`, if enabled."""
271
- cache_type = self._get_cache_type(vllm_config)
272
- if cache_type is None:
273
- return None
274
-
275
- return MultiModalProcessorOnlyCache(vllm_config.model_config)
276
-
277
- def engine_receiver_cache_from_config(
278
- self,
279
- vllm_config: "VllmConfig",
280
- ) -> BaseMultiModalReceiverCache | None:
281
- """Return a `BaseMultiModalReceiverCache` for the engine process."""
282
- cache_type = self._get_cache_type(vllm_config)
283
- if cache_type in (None, "processor_only", "shm"):
284
- return None
285
- elif cache_type == "lru":
286
- return MultiModalReceiverCache(vllm_config.model_config)
287
- else:
288
- raise ValueError(f"Unknown cache type: {cache_type!r}")
289
-
290
- def worker_receiver_cache_from_config(
291
- self,
292
- vllm_config: "VllmConfig",
293
- shared_worker_lock: LockType,
294
- ) -> BaseMultiModalReceiverCache | None:
295
- """Return a `BaseMultiModalReceiverCache` for the worker process."""
296
- cache_type = self._get_cache_type(vllm_config)
297
- if cache_type in (None, "processor_only", "lru"):
298
- return None
299
- elif cache_type == "shm":
300
- return ShmObjectStoreReceiverCache(vllm_config, shared_worker_lock)
301
- else:
302
- raise ValueError(f"Unknown cache type: {cache_type!r}")
303
-
168
169
class MultiModalTimingRegistry:
170
def __init__(self, observability_config: "ObservabilityConfig | None") -> None:
vllm/renderers/base.py
+8
-6
@@ -32,7 +32,11 @@ from vllm.inputs import (
32
)
33
from vllm.logger import init_logger
34
from vllm.multimodal import MULTIMODAL_REGISTRY as mm_registry
35
-from vllm.multimodal.cache import BaseMultiModalProcessorCache
35
+from vllm.multimodal.cache import (
36
+ BaseMultiModalProcessorCache,
37
+ processor_cache_from_config,
38
+ processor_only_cache_from_config,
39
+)
40
from vllm.multimodal.gpu_ipc_memory import maybe_init_mm_gpu_ipc_pool
41
from vllm.multimodal.parse import (
42
MultiModalDataItems,
@@ -149,7 +153,7 @@ class BaseRenderer(ABC, Generic[_T]):
153
154
self._mm_cache_stats: MultiModalCacheStats | None = None
155
152
- if mm_registry.supports_multimodal_inputs(config.model_config):
156
+ if config.model_config.supports_multimodal_inputs:
157
# Install the process-global GPU memory pool used to gate
158
# frontend GPU-side multimodal decoding (no-op when the budget
159
# is 0). Lives in the API-server process only.
@@ -166,10 +170,8 @@ class BaseRenderer(ABC, Generic[_T]):
170
tokenizer=self.tokenizer,
171
)
172
169
- self._mm_processor_cache = mm_registry.processor_cache_from_config(config)
170
- self._mm_processor_only_cache = (
171
- mm_registry.processor_only_cache_from_config(config)
172
- )
173
+ self._mm_processor_cache = processor_cache_from_config(config)
174
+ self._mm_processor_only_cache = processor_only_cache_from_config(config)
175
176
# This is used to generate internal request ID for MM processing
177
# It has no relation to the request ID for engine core
vllm/v1/core/sched/scheduler.py
+1
-3
@@ -234,9 +234,7 @@ class Scheduler(SchedulerInterface):
234
235
# Encoder-related.
236
# Calculate encoder cache size if applicable
237
- supports_mm_inputs = mm_registry.supports_multimodal_inputs(
238
- vllm_config.model_config
239
- )
237
+ supports_mm_inputs = vllm_config.model_config.supports_multimodal_inputs
238
mm_budget = (
239
MultiModalBudget(vllm_config, mm_registry) if supports_mm_inputs else None
240
)
vllm/v1/engine/core.py
+5
-6
@@ -31,8 +31,10 @@ from vllm.envs import enable_envs_cache
31
from vllm.logger import init_logger
32
from vllm.logging_utils.dump_input import dump_engine_exception
33
from vllm.lora.request import LoRARequest
34
-from vllm.multimodal import MULTIMODAL_REGISTRY
35
-from vllm.multimodal.cache import MultiModalCacheMissError
34
+from vllm.multimodal.cache import (
35
+ MultiModalCacheMissError,
36
+ engine_receiver_cache_from_config,
37
+)
38
from vllm.tasks import POOLING_TASKS, SupportedTask
39
from vllm.tracing import instrument, maybe_init_worker_tracer
40
from vllm.transformers_utils.config import maybe_register_config_serialize_by_value
@@ -182,10 +184,7 @@ class EngineCore:
184
if self.scheduler.ec_connector is not None: # type: ignore
185
self.model_executor.init_ec_output_aggregator()
186
185
- mm_registry = MULTIMODAL_REGISTRY
186
- self.mm_receiver_cache = mm_registry.engine_receiver_cache_from_config(
187
- vllm_config
188
- )
187
+ self.mm_receiver_cache = engine_receiver_cache_from_config(vllm_config)
188
189
# If a KV connector is initialized for scheduler, we want to collect
190
# handshake metadata from all workers so the connector in the scheduler
vllm/v1/engine/input_processor.py
+1
-1
@@ -62,7 +62,7 @@ class InputProcessor:
62
63
self.renderer = renderer or renderer_from_config(vllm_config)
64
65
- self.supports_mm_inputs = mm_registry.supports_multimodal_inputs(model_config)
65
+ self.supports_mm_inputs = model_config.supports_multimodal_inputs
66
self.mm_encoder_cache_size = 0
67
self.skip_prompt_length_check = False
68
if self.supports_mm_inputs:
vllm/v1/spec_decode/llm_base_proposer.py
+1
-5
@@ -35,7 +35,6 @@ from vllm.model_executor.models.laguna_dflash import DFlashLagunaForCausalLM
35
from vllm.model_executor.models.llama_eagle3 import Eagle3LlamaForCausalLM
36
from vllm.model_executor.models.qwen3_dflash import DFlashQwen3ForCausalLM
37
from vllm.model_executor.models.qwen3_eagle3 import Eagle3Qwen3ForCausalLM
38
-from vllm.multimodal import MULTIMODAL_REGISTRY
38
from vllm.platforms import current_platform
39
from vllm.utils.torch_utils import PIN_MEMORY, async_tensor_h2d
40
from vllm.v1.attention.backend import CommonAttentionMetadata
@@ -165,10 +164,7 @@ class SpecDecodeBaseProposer:
164
self.max_positions = self.max_num_tokens
165
166
# Multi-modal data support
168
- self.mm_registry = MULTIMODAL_REGISTRY
169
- self.supports_mm_inputs = self.mm_registry.supports_multimodal_inputs(
170
- vllm_config.model_config
171
- )
167
+ self.supports_mm_inputs = vllm_config.model_config.supports_multimodal_inputs
168
169
self.draft_attn_groups: list[AttentionGroup] = []
170
self.kv_cache_gid: int = -1
vllm/v1/worker/gpu/model_runner.py
+1
-3
@@ -257,9 +257,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
257
258
# Multimodal
259
self.mm_registry = MULTIMODAL_REGISTRY
260
- self.supports_mm_inputs = self.mm_registry.supports_multimodal_inputs(
261
- self.model_config
262
- )
260
+ self.supports_mm_inputs = self.model_config.supports_multimodal_inputs
261
self.uses_inputs_embeds = (
262
self.supports_mm_inputs or self.model_config.enable_prompt_embeds
263
)
vllm/v1/worker/gpu/spec_decode/speculator.py
+1
-4
@@ -15,7 +15,6 @@ from vllm.distributed.eplb.eplb_state import EplbState
15
from vllm.logger import init_logger
16
from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
17
from vllm.model_executor.models import supports_multimodal_embeddings
18
-from vllm.multimodal import MULTIMODAL_REGISTRY
18
from vllm.v1.kv_cache_interface import KVCacheConfig
19
from vllm.v1.watermarking import create_watermarker
20
from vllm.v1.watermarking.spec_decode import (
@@ -226,9 +225,7 @@ class DraftModelSpeculator(BaseSpeculator):
225
)
226
self.draft_attn_layer_names = all_attn_layers - target_attn_layer_names
227
229
- target_supports_mm = MULTIMODAL_REGISTRY.supports_multimodal_inputs(
230
- self.vllm_config.model_config
231
- )
228
+ target_supports_mm = self.vllm_config.model_config.supports_multimodal_inputs
229
draft_supports_mm = supports_multimodal_embeddings(self.model)
230
self.supports_mm_inputs = target_supports_mm and draft_supports_mm
231
if target_supports_mm and not draft_supports_mm:
vllm/v1/worker/gpu_model_runner.py
+1
-3
@@ -550,9 +550,7 @@ class GPUModelRunner(
550
self.mm_registry = MULTIMODAL_REGISTRY
551
self.uses_mrope = model_config.uses_mrope
552
self.mrope_num_dims = model_config.mrope_num_dims
553
- self.supports_mm_inputs = self.mm_registry.supports_multimodal_inputs(
554
- model_config
555
- )
553
+ self.supports_mm_inputs = model_config.supports_multimodal_inputs
554
555
if self.model_config.is_encoder_decoder:
556
# Maximum length of the encoder input, only for encoder-decoder
vllm/v1/worker/worker_base.py
+5
-21
@@ -12,7 +12,7 @@ from vllm.config import VllmConfig, set_current_vllm_config
12
from vllm.distributed.kv_transfer.kv_connector.utils import get_current_attn_backends
13
from vllm.logger import init_logger
14
from vllm.lora.request import LoRARequest
15
-from vllm.multimodal import MULTIMODAL_REGISTRY
15
+from vllm.multimodal.cache import worker_receiver_cache_from_config
16
from vllm.tracing import instrument
17
from vllm.utils.import_utils import resolve_obj_by_qualname
18
from vllm.utils.system_utils import update_environment_variables
@@ -315,26 +315,10 @@ class WorkerWrapperBase:
315
)
316
317
shared_worker_lock = kwargs.pop("shared_worker_lock", None)
318
- if shared_worker_lock is None:
319
- msg = (
320
- "Missing `shared_worker_lock` argument from executor. "
321
- "This argument is needed for mm_processor_cache_type='shm'."
322
- )
323
-
324
- mm_config = vllm_config.model_config.multimodal_config
325
- if mm_config and mm_config.mm_processor_cache_type == "shm":
326
- raise ValueError(msg)
327
- else:
328
- logger.warning_once(msg)
329
-
330
- self.mm_receiver_cache = None
331
- else:
332
- self.mm_receiver_cache = (
333
- MULTIMODAL_REGISTRY.worker_receiver_cache_from_config(
334
- vllm_config,
335
- shared_worker_lock,
336
- )
337
- )
318
+ self.mm_receiver_cache = worker_receiver_cache_from_config(
319
+ vllm_config,
320
+ shared_worker_lock,
321
+ )
322
323
with set_current_vllm_config(self.vllm_config):
324
# To make vLLM config available during worker initialization