[EPD] Remove duplicate image preprocessing in EPD and enable preprocess on GPU (#50390)

Signed-off-by: Tianyu Guo <guoty@inferact.ai>

Tianyu Guo committed Aug 6, 2026 at 06:59 UTC b30291cf0c9a15898589f0347906bad07f3b538b
22 files changed +903 -44
examples/disaggregated/disaggregated_encoder/disagg_epd_proxy.py
+162 -6
@@ -21,13 +21,18 @@ from __future__ import annotations
21
22 import argparse
23 import asyncio
24 +import hashlib
25 +import io
26 +import json
27 import logging
28 import os
29 import random
30 +import time
31 import uuid
32 from collections.abc import AsyncIterator
33
34 import aiohttp
35 +import pybase64 as base64
36 import uvicorn
37 from fastapi import FastAPI, HTTPException, Request
38 from fastapi.responses import JSONResponse, StreamingResponse
@@ -53,6 +58,89 @@ decode_session: aiohttp.ClientSession | None = None
58
59 MM_TYPES = {"image_url", "audio_url", "input_audio"}
60
61 +# Diagnostic switch: forward the original request to the decoder so the
62 +# only difference from the rewrite path is the rewrite itself.
63 +NO_REWRITE = False
64 +
65 +
66 +# Grid metadata reported by the encoder instance, keyed by item index.
67 +# Empty when the encoder did not report any (then nothing is rewritten).
68 +def content_uuid(item: dict) -> str:
69 + """Cache key for a multimodal item, derived from its content.
70 +
71 + Must be content-derived, not request-derived: the EC cache is keyed by this
72 + value, so a per-request key (a request id, say) would make every request a
73 + miss and throw away cross-request reuse of already-encoded media -- while
74 + the unmodified path, which hashes the content, would keep it. That asymmetry
75 + silently biases any comparison between the two.
76 + """
77 + url = (item.get("image_url") or item.get("audio_url") or {}).get("url") or ""
78 + payload = url or json.dumps(item, sort_keys=True)
79 + return hashlib.sha256(payload.encode()).hexdigest()
80 +
81 +
82 +def _b64_tensor(values: list) -> str:
83 + import torch
84 +
85 + buf = io.BytesIO()
86 + grid = torch.tensor(values, dtype=torch.long)
87 + # Downstream stacks per item, so hand over a flat (t, h, w).
88 + torch.save(grid.reshape(-1)[:3], buf)
89 + return base64.b64encode(buf.getvalue()).decode()
90 +
91 +
92 +def rewrite_for_decode(req_data: dict, item_meta: dict[int, dict]) -> dict:
93 + """Replace each image item with a metadata-only reference for the decoder.
94 +
95 + The decoder does not need the pixels: the encoder instance already produced
96 + the embedding and published it through the EC connector under the same uuid.
97 + Sending only the grid lets the decoder size the placeholder range without
98 + re-running the image transform.
99 +
100 + `item_meta` holds what the encoder reported for each item (its cache key and
101 + the grid its processor actually produced), so the grid is never re-derived
102 + here -- a second derivation could disagree with the encoder's.
103 + """
104 + rewritten = 0
105 + idx = 0
106 + new_messages = []
107 + for msg in req_data.get("messages", []):
108 + content = msg.get("content")
109 + if not isinstance(content, list):
110 + new_messages.append(msg)
111 + continue
112 + new_content = []
113 + for item in content:
114 + if item.get("type") not in MM_TYPES:
115 + new_content.append(item)
116 + continue
117 + meta = dict(item_meta.get(idx) or {})
118 + idx += 1
119 + item_uuid = meta.pop("mm_hash", None)
120 + # Whatever keys the encoder reported are the metadata its model
121 + # declared as needed to size the placeholder range; the proxy does
122 + # not need to know their names.
123 + metadata = {k: _b64_tensor(v) for k, v in meta.items()}
124 + if not metadata or not item_uuid:
125 + # The encoder reported no metadata (e.g. the item came from its
126 + # processor cache); let the decoder process the media itself.
127 + new_content.append(item)
128 + continue
129 + new_content.append(
130 + {
131 + "type": "image_embeds",
132 + "image_embeds": metadata,
133 + "uuid": item_uuid,
134 + }
135 + )
136 + rewritten += 1
137 + new_messages.append({**msg, "content": new_content})
138 +
139 + if not rewritten:
140 + return req_data
141 + logger.info("Rewrote %d image item(s) as metadata references", rewritten)
142 + return {**req_data, "messages": new_messages}
143 +
144
145 def extract_mm_items(request_data: dict) -> list[dict]:
146 """
@@ -77,22 +165,29 @@ async def fanout_encoder_primer(
165 orig_request: dict,
166 e_urls: list[str],
167 req_id: str,
80 -) -> None:
168 +) -> dict[int, dict]:
169 """
170 1. Build one request *per MM item* with all text removed.
171 2. Send them concurrently to the encode cluster.
172 3. Raise if any of them fails.
173 +
174 + Returns, per item index, the metadata the encoder reported in
175 + `ec_transfer_params`: its EC cache key and the grid its processor produced.
176 + The proxy still supplies the uuid so both sides key the cache the same way;
177 + the grid can only come from the encoder, which is the side that computed it.
178 """
179 logger.info("[%s] Processing multimodal items...", req_id)
180
181 mm_items = extract_mm_items(orig_request)
182 if not mm_items:
183 logger.info("[%s] No multimodal items, skipping encoder", req_id)
91 - return # nothing to do
184 + return {} # nothing to do
185
186 logger.info("[%s] got %d multimodal items...", req_id, len(mm_items))
187
188 tasks = []
189 + item_uuids: dict[int, str] = {}
190 + item_meta: dict[int, dict] = {}
191
192 # Round-robin over encode servers to distribute load a bit
193 url_cycle = (e_urls[i % len(e_urls)] for i in range(len(mm_items)))
@@ -102,11 +197,24 @@ async def fanout_encoder_primer(
197 child_req_id = f"{req_id}:{idx}:{uuid.uuid4().hex[:6]}"
198 headers = {"x-request-id": child_req_id}
199
200 + # With --no-rewrite the decoder still receives the raw image and derives
201 + # the cache key by hashing it, so the encoder must do the same -- passing
202 + # a uuid here would make the two disagree and silently defeat the EC
203 + # transfer, leaving the decoder to encode the image itself.
204 + item_uuid = None if NO_REWRITE else content_uuid(item)
205 + if item_uuid is not None:
206 + item_uuids[idx] = item_uuid
207 +
208 encoder_req = {
209 # You *may* need to keep additional fields
210 "model": orig_request.get("model"),
211 "messages": [
109 - {"role": "user", "content": [item]},
212 + {
213 + "role": "user",
214 + "content": [
215 + item if item_uuid is None else {**item, "uuid": item_uuid}
216 + ],
217 + },
218 ],
219 # No max_tokens cap: the encoder instance never samples, it finishes
220 # once the prompt is encoded and its embeddings are published.
@@ -152,9 +260,21 @@ async def fanout_encoder_primer(
260 detail=f"Encoder request failed: {detail}",
261 )
262
263 + # The encoder reports each item's cache key and grid here.
264 + try:
265 + params = (await r.json()).get("ec_transfer_params") or {}
266 + reported = params.get("ec_items") or []
267 + except Exception:
268 + logger.warning("[%s] Could not read encoder metadata #%d", req_id, idx)
269 + reported = []
270 + if reported and idx in item_uuids:
271 + # One item per encoder request, so the first entry is this item's.
272 + item_meta[idx] = {**reported[0], "mm_hash": item_uuids[idx]}
273 +
274 logger.info(
275 "[%s] All %d encoder requests completed successfully", req_id, len(mm_items)
276 )
277 + return item_meta
278
279
280 async def maybe_prefill(
@@ -320,7 +440,11 @@ async def forward_non_stream(
440 ) -> dict:
441 try:
442 # Step 1: Process through Encoder instance (if has MM input)
323 - await fanout_encoder_primer(req_data, e_urls, req_id)
443 + _t0 = time.perf_counter()
444 + item_meta = await fanout_encoder_primer(req_data, e_urls, req_id)
445 + _t1 = time.perf_counter()
446 + req_data = req_data if NO_REWRITE else rewrite_for_decode(req_data, item_meta)
447 + _t2 = time.perf_counter()
448
449 # Step 2: Process through Prefill instance
450 req_data = await maybe_prefill(req_data, p_url, req_id)
@@ -334,7 +458,17 @@ async def forward_non_stream(
458 f"{d_url}/v1/chat/completions", json=req_data, headers=headers
459 ) as resp:
460 resp.raise_for_status()
337 - return await resp.json()
461 + out = await resp.json()
462 + _t3 = time.perf_counter()
463 + logger.info(
464 + "STAGE %s encode=%.1f rewrite=%.1f decode=%.1f total=%.1f",
465 + "no-rewrite" if NO_REWRITE else "rewrite",
466 + (_t1 - _t0) * 1e3,
467 + (_t2 - _t1) * 1e3,
468 + (_t3 - _t2) * 1e3,
469 + (_t3 - _t0) * 1e3,
470 + )
471 + return out
472
473 except HTTPException:
474 raise
@@ -348,7 +482,11 @@ async def forward_stream(
482 ) -> AsyncIterator[str]:
483 try:
484 # Step 1: Process through Encoder instance (if has MM input)
351 - await fanout_encoder_primer(req_data, e_urls, req_id)
485 + _t0 = time.perf_counter()
486 + item_meta = await fanout_encoder_primer(req_data, e_urls, req_id)
487 + _t1 = time.perf_counter()
488 + req_data = req_data if NO_REWRITE else rewrite_for_decode(req_data, item_meta)
489 + _t2 = time.perf_counter()
490
491 # Step 2: Process through Prefill instance
492 req_data = await maybe_prefill(req_data, p_url, req_id)
@@ -358,6 +496,7 @@ async def forward_stream(
496 headers = {"x-request-id": req_id}
497
498 # Streaming response
499 + _first = None
500 async with decode_session.post(
501 f"{d_url}/v1/chat/completions",
502 json=req_data,
@@ -366,8 +505,19 @@ async def forward_stream(
505 resp.raise_for_status()
506 async for chunk in resp.content.iter_chunked(1024):
507 if chunk:
508 + if _first is None:
509 + _first = time.perf_counter()
510 yield chunk.decode("utf-8", errors="ignore")
511 + _t3 = time.perf_counter()
512
513 + logger.info(
514 + "STAGE %s encode=%.1f rewrite=%.2f decode_ttfb=%.1f decode_total=%.1f",
515 + "no-rewrite" if NO_REWRITE else "rewrite",
516 + (_t1 - _t0) * 1e3,
517 + (_t2 - _t1) * 1e3,
518 + ((_first or _t3) - _t2) * 1e3,
519 + (_t3 - _t2) * 1e3,
520 + )
521 logger.info("[%s] Streaming completed", req_id)
522
523 except HTTPException:
@@ -553,6 +703,11 @@ if __name__ == "__main__":
703 parser = argparse.ArgumentParser()
704 parser.add_argument("--host", default="0.0.0.0")
705 parser.add_argument("--port", type=int, default=8000)
706 + parser.add_argument(
707 + "--no-rewrite",
708 + action="store_true",
709 + help="Forward images to the decoder unchanged (for stage-timing A/B).",
710 + )
711 parser.add_argument(
712 "--encode-servers-urls",
713 required=True,
@@ -573,6 +728,7 @@ if __name__ == "__main__":
728 )
729
730 args = parser.parse_args()
731 + NO_REWRITE = args.no_rewrite
732 app.state.e_urls = [
733 u.strip() for u in args.encode_servers_urls.split(",") if u.strip()
734 ]
tests/config/test_multimodal_config.py
+174
@@ -4,10 +4,13 @@
4 from unittest.mock import MagicMock, patch
5
6 import pytest
7 +import torch
8 from transformers import PretrainedConfig
9
10 +from vllm.config.ec_transfer import ECRole, ECTransferConfig
11 from vllm.config.model import ModelConfig
12 from vllm.config.multimodal import MultiModalConfig
13 +from vllm.config.vllm import VllmConfig
14 from vllm.transformers_utils.model_arch_config_convertor import (
15 ModelArchConfigConvertorBase,
16 )
@@ -173,3 +176,174 @@ def test_sticky_cache_survives_text_subconfig_regeneration():
176 side_effect=AssertionError("must not re-query registry"),
177 ):
178 assert text_config._supports_multimodal_for_mm_prefix() is False
179 +
180 +
181 +@pytest.mark.parametrize(
182 + ("device", "expected"),
183 + [
184 + (None, None),
185 + ("cpu", "cpu"),
186 + ("cuda", "cuda"),
187 + # Callers compare against a bare device type, so an indexed device and a
188 + # torch.device must normalise to the same thing as "cuda".
189 + ("cuda:1", "cuda"),
190 + (torch.device("cuda", 1), "cuda"),
191 + ],
192 +)
193 +def test_mm_processor_device_type_normalizes(device: object, expected: str | None):
194 + kwargs = {} if device is None else {"device": device}
195 + config = MultiModalConfig(mm_processor_kwargs=kwargs)
196 + assert config.get_mm_processor_device_type() == expected
197 +
198 +
199 +def _validate_mm_processor_device(*, device: str, ec_role: ECRole | None) -> None:
200 + ec_config = (
201 + None
202 + if ec_role is None
203 + # `is_ec_producer`/`is_ec_consumer` are False without a connector, so an
204 + # unset one would make every role look like "no EC role at all".
205 + else ECTransferConfig(ec_connector="ECExampleConnector", ec_role=ec_role)
206 + )
207 + MultiModalConfig(
208 + mm_processor_kwargs={"device": device}
209 + ).validate_mm_processor_device(ec_config)
210 +
211 +
212 +@pytest.mark.parametrize("ec_role", [None, "ec_producer"])
213 +def test_bad_mm_processor_device_rejected(ec_role: ECRole | None):
214 + """A bad device must fail during startup, not mid-request.
215 +
216 + Rejected for every role, and on a CPU-only platform too, so a typo can never
217 + silently fall through to running the processor somewhere unintended.
218 + """
219 + with (
220 + patch("vllm.platforms.current_platform.device_type", "cpu"),
221 + pytest.raises(ValueError, match='Invalid "device" in mm_processor_kwargs'),
222 + ):
223 + _validate_mm_processor_device(device="not-a-device", ec_role=ec_role)
224 +
225 +
226 +@pytest.mark.parametrize("ec_role", [None, "ec_consumer", "ec_both"])
227 +def test_accelerator_mm_processor_rejected_outside_encoder_instance(
228 + ec_role: ECRole | None,
229 +):
230 + """Only an encode-only EPD instance has the device to itself.
231 +
232 + Every other role runs the language model in the same process, so frontend
233 + accelerator work would contend with the forward pass and allocate outside
234 + the memory profiled for the KV cache.
235 + """
236 + with (
237 + patch("vllm.platforms.current_platform.device_type", "cuda"),
238 + pytest.raises(ValueError, match="also runs the language model"),
239 + ):
240 + _validate_mm_processor_device(device="cuda", ec_role=ec_role)
241 +
242 +
243 +def test_accelerator_mm_processor_allowed_on_encoder_instance():
244 + with patch("vllm.platforms.current_platform.device_type", "cuda"):
245 + _validate_mm_processor_device(device="cuda", ec_role="ec_producer")
246 +
247 +
248 +def test_cpu_mm_processor_needs_no_ec_role():
249 + """The gate only applies to the accelerator, so CPU stays unrestricted."""
250 + with patch("vllm.platforms.current_platform.device_type", "cuda"):
251 + _validate_mm_processor_device(device="cpu", ec_role=None)
252 +
253 +
254 +@pytest.mark.parametrize(
255 + ("flag", "kwargs", "expected"),
256 + [
257 + # "auto" is only settled once the EC role is known, so it must leave no
258 + # device behind for `VllmConfig` to mistake for an explicit request.
259 + ("auto", None, None),
260 + (None, None, None),
261 + ("cpu", None, "cpu"),
262 + ("cuda", None, "cuda"),
263 + # Any explicit value other than "cpu" means "whatever this platform
264 + # calls its accelerator".
265 + ("gpu", None, "cuda"),
266 + # An explicit device in the kwargs wins over the convenience flag.
267 + ("cpu", {"device": "cuda"}, "cuda"),
268 + ("cuda", {"device": "cpu"}, "cpu"),
269 + ],
270 +)
271 +def test_fold_mm_processor_device(
272 + flag: str | None, kwargs: dict | None, expected: str | None
273 +):
274 + with patch("vllm.platforms.current_platform.device_type", "cuda"):
275 + folded = MultiModalConfig.fold_mm_processor_device(kwargs, flag)
276 +
277 + config = MultiModalConfig(mm_processor_kwargs=folded or {})
278 + assert config.get_mm_processor_device_type() == expected
279 +
280 +
281 +def _resolve_mm_processor_device(
282 + *,
283 + ec_role: ECRole | None,
284 + mm_tensor_ipc: str = "torch_shm",
285 + device: str | None = None,
286 +) -> str | None:
287 + """Run the `auto` resolution and report where the processor ended up."""
288 + mm_config = MultiModalConfig(
289 + mm_processor_kwargs={} if device is None else {"device": device},
290 + mm_tensor_ipc=mm_tensor_ipc, # type: ignore[arg-type]
291 + )
292 + model_config = MagicMock(spec=ModelConfig)
293 + model_config.multimodal_config = mm_config
294 + vllm_config = MagicMock(spec=VllmConfig)
295 + vllm_config.model_config = model_config
296 + vllm_config.ec_transfer_config = (
297 + None
298 + if ec_role is None
299 + else ECTransferConfig(ec_connector="ECExampleConnector", ec_role=ec_role)
300 + )
301 +
302 + with patch("vllm.platforms.current_platform.device_type", "cuda"):
303 + VllmConfig._resolve_mm_processor_device(vllm_config)
304 + return mm_config.get_mm_processor_device_type()
305 +
306 +
307 +def test_auto_mm_processor_device_uses_accelerator_on_encoder_instance():
308 + """The one deployment that has the device to itself and can hand it over."""
309 + assert _resolve_mm_processor_device(ec_role="ec_producer") == "cuda"
310 +
311 +
312 +@pytest.mark.parametrize("ec_role", [None, "ec_consumer", "ec_both"])
313 +def test_auto_mm_processor_device_stays_on_cpu_off_encoder_instance(
314 + ec_role: ECRole | None,
315 +):
316 + """Every other role runs the language model in the same process."""
317 + assert _resolve_mm_processor_device(ec_role=ec_role) is None
318 +
319 +
320 +def test_auto_mm_processor_device_needs_a_device_capable_transport():
321 + """Other transports serialize host bytes, so the output is copied back."""
322 + assert (
323 + _resolve_mm_processor_device(ec_role="ec_producer", mm_tensor_ipc="direct_rpc")
324 + is None
325 + )
326 +
327 +
328 +def test_auto_mm_processor_device_leaves_an_explicit_request_alone():
329 + assert _resolve_mm_processor_device(ec_role="ec_producer", device="cpu") == "cpu"
330 +
331 +
332 +def test_vllm_config_runs_the_mm_processor_device_check():
333 + """Startup must reach the check; the rule itself is covered above.
334 +
335 + Guards the wiring, which no other test would notice going missing.
336 + """
337 + model_config = MagicMock(spec=ModelConfig)
338 + model_config.multimodal_config = MultiModalConfig(
339 + mm_processor_kwargs={"device": "cuda"}
340 + )
341 + vllm_config = MagicMock(spec=VllmConfig)
342 + vllm_config.model_config = model_config
343 + vllm_config.ec_transfer_config = None
344 +
345 + with (
346 + patch("vllm.platforms.current_platform.device_type", "cuda"),
347 + pytest.raises(ValueError, match="also runs the language model"),
348 + ):
349 + VllmConfig._validate_mm_processor_device(vllm_config)
vllm/config/ec_transfer.py
+10
@@ -103,5 +103,15 @@ class ECTransferConfig:
103 def is_ec_consumer(self) -> bool:
104 return self.ec_connector is not None and self.ec_role in get_args(ECConsumer)
105
106 + @property
107 + def is_encode_only(self) -> bool:
108 + """Whether this instance encodes but does not run the language model.
109 +
110 + It allocates no KV cache either -- `GPUModelRunner.get_kv_cache_spec`
111 + returns {} for it -- so it is the one role that can spend accelerator
112 + time and memory on frontend work.
113 + """
114 + return self.is_ec_producer and not self.is_ec_consumer
115 +
116 def get_from_extra_config(self, key, default) -> Any:
117 return self.ec_connector_extra_config.get(key, default)
vllm/config/model.py
+7
@@ -18,6 +18,7 @@ from vllm.config.multimodal import (
18 MMCacheType,
19 MMEncoderTPMode,
20 MMHasherAlgorithm,
21 + MMProcessorDevice,
22 MMTensorIPC,
23 MultiModalConfig,
24 )
@@ -395,6 +396,7 @@ class ModelConfig:
396 video_pruning_method: InitVar[str | None] = None
397 mm_tensor_ipc: InitVar[MMTensorIPC] = None
398 mm_ipc_gpu_memory_gb: InitVar[float | None] = None
399 + mm_processor_device: InitVar[MMProcessorDevice | None] = None
400
401 def compute_hash(self) -> str:
402 """
@@ -525,6 +527,7 @@ class ModelConfig:
527 video_pruning_method: str | None,
528 mm_tensor_ipc: MMTensorIPC,
529 mm_ipc_gpu_memory_gb: float | None,
530 + mm_processor_device: MMProcessorDevice | None,
531 ) -> None:
532 # Keep set served_model_name before maybe_model_redirect(self.model)
533 self.served_model_name = get_served_model_name(
@@ -763,6 +766,10 @@ class ModelConfig:
766 )
767 mm_encoder_tp_mode = "weights"
768
769 + mm_processor_kwargs = MultiModalConfig.fold_mm_processor_device(
770 + mm_processor_kwargs, mm_processor_device
771 + )
772 +
773 mm_config_kwargs = dict(
774 language_model_only=language_model_only,
775 limit_per_prompt=limit_mm_per_prompt,
vllm/config/multimodal.py
+136
@@ -5,14 +5,19 @@ from collections.abc import Mapping
5 from pathlib import Path
6 from typing import Any, Literal, TypeAlias, TypedDict, cast, final
7
8 +import torch
9 from pydantic import ConfigDict, Field, field_validator, model_validator
10 from pydantic.dataclasses import dataclass
11
12 import vllm.envs as envs
13 +from vllm.config.ec_transfer import ECTransferConfig
14 from vllm.config.utils import config, get_from_deprecated_env_if_set
15 +from vllm.logger import init_logger
16 from vllm.utils.hashing import safe_hash
17 from vllm.v1.attention.backends.registry import AttentionBackendEnum
18
19 +logger = init_logger(__name__)
20 +
21
22 @dataclass
23 class BaseDummyOptions:
@@ -64,6 +69,10 @@ MMCacheType = Literal["shm", "lru"]
69 VideoPruningMethod = Literal["evs", "vidcom2"]
70 MMTensorIPC = Literal["direct_rpc", "torch_shm"]
71 MMHasherAlgorithm = Literal["blake3", "sha256", "sha512"]
72 +MMProcessorDevice: TypeAlias = str
73 +"""`"auto"`, `"cpu"`, or the platform's own accelerator name
74 +(`current_platform.device_type`, e.g. `"cuda"` on CUDA and ROCm,
75 +`"xpu"` on XPU). Validated against that set by the CLI."""
76
77
78 def _get_mm_hasher_algorithm() -> MMHasherAlgorithm:
@@ -222,6 +231,20 @@ class MultiModalConfig:
231 - "direct_rpc": Use msgspec serialization via RPC
232 - "torch_shm": Use torch.multiprocessing shared memory for zero-copy IPC
233 Defaults to "direct_rpc". """
234 + mm_embeds_from_ec_connector: bool = False
235 + """Whether a pre-computed-embedding input may omit the `*_embeds` tensor.
236 +
237 + In an encode/prefill/decode (EPD) deployment the encoder instance publishes
238 + embeddings through the EC connector, so the request that reaches the
239 + prefill/decode instance only needs to carry the grid/size metadata that
240 + sizes the placeholder range — the embeddings themselves come from the
241 + connector, keyed by `mm_hash`.
242 +
243 + Derived, not user-settable: `VllmConfig.__post_init__` sets this to True
244 + exactly on EC consumers. Everywhere else it stays False so that a request
245 + which forgets its embeddings still fails fast in the frontend, with a clear
246 + error, rather than deep inside the model."""
247 +
248 mm_ipc_gpu_memory_gb: float = Field(default=0, ge=0)
249 """Amount of GPU memory (in GiB) sequestered on the engine's device for
250 GPU-side multimodal work in the API-server (frontend) process, such as
@@ -319,6 +342,119 @@ class MultiModalConfig:
342 )
343 return self
344
345 + @staticmethod
346 + def fold_mm_processor_device(
347 + mm_processor_kwargs: dict[str, Any] | None,
348 + mm_processor_device: MMProcessorDevice | None,
349 + ) -> dict[str, Any] | None:
350 + """Fold the `mm_processor_device` convenience flag into the kwargs.
351 +
352 + The flag keeps no state of its own: `mm_processor_kwargs["device"]` is
353 + the only representation of where the processor runs, so an explicit
354 + `device` there always wins and `"auto"` stays unresolved for
355 + `VllmConfig`, which is where the EC role needed to resolve it lives.
356 +
357 + Args:
358 + mm_processor_kwargs: The kwargs as given, or None.
359 + mm_processor_device: The flag's value, or None when unset.
360 +
361 + Returns:
362 + The kwargs to build the config with, unchanged unless the flag adds
363 + a `device`.
364 + """
365 + if mm_processor_device in (None, "auto"):
366 + return mm_processor_kwargs
367 + if (mm_processor_kwargs or {}).get("device") is not None:
368 + return mm_processor_kwargs
369 +
370 + from vllm.platforms import current_platform
371 +
372 + # Any explicit value other than "cpu" means "the accelerator", so a
373 + # programmatically-set "cuda" still works on a platform whose device type
374 + # is named differently ("xpu"), and degrades to CPU where there is none.
375 + device = (
376 + "cpu"
377 + if mm_processor_device == "cpu"
378 + else (current_platform.device_type or "cpu")
379 + )
380 + return {**(mm_processor_kwargs or {}), "device": device}
381 +
382 + def get_mm_processor_device_type(self) -> str | None:
383 + """The torch device type `mm_processor_kwargs["device"]` names.
384 +
385 + `mm_processor_kwargs` is untyped, so `device` may be any form torch
386 + accepts -- `"cuda"`, `"cuda:1"`, `torch.device(...)`, or a bare index.
387 + Normalising through torch rather than parsing the string keeps the
388 + non-string forms from slipping past a caller's comparison.
389 +
390 + Returns:
391 + The device type, or None when no device is requested.
392 +
393 + Raises:
394 + ValueError: If `device` is not something `torch.device` accepts.
395 + `validate_mm_processor_device` is what surfaces this during
396 + startup, so the value is only parsed once.
397 + """
398 + device = (self.mm_processor_kwargs or {}).get("device")
399 + if device is None:
400 + return None
401 + try:
402 + return torch.device(device).type # type: ignore[arg-type]
403 + except (RuntimeError, TypeError, ValueError):
404 + raise ValueError(
405 + f'Invalid "device" in mm_processor_kwargs: {device!r}. Expected a '
406 + 'torch device such as "cpu", "cuda" or "cuda:0".'
407 + ) from None
408 +
409 + def validate_mm_processor_device(self, ec_config: ECTransferConfig | None) -> None:
410 + """Check `mm_processor_kwargs["device"]` for this deployment.
411 +
412 + The only place the requested device is validated, so it runs even on a
413 + CPU-only platform: the value is parsed before any early return.
414 +
415 + Args:
416 + ec_config: The deployment's EC config, or None when it is not an
417 + encode/prefill/decode deployment. Passed in because it is not
418 + reachable from here, and because a field assigned after
419 + construction would not re-trigger this config's validators.
420 +
421 + Raises:
422 + ValueError: If the requested device is not a torch device, or if it
423 + is the accelerator on an instance that also runs the language
424 + model.
425 + """
426 + from vllm.platforms import current_platform
427 +
428 + device_type = self.get_mm_processor_device_type()
429 + accelerator = current_platform.device_type
430 + if device_type is None or accelerator in ("", "cpu"):
431 + return
432 + if device_type != accelerator:
433 + return
434 +
435 + if ec_config is None or not ec_config.is_encode_only:
436 + raise ValueError(
437 + f"Cannot run the multi-modal processor on {device_type!r}: this "
438 + "instance also runs the language model. The processor would "
439 + "share the device with the model's forward pass, so its "
440 + "transform kernels contend with that compute, and because it "
441 + "runs in the API-server process its allocations are outside the "
442 + "memory the engine profiled for its KV cache -- risking OOM or "
443 + "a silently shrunken cache.\n"
444 + "Accelerator preprocessing is only supported on an encode-only "
445 + "instance of an encode/prefill/decode deployment (an EC "
446 + "producer that is not also a consumer), which runs no forward "
447 + "pass and allocates no KV cache.\n"
448 + 'Use --mm-processor-device=cpu, or drop "device" from '
449 + "--mm-processor-kwargs."
450 + )
451 +
452 + logger.info_once(
453 + "Running the multi-modal processor on %s. Override with "
454 + "--mm-processor-device=cpu.",
455 + device_type,
456 + )
457 +
458 def compute_hash(self) -> str:
459 """
460 WARNING: Whenever a new field is added to this config,
vllm/config/vllm.py
+99
@@ -1509,6 +1509,10 @@ class VllmConfig:
1509 )
1510 current_platform.check_and_update_config(self)
1511
1512 + self._resolve_mm_embeds_from_ec_connector()
1513 + self._resolve_mm_processor_device()
1514 + self._validate_mm_processor_device()
1515 +
1516 if self.use_v2_model_runner:
1517 self._validate_v2_model_runner()
1518 elif self.parallel_config.prefill_context_parallel_size > 1:
@@ -2185,6 +2189,101 @@ class VllmConfig:
2189 f"kernel_config={self.kernel_config!r}"
2190 )
2191
2192 + def _resolve_mm_embeds_from_ec_connector(self) -> None:
2193 + """Allow `*_embeds` to be omitted only where the connector supplies them.
2194 +
2195 + That is exactly an EC consumer: the encoder instance publishes the
2196 + embeddings through the EC connector, so the request only has to carry
2197 + the grid metadata that sizes the placeholder range. On every other
2198 + deployment a missing `*_embeds` is a client error and must keep failing
2199 + fast in the frontend.
2200 + """
2201 + model_config = self.model_config
2202 + if model_config is None:
2203 + return
2204 + mm_config = model_config.multimodal_config
2205 + if mm_config is None:
2206 + return
2207 +
2208 + ec_config = self.ec_transfer_config
2209 + # Derived, so overwrite unconditionally rather than honouring a value
2210 + # that was set by hand.
2211 + mm_config.mm_embeds_from_ec_connector = (
2212 + ec_config is not None and ec_config.is_ec_consumer
2213 + )
2214 + if mm_config.mm_embeds_from_ec_connector:
2215 + logger.info_once(
2216 + "EC consumer: pre-computed-embedding inputs may omit the "
2217 + "embedding tensor; embeddings are loaded from the EC connector."
2218 + )
2219 +
2220 + def _resolve_mm_processor_device(self) -> None:
2221 + """Settle `--mm-processor-device=auto` now that the EC role is known.
2222 +
2223 + "auto" means "the accelerator, but only where the processor has it to
2224 + itself and its output can be handed over without a copy back to host":
2225 + an encode-only instance whose tensor transport carries device tensors.
2226 + Every other deployment keeps the processor on CPU.
2227 +
2228 + An explicit device -- from `--mm-processor-device` or straight from
2229 + `mm_processor_kwargs` -- is already folded in by `MultiModalConfig`, so
2230 + it is left alone here and validated by `_validate_mm_processor_device`.
2231 + """
2232 + model_config = self.model_config
2233 + if model_config is None:
2234 + return
2235 + mm_config = model_config.multimodal_config
2236 + if mm_config is None:
2237 + return
2238 + if mm_config.get_mm_processor_device_type() is not None:
2239 + return
2240 +
2241 + from vllm.platforms import current_platform
2242 +
2243 + device_type = current_platform.device_type
2244 + if device_type in ("", "cpu"):
2245 + return
2246 +
2247 + ec_config = self.ec_transfer_config
2248 + # An EC producer that is not also a consumer runs no forward pass and
2249 + # allocates no KV cache, so frontend accelerator work has the device to
2250 + # itself.
2251 + if ec_config is None or not ec_config.is_encode_only:
2252 + return
2253 +
2254 + if mm_config.mm_tensor_ipc != "torch_shm":
2255 + # Any other transport serializes host bytes, so the output would be
2256 + # copied back, and that copy costs more than running the transform
2257 + # on device saves.
2258 + logger.info_once(
2259 + "EPD encoder instance: keeping the multi-modal processor on CPU "
2260 + "because mm_tensor_ipc=%s cannot carry device tensors. Add "
2261 + "--mm-tensor-ipc=torch_shm to run it on the accelerator.",
2262 + mm_config.mm_tensor_ipc,
2263 + )
2264 + return
2265 +
2266 + mm_config.mm_processor_kwargs = {
2267 + **(mm_config.mm_processor_kwargs or {}),
2268 + "device": device_type,
2269 + }
2270 + logger.info_once(
2271 + "EPD encoder instance: running the multi-modal processor on %s. "
2272 + "Override with --mm-processor-device=cpu.",
2273 + device_type,
2274 + )
2275 +
2276 + def _validate_mm_processor_device(self) -> None:
2277 + """Hand the EC config to `MultiModalConfig`, which owns the rule."""
2278 + model_config = self.model_config
2279 + if model_config is None:
2280 + return
2281 + mm_config = model_config.multimodal_config
2282 + if mm_config is None:
2283 + return
2284 +
2285 + mm_config.validate_mm_processor_device(self.ec_transfer_config)
2286 +
2287 def _get_v2_model_runner_unsupported_features(self) -> list[str]:
2288 """Collect features not yet supported by the V2 model runner."""
2289 unsupported: list[str] = []
vllm/distributed/ec_transfer/ec_connector/example_connector.py
+68 -1
@@ -2,9 +2,10 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import os
4 from dataclasses import dataclass
5 -from typing import TYPE_CHECKING
5 +from typing import TYPE_CHECKING, Any
6
7 import safetensors
8 +import torch
9
10 from vllm.config import VllmConfig
11 from vllm.distributed.ec_transfer.ec_connector.base import (
@@ -50,6 +51,8 @@ class ECExampleConnector(ECConnectorBase):
51 super().__init__(vllm_config=vllm_config, role=role)
52 # req_id -> index
53 self._mm_datas_need_loads: dict[str, int] = {}
54 + self._model_config = vllm_config.model_config
55 + self._metadata_fields_cache: dict[str, set[str]] = {}
56 transfer_config = vllm_config.ec_transfer_config
57 if transfer_config is not None:
58 self._storage_path = transfer_config.get_from_extra_config(
@@ -165,6 +168,70 @@ class ECExampleConnector(ECConnectorBase):
168 self._mm_datas_need_loads.clear()
169 return meta
170
171 + def _placeholder_metadata_fields(self, modality: str) -> set[str]:
172 + """Which processed keys this model needs published for `modality`.
173 +
174 + Read from `MultiModalDataParser.embedding_fields`, the same declaration
175 + the consumer's parser requires, so the two cannot drift. An empty set
176 + means the modality cannot be delivered out of band, and the consumer
177 + will process the media itself.
178 + """
179 + if modality in self._metadata_fields_cache:
180 + return self._metadata_fields_cache[modality]
181 +
182 + fields: set[str] = set()
183 + try:
184 + from vllm.multimodal import MULTIMODAL_REGISTRY
185 +
186 + info = MULTIMODAL_REGISTRY.create_processor(self._model_config).info
187 + fields = info.data_parser.placeholder_metadata_fields(modality)
188 + except Exception:
189 + # Reporting nothing is a safe degradation: the consumer falls back to
190 + # processing the media itself.
191 + logger.warning(
192 + "Could not determine the placeholder metadata fields for "
193 + "modality %s; the consumer will preprocess the media itself.",
194 + modality,
195 + exc_info=True,
196 + )
197 +
198 + self._metadata_fields_cache[modality] = fields
199 + return fields
200 +
201 + def request_finished(
202 + self,
203 + request: "Request",
204 + ) -> tuple[bool, dict[str, Any] | None]:
205 + """Report each item's cache key and grid so a consumer can skip the
206 + image transform.
207 +
208 + A consumer only needs the grid to size the prompt's placeholder range;
209 + the embedding itself arrives through this connector. Reporting the grid
210 + the producer actually computed keeps the two sides in agreement without
211 + the caller re-deriving it from the raw media.
212 + """
213 + if not self.is_producer:
214 + return False, None
215 +
216 + items = []
217 + for feature in request.mm_features:
218 + metadata = {}
219 + # `data` is None for items served from the processor cache, in which
220 + # case the metadata is unavailable here and the consumer has to fall
221 + # back to processing the media itself.
222 + if feature.data is not None:
223 + wanted = self._placeholder_metadata_fields(feature.modality)
224 + metadata = {
225 + key: value.tolist()
226 + for key, value in feature.data.get_data().items()
227 + if key in wanted and isinstance(value, torch.Tensor)
228 + }
229 + items.append({"mm_hash": feature.identifier, **metadata})
230 +
231 + if not items:
232 + return False, None
233 + return False, {"ec_items": items}
234 +
235 # ==============================
236 # Helper functions
237 # ==============================
vllm/engine/arg_utils.py
+27
@@ -90,6 +90,7 @@ from vllm.config.multimodal import (
90 MMCacheType,
91 MMEncoderTPMode,
92 MMHasherAlgorithm,
93 + MMProcessorDevice,
94 MMTensorIPC,
95 )
96 from vllm.config.observability import DetailedTraceModules
@@ -596,6 +597,7 @@ class EngineArgs:
597 video_pruning_rate: float | None = MultiModalConfig.video_pruning_rate
598 video_pruning_method: str = MultiModalConfig.video_pruning_method
599 mm_tensor_ipc: MMTensorIPC = MultiModalConfig.mm_tensor_ipc
600 + mm_processor_device: MMProcessorDevice = "auto"
601 mm_ipc_gpu_memory_gb: float = MultiModalConfig.mm_ipc_gpu_memory_gb
602 # LoRA fields
603 enable_lora: bool = False
@@ -1353,6 +1355,30 @@ class EngineArgs:
1355 multimodal_group.add_argument(
1356 "--mm-tensor-ipc", **multimodal_kwargs["mm_tensor_ipc"]
1357 )
1358 + multimodal_group.add_argument(
1359 + "--mm-processor-device",
1360 + choices=["auto", "cpu"]
1361 + + (
1362 + [current_platform.device_type]
1363 + if current_platform.device_type not in ("", "cpu")
1364 + else []
1365 + ),
1366 + default="auto",
1367 + help="Device the HF multi-modal processor runs the image/video "
1368 + "transform on. Convenience for `--mm-processor-kwargs "
1369 + "'{\"device\": ...}'`: the value is resolved here and stored there, "
1370 + "it is not kept as separate state. Only takes effect for HF "
1371 + '"fast" (torchvision-backed) processors, which accept a `device` '
1372 + "argument; the others ignore it and stay on CPU.\n\n"
1373 + '"auto" uses the accelerator on encoder instances of an '
1374 + "encode/prefill/decode deployment -- an EC producer that is not "
1375 + "also a consumer allocates no KV cache, so its accelerator is not "
1376 + "contended by the language model -- and then only when "
1377 + "`--mm-tensor-ipc=torch_shm` can carry device tensors, since every "
1378 + "other transport would copy the result back to the host and that "
1379 + 'copy costs more than it saves. "auto" resolves to "cpu" '
1380 + "everywhere else.",
1381 + )
1382 multimodal_group.add_argument(
1383 "--mm-ipc-gpu-memory-gb",
1384 **multimodal_kwargs["mm_ipc_gpu_memory_gb"],
@@ -1743,6 +1769,7 @@ class EngineArgs:
1769 video_pruning_method=self.video_pruning_method,
1770 mm_tensor_ipc=self.mm_tensor_ipc,
1771 mm_ipc_gpu_memory_gb=self.mm_ipc_gpu_memory_gb,
1772 + mm_processor_device=self.mm_processor_device,
1773 io_processor_plugin=self.io_processor_plugin,
1774 renderer_num_workers=self.renderer_num_workers,
1775 )
vllm/model_executor/models/colqwen3.py
+1
@@ -104,6 +104,7 @@ class ColQwen3ProcessingInfo(Qwen3VLProcessingInfo):
104 spatial_merge_size,
105 video_needs_metadata=self._supports_video,
106 expected_hidden_size=self._get_expected_hidden_size(),
107 + embeds_from_ec_connector=self.embeds_from_ec_connector,
108 )
109
110
vllm/model_executor/models/colqwen3_5.py
+1
@@ -101,6 +101,7 @@ class ColQwen3_5ProcessingInfo(Qwen3_5ProcessingInfo):
101 spatial_merge_size,
102 video_needs_metadata=self._supports_video,
103 expected_hidden_size=self._get_expected_hidden_size(),
104 + embeds_from_ec_connector=self.embeds_from_ec_connector,
105 )
106
107
vllm/model_executor/models/hunyuan_vision.py
+9 -1
@@ -551,15 +551,22 @@ def _hunyuan_vl_field_config(hf_inputs: Mapping[str, torch.Tensor]):
551
552
553 class HunYuanVLMultiModalDataParser(MultiModalDataParser):
554 + # The patch grid is what sizes the placeholder range.
555 + embedding_fields = {
556 + "image": {"image_embeds": "values", "image_grid_thw": "metadata"},
557 + }
558 +
559 def _parse_image_data(
560 self,
561 data: dict[str, torch.Tensor] | ModalityData[ImageItem],
562 ) -> ModalityDataItems[Any, Any] | None:
563 if isinstance(data, dict):
564 + required, optional = self.embedding_field_sets("image")
565 return DictEmbeddingItems(
566 data,
567 modality="image",
562 - required_fields={"image_embeds", "image_grid_thw"},
568 + required_fields=required,
569 + optional_fields=optional,
570 fields_factory=_hunyuan_vl_field_config,
571 )
572
@@ -592,6 +599,7 @@ class HunYuanVLProcessingInfo(BaseProcessingInfo):
599 def get_data_parser(self):
600 return HunYuanVLMultiModalDataParser(
601 expected_hidden_size=self._get_expected_hidden_size(),
602 + embeds_from_ec_connector=self.embeds_from_ec_connector,
603 )
604
605 def get_supported_mm_limits(self) -> Mapping[str, int | None]:
vllm/model_executor/models/keye.py
+13 -8
@@ -881,18 +881,23 @@ def _keye_field_config(
881
882
883 class KeyeMultiModalDataParser(MultiModalDataParser):
884 + # The patch grid is what sizes the placeholder range.
885 + embedding_fields = {
886 + "image": {"image_embeds": "values", "image_grid_thw": "metadata"},
887 + "video": {"video_embeds": "values", "video_grid_thw": "metadata"},
888 + }
889 +
890 def _parse_image_data(
891 self,
892 data: dict[str, torch.Tensor] | ModalityData[ImageItem],
893 ) -> ModalityDataItems[Any, Any] | None:
894 if isinstance(data, dict):
895 + required, optional = self.embedding_field_sets("image")
896 return DictEmbeddingItems(
897 data,
898 modality="image",
892 - required_fields={
893 - "image_embeds",
894 - "image_grid_thw",
895 - },
899 + required_fields=required,
900 + optional_fields=optional,
901 fields_factory=_keye_field_config,
902 )
903
@@ -903,13 +908,12 @@ class KeyeMultiModalDataParser(MultiModalDataParser):
908 data: dict[str, torch.Tensor] | ModalityData[VideoItem],
909 ) -> ModalityDataItems[Any, Any] | None:
910 if isinstance(data, dict):
911 + required, optional = self.embedding_field_sets("video")
912 return DictEmbeddingItems(
913 data,
914 modality="video",
909 - required_fields={
910 - "video_embeds",
911 - "video_grid_thw",
912 - },
915 + required_fields=required,
916 + optional_fields=optional,
917 fields_factory=_keye_field_config,
918 )
919
@@ -929,6 +933,7 @@ class KeyeProcessingInfo(BaseProcessingInfo):
933 def get_data_parser(self):
934 return KeyeMultiModalDataParser(
935 expected_hidden_size=self._get_expected_hidden_size(),
936 + embeds_from_ec_connector=self.embeds_from_ec_connector,
937 )
938
939 def get_supported_mm_limits(
vllm/model_executor/models/keye_vl1_5.py
+13 -8
@@ -320,18 +320,23 @@ def _keye_field_config(
320
321
322 class KeyeVL1_5MultiModalDataParser(MultiModalDataParser):
323 + # The patch grid is what sizes the placeholder range.
324 + embedding_fields = {
325 + "image": {"image_embeds": "values", "image_grid_thw": "metadata"},
326 + "video": {"video_embeds": "values", "video_grid_thw": "metadata"},
327 + }
328 +
329 def _parse_image_data(
330 self,
331 data: dict[str, torch.Tensor] | ModalityData[ImageItem],
332 ) -> ModalityDataItems[Any, Any] | None:
333 if isinstance(data, dict):
334 + required, optional = self.embedding_field_sets("image")
335 return DictEmbeddingItems(
336 data,
337 modality="image",
331 - required_fields={
332 - "image_embeds",
333 - "image_grid_thw",
334 - },
338 + required_fields=required,
339 + optional_fields=optional,
340 fields_factory=_keye_field_config,
341 )
342
@@ -342,13 +347,12 @@ class KeyeVL1_5MultiModalDataParser(MultiModalDataParser):
347 data: dict[str, torch.Tensor] | ModalityData[VideoItem],
348 ) -> ModalityDataItems[Any, Any] | None:
349 if isinstance(data, dict):
350 + required, optional = self.embedding_field_sets("video")
351 return DictEmbeddingItems(
352 data,
353 modality="video",
348 - required_fields={
349 - "video_embeds",
350 - "video_grid_thw",
351 - },
354 + required_fields=required,
355 + optional_fields=optional,
356 fields_factory=_keye_field_config,
357 )
358
@@ -359,6 +363,7 @@ class KeyeVL1_5ProcessingInfo(KeyeProcessingInfo):
363 def get_data_parser(self):
364 return KeyeVL1_5MultiModalDataParser(
365 expected_hidden_size=self._get_expected_hidden_size(),
366 + embeds_from_ec_connector=self.embeds_from_ec_connector,
367 )
368
369 def get_max_frame_per_video(self) -> int:
vllm/model_executor/models/llava_onevision2.py
+9 -1
@@ -1279,6 +1279,7 @@ class LlavaOnevision2ProcessingInfo(BaseProcessingInfo):
1279 return LlavaOnevision2MultiModalDataParser(
1280 self.get_hf_config().vision_config.spatial_merge_size,
1281 video_needs_metadata=True,
1282 + embeds_from_ec_connector=self.embeds_from_ec_connector,
1283 )
1284
1285 def get_hf_processor(self, **kwargs: object):
@@ -1510,6 +1511,11 @@ class LlavaOnevision2DummyInputsBuilder(
1511
1512
1513 class LlavaOnevision2MultiModalDataParser(MultiModalDataParser):
1514 + # The patch grid is what sizes the placeholder range.
1515 + embedding_fields = {
1516 + "image": {"image_embeds": "values", "image_grid_thw": "metadata"},
1517 + }
1518 +
1519 def __init__(self, spatial_merge_size: int, *args, **kwargs):
1520 self._spatial_merge_size = spatial_merge_size
1521 super().__init__(*args, **kwargs)
@@ -1519,10 +1525,12 @@ class LlavaOnevision2MultiModalDataParser(MultiModalDataParser):
1525 data: dict[str, torch.Tensor] | ModalityData[ImageItem],
1526 ) -> ModalityDataItems[Any, Any] | None:
1527 if isinstance(data, dict):
1528 + required, optional = self.embedding_field_sets("image")
1529 return DictEmbeddingItems(
1530 data,
1531 modality="image",
1525 - required_fields={"image_embeds", "image_grid_thw"},
1532 + required_fields=required,
1533 + optional_fields=optional,
1534 fields_factory=_create_field_factory(self._spatial_merge_size),
1535 )
1536 return super()._parse_image_data(data)
vllm/model_executor/models/minicpmv.py
+22 -3
@@ -26,7 +26,7 @@
26
27 import math
28 from collections import defaultdict
29 -from collections.abc import Callable, Iterable, Mapping, Sequence
29 +from collections.abc import Callable, Iterable, Mapping, Sequence, Set
30 from functools import partial
31 from itertools import chain
32 from typing import Annotated, Any, Literal, TypeAlias
@@ -474,11 +474,14 @@ class MiniCPMVImageEmbeddingItems(DictEmbeddingItems):
474 [Mapping[str, torch.Tensor]],
475 Mapping[str, MultiModalFieldConfig],
476 ],
477 + required_fields: Set[str],
478 + optional_fields: Set[str] = frozenset(),
479 ) -> None:
480 super().__init__(
481 data,
482 modality="image",
481 - required_fields={"image_embeds", "image_sizes"},
483 + required_fields=required_fields,
484 + optional_fields=optional_fields,
485 fields_factory=fields_factory,
486 )
487
@@ -495,11 +498,14 @@ class MiniCPMVVideoEmbeddingItems(DictEmbeddingItems):
498 [Mapping[str, torch.Tensor]],
499 Mapping[str, MultiModalFieldConfig],
500 ],
501 + required_fields: Set[str],
502 + optional_fields: Set[str] = frozenset(),
503 ) -> None:
504 super().__init__(
505 data,
506 modality="video",
502 - required_fields={"video_embeds", "video_image_sizes"},
507 + required_fields=required_fields,
508 + optional_fields=optional_fields,
509 fields_factory=fields_factory,
510 )
511
@@ -512,14 +518,23 @@ class MiniCPMVVideoEmbeddingItems(DictEmbeddingItems):
518
519
520 class MiniCPMVMultiModalDataParser(MultiModalDataParser):
521 + # The per-item sizes are what size the placeholder range.
522 + embedding_fields = {
523 + "image": {"image_embeds": "values", "image_sizes": "metadata"},
524 + "video": {"video_embeds": "values", "video_image_sizes": "metadata"},
525 + }
526 +
527 def _parse_image_data(
528 self,
529 data: dict[str, torch.Tensor] | ModalityData[ImageItem],
530 ) -> ModalityDataItems[Any, Any] | None:
531 if isinstance(data, dict):
532 + required, optional = self.embedding_field_sets("image")
533 return MiniCPMVImageEmbeddingItems(
534 data,
535 fields_factory=_minicpmv_field_config,
536 + required_fields=required,
537 + optional_fields=optional,
538 )
539
540 return super()._parse_image_data(data)
@@ -529,9 +544,12 @@ class MiniCPMVMultiModalDataParser(MultiModalDataParser):
544 data: dict[str, torch.Tensor] | ModalityData[VideoItem],
545 ) -> ModalityDataItems[Any, Any] | None:
546 if isinstance(data, dict):
547 + required, optional = self.embedding_field_sets("video")
548 return MiniCPMVVideoEmbeddingItems(
549 data,
550 fields_factory=_minicpmv_field_config,
551 + required_fields=required,
552 + optional_fields=optional,
553 )
554
555 return super()._parse_video_data(data)
@@ -573,6 +591,7 @@ class MiniCPMVProcessingInfo(BaseProcessingInfo):
591 def get_data_parser(self):
592 return MiniCPMVMultiModalDataParser(
593 expected_hidden_size=self._get_expected_hidden_size(),
594 + embeds_from_ec_connector=self.embeds_from_ec_connector,
595 )
596
597 def get_model_version(self):
vllm/model_executor/models/opencua.py
+1
@@ -57,6 +57,7 @@ class OpenCUAProcessingInfo(Qwen2VLProcessingInfo):
57 return Qwen2VLMultiModalDataParser(
58 self.get_hf_config().vision_config.spatial_merge_size,
59 expected_hidden_size=self._get_expected_hidden_size(),
60 + embeds_from_ec_connector=self.embeds_from_ec_connector,
61 )
62
63 def get_hf_config(self):
vllm/model_executor/models/qwen2_5_omni_thinker.py
+1
@@ -372,6 +372,7 @@ class Qwen2_5OmniThinkerProcessingInfo(
372 target_sr=feature_extractor.sampling_rate,
373 target_channels=self.get_target_channels(),
374 expected_hidden_size=self._get_expected_hidden_size(),
375 + embeds_from_ec_connector=self.embeds_from_ec_connector,
376 )
377
378 def get_target_channels(self) -> int:
vllm/model_executor/models/qwen2_vl.py
+13 -2
@@ -795,6 +795,12 @@ def _create_qwen2vl_field_factory(
795
796
797 class Qwen2VLMultiModalDataParser(MultiModalDataParser):
798 + # The patch grid is what sizes the placeholder range.
799 + embedding_fields = {
800 + "image": {"image_embeds": "values", "image_grid_thw": "metadata"},
801 + "video": {"video_embeds": "values", "video_grid_thw": "metadata"},
802 + }
803 +
804 def __init__(self, spatial_merge_size: int, *args, **kwargs):
805 self._spatial_merge_size = spatial_merge_size
806 super().__init__(*args, **kwargs)
@@ -804,10 +810,12 @@ class Qwen2VLMultiModalDataParser(MultiModalDataParser):
810 data: dict[str, torch.Tensor] | ModalityData[ImageItem],
811 ) -> ModalityDataItems[Any, Any] | None:
812 if isinstance(data, dict):
813 + required, optional = self.embedding_field_sets("image")
814 return DictEmbeddingItems(
815 data,
816 modality="image",
810 - required_fields={"image_embeds", "image_grid_thw"},
817 + required_fields=required,
818 + optional_fields=optional,
819 fields_factory=_create_qwen2vl_field_factory(self._spatial_merge_size),
820 )
821
@@ -818,10 +826,12 @@ class Qwen2VLMultiModalDataParser(MultiModalDataParser):
826 data: dict[str, torch.Tensor] | ModalityData[VideoItem],
827 ) -> ModalityDataItems[Any, Any] | None:
828 if isinstance(data, dict):
829 + required, optional = self.embedding_field_sets("video")
830 return DictEmbeddingItems(
831 data,
832 modality="video",
824 - required_fields={"video_embeds", "video_grid_thw"},
833 + required_fields=required,
834 + optional_fields=optional,
835 fields_factory=_create_qwen2vl_field_factory(self._spatial_merge_size),
836 )
837
@@ -846,6 +856,7 @@ class Qwen2VLProcessingInfo(BaseProcessingInfo):
856 return Qwen2VLMultiModalDataParser(
857 self.get_hf_config().vision_config.spatial_merge_size,
858 expected_hidden_size=self._get_expected_hidden_size(),
859 + embeds_from_ec_connector=self.embeds_from_ec_connector,
860 )
861
862 def get_supported_mm_limits(self) -> Mapping[str, int | None]:
vllm/model_executor/models/qwen3_vl.py
+1
@@ -879,6 +879,7 @@ class Qwen3VLProcessingInfo(Qwen2VLProcessingInfo):
879 self.get_hf_config().vision_config.spatial_merge_size,
880 video_needs_metadata=True,
881 expected_hidden_size=self._get_expected_hidden_size(),
882 + embeds_from_ec_connector=self.embeds_from_ec_connector,
883 )
884
885 def _get_vision_info(
vllm/multimodal/inputs.py
+13 -1
@@ -23,7 +23,7 @@ from typing_extensions import TypeVar
23
24 from vllm.utils.collection_utils import is_list_of
25 from vllm.utils.import_utils import LazyLoader
26 -from vllm.utils.jsontree import json_map_leaves
26 +from vllm.utils.jsontree import json_iter_leaves, json_map_leaves
27
28 from .media import MediaWithBytes
29
@@ -286,6 +286,13 @@ def nested_tensors_equal(
286 return a == b
287
288
289 +def _nested_tensors_are_cpu(tensors: NestedTensors) -> bool:
290 + """Whether every tensor in `tensors` lives in host memory."""
291 + return not any(
292 + isinstance(x, torch.Tensor) and not x.is_cpu for x in json_iter_leaves(tensors)
293 + )
294 +
295 +
296 def _nested_tensors_h2d(
297 tensors: NestedTensors,
298 device: torch.types.Device,
@@ -478,6 +485,11 @@ class BaseMultiModalField(ABC):
485 pin_memory = False
486
487 batch = [elem.data for elem in elems]
488 + if pin_memory and not _nested_tensors_are_cpu(batch):
489 + # Data already sits on an accelerator (e.g. a device-side HF
490 + # processor handed it over by IPC). Pinning is a host-memory
491 + # concept, and `pin_memory()` rejects device tensors outright.
492 + pin_memory = False
493 out = self._reduce_data(batch, pin_memory=pin_memory)
494 return _nested_tensors_h2d(out, device=device)
495
vllm/multimodal/parse.py
+89 -6
@@ -40,8 +40,18 @@ from .media import MediaWithBytes
40 _T = TypeVar("_T")
41 _I = TypeVar("_I")
42
43 +EmbeddingFieldRole: TypeAlias = Literal["values", "metadata"]
44 +"""What a field of a pre-computed-embedding input carries.
45 +
46 +`"values"` is the embedding tensor itself; `"metadata"` is a key that sizes the
47 +prompt's placeholder range. The distinction is what lets one declaration serve
48 +both an ordinary request (everything present) and an EC consumer (embeddings
49 +arrive through the connector, metadata still in the request).
50 +"""
51 +
52 if TYPE_CHECKING:
53 import PIL.Image as PILImage
54 +
55 else:
56 PILImage = LazyLoader("PILImage", globals(), "PIL.Image")
57
@@ -250,34 +260,62 @@ class DictEmbeddingItems(
260 self,
261 data: Mapping[str, torch.Tensor],
262 modality: str,
253 - required_fields: set[str],
263 + required_fields: Set[str],
264 fields_factory: Callable[
265 [Mapping[str, torch.Tensor]],
266 Mapping[str, MultiModalFieldConfig],
267 ],
268 + optional_fields: Set[str] = frozenset(),
269 ) -> None:
270 + """
271 + Args:
272 + data: The dictionary of tensors for this modality.
273 + modality: The modality these items belong to.
274 + required_fields: Fields `data` must contain.
275 + fields_factory: Builds the field config from the data.
276 + optional_fields: Fields `data` may omit. Which fields these are is
277 + the caller's decision -- see
278 + `MultiModalDataParser.embedding_field_sets`, where a deployment
279 + that receives embeddings through an EC connector makes the
280 + embeddings optional. They still need a field config, since they
281 + are used whenever they *are* supplied.
282 + """
283 from transformers.feature_extraction_utils import BatchFeature
284
285 super().__init__(data, modality)
286
263 - missing_required_data_keys = required_fields - data.keys()
287 + # Nothing required would leave nothing to size the placeholder range
288 + # from, so the item would parse into zero entries and silently produce a
289 + # wrong prompt instead of failing here.
290 + if not required_fields:
291 + raise ValueError(
292 + f"Cannot parse {modality!r} embeddings: every declared field is "
293 + f"optional ({sorted(optional_fields)}), so nothing is left to "
294 + "size the placeholder range."
295 + )
296 +
297 + declared_fields = set(required_fields) | set(optional_fields)
298 +
299 + missing_required_data_keys = set(required_fields) - data.keys()
300 if missing_required_data_keys:
301 data_keys = set(data.keys())
302 msg = (
267 - f"The data should contain the fields: {required_fields}, "
303 + f"The data should contain the fields: {set(required_fields)}, "
304 f"but only found the following keys: {data_keys}"
305 )
306 raise ValueError(msg)
307
308 fields_config = fields_factory(data)
273 - missing_required_fields = required_fields - fields_config.keys()
309 + # Check every declared field, not just the required ones: an optional
310 + # field still needs a config for when it is supplied.
311 + missing_required_fields = declared_fields - fields_config.keys()
312 if missing_required_fields:
313 fields = set(fields_config.keys())
276 - msg = f"{required_fields=} should be a subset of {fields=}"
314 + msg = f"{declared_fields=} should be a subset of {fields=}"
315 raise ValueError(msg)
316
317 self.fields_config = fields_config
280 - self.required_fields = required_fields
318 + self.required_fields = set(required_fields)
319
320 self._kwargs = MultiModalKwargsItems.from_hf_inputs(
321 BatchFeature(dict(data)),
@@ -516,8 +554,50 @@ class MultiModalDataParser:
554 embedding inputs. If provided, validates that user-supplied
555 embeddings have the correct hidden size to prevent crashes
556 during model inference.
557 + embeds_from_ec_connector (bool): Whether pre-computed embeddings may be
558 + absent from the request because an encode/prefill/decode encoder
559 + instance publishes them through an EC connector instead. Derived by
560 + `BaseProcessingInfo.embeds_from_ec_connector`.
561 """
562
563 + embedding_fields: Mapping[str, Mapping[str, EmbeddingFieldRole]] = {}
564 + """Per-modality field roles for pre-computed-embedding inputs.
565 +
566 + Declared here rather than inside `_parse_*_data` so an EC producer can read
567 + it too: on a producer the request carries real media, so the branch that
568 + builds `DictEmbeddingItems` never runs, yet the producer still has to know
569 + which processed keys to publish. One declaration, both sides, no drift.
570 +
571 + A modality absent from this mapping can only be sent whole in the request.
572 + """
573 +
574 + @classmethod
575 + def placeholder_metadata_fields(cls, modality: str) -> set[str]:
576 + """The keys that size `modality`'s placeholder range.
577 +
578 + What an EC producer publishes alongside the embedding, and what a
579 + consumer keeps requiring once the embedding itself is gone.
580 + """
581 + return {
582 + field
583 + for field, role in cls.embedding_fields.get(modality, {}).items()
584 + if role == "metadata"
585 + }
586 +
587 + def embedding_field_sets(self, modality: str) -> tuple[set[str], set[str]]:
588 + """`modality`'s (required, optional) fields for this deployment.
589 +
590 + Resolves the static roles in `embedding_fields` against where the
591 + embeddings actually come from: on an EC consumer they arrive through the
592 + connector, so the request may omit them; anywhere else a request that
593 + claims to carry pre-computed embeddings has to actually carry them.
594 + """
595 + metadata = self.placeholder_metadata_fields(modality)
596 + values = set(self.embedding_fields.get(modality, {})) - metadata
597 + if self.embeds_from_ec_connector:
598 + return metadata, values
599 + return metadata | values, set()
600 +
601 def __init__(
602 self,
603 *,
@@ -526,9 +606,12 @@ class MultiModalDataParser:
606 audio_resample_method: Literal["pyav", "scipy", "soxr"] = "pyav",
607 video_needs_metadata: bool = False,
608 expected_hidden_size: int | None = None,
609 + embeds_from_ec_connector: bool = False,
610 ) -> None:
611 super().__init__()
612
613 + self.embeds_from_ec_connector = embeds_from_ec_connector
614 +
615 self.audio_resampler = AudioResampler(
616 target_sr=target_sr,
617 method=audio_resample_method,
vllm/multimodal/processing/context.py
+34 -7
@@ -1,7 +1,6 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import time
4 -from abc import abstractmethod
4 from collections.abc import Callable, Mapping
5 from contextlib import contextmanager
6 from dataclasses import dataclass, field
@@ -227,13 +226,30 @@ class InputProcessingContext:
226 self,
227 output: JSONTree,
228 ) -> JSONTree:
229 + # "torch_shm" puts tensors on a torch.multiprocessing queue, which
230 + # shares device tensors by CUDA IPC handle, so a device-side processor
231 + # can hand `pixel_values` straight to the worker. Every other transport
232 + # serializes host bytes, so the result has to be copied back first.
233 + keep_on_device = (
234 + self.model_config.get_multimodal_config().mm_tensor_ipc == "torch_shm"
235 + )
236 +
237 def _postprocess_one(x: object):
231 - if isinstance(x, torch.Tensor): # noqa: SIM102
232 - # This mimics the behavior of transformers.BatchFeature
233 - if x.is_floating_point():
234 - x = x.to(dtype=self.model_config.dtype)
238 + if not isinstance(x, torch.Tensor):
239 + return x
240 +
241 + # Bind to a Tensor-typed local: reassigning the `object`-typed
242 + # parameter would discard the isinstance narrowing.
243 + tensor = x
244 +
245 + # This mimics the behavior of transformers.BatchFeature
246 + if tensor.is_floating_point():
247 + tensor = tensor.to(dtype=self.model_config.dtype)
248 +
249 + if not tensor.is_cpu and not keep_on_device:
250 + tensor = tensor.cpu()
251
236 - return x
252 + return tensor
253
254 return json_map_leaves(_postprocess_one, output)
255
@@ -350,6 +366,17 @@ class BaseProcessingInfo:
366
367 return None
368
369 + @property
370 + def embeds_from_ec_connector(self) -> bool:
371 + """Whether pre-computed embeddings may arrive outside the request.
372 +
373 + True only on an EC consumer, where an encode/prefill/decode encoder
374 + instance publishes them through the connector instead, so the request
375 + carries only the metadata that sizes the placeholder range.
376 + """
377 + mm_config = self.ctx.model_config.multimodal_config
378 + return mm_config is not None and mm_config.mm_embeds_from_ec_connector
379 +
380 def get_data_parser(self) -> MultiModalDataParser:
381 """
382 Constructs a parser to preprocess multi-modal data items
@@ -362,6 +389,7 @@ class BaseProcessingInfo:
389 """
390 return MultiModalDataParser(
391 expected_hidden_size=self._get_expected_hidden_size(),
392 + embeds_from_ec_connector=self.embeds_from_ec_connector,
393 )
394
395 @cached_property
@@ -372,7 +400,6 @@ class BaseProcessingInfo:
400 def skip_prompt_length_check(self) -> bool:
401 return False
402
375 - @abstractmethod
403 def get_supported_mm_limits(self) -> Mapping[str, int | None]:
404 """
405 Return the maximum supported number of items for each modality.