[Frontend] Expose multimodal metadata for disaggregated prefill (#54659)
Signed-off-by: zhouyou9505 <zhouyou9505@gmail.com> Co-authored-by: Cursor Grok 4.6 <noreply@cursor.com>
zhouyou committed
Sep 6, 2026 at 22:46 UTC
52358e6e192aeae73dd3764046c98fc4b156ea83
7 files changed
+605
-100
docs/serving/online_serving/renderer.md
+144
@@ -27,3 +27,147 @@ VLLM_ENABLE_SCALE_OUT_ENDPOINTS=1 vllm serve <model>
27
- Render chat completions
28
29
For the post processing counterpart that turns generated token IDs back into OpenAI compatible responses, see the [Derenderer APIs](derenderer.md).
30
+
31
+## Multimodal Render Features
32
+
33
+Multimodal render responses include a `features` object with per-modality
34
+hashes, placeholder ranges, and serialized processor data. When the model
35
+exposes placeholder-metadata or `keep_on_cpu` fields (for example
36
+`image_grid_thw`), the response also includes `mm_metadata`. Each
37
+`mm_metadata` entry is a base64-encoded `MultiModalKwargsItem` containing
38
+only those fields, not encoder inputs such as `pixel_values`.
39
+
40
+The arrays in `mm_hashes`, `mm_placeholders`, `kwargs_data`, and
41
+`mm_metadata` use the same per-modality item order. Downstream workers
42
+should split those fields:
43
+
44
+- Encode requests keep `kwargs_data`.
45
+- Prefill requests may omit `kwargs_data` and send `mm_metadata` only when
46
+ `ec_transfer_params` is also set, so embeddings are loaded by the EC
47
+ connector. Omitting `kwargs_data` without `ec_transfer_params` is rejected.
48
+- Legacy clients that ignore `mm_metadata` and keep sending `kwargs_data`
49
+ continue to work.
50
+
51
+## Example
52
+
53
+The example below shows how a disaggregated encode / prefill coordinator can
54
+split a multimodal render response. The render step returns both
55
+`kwargs_data` (encoder tensors plus metadata) and `mm_metadata` (metadata
56
+only). Encode keeps the full payload; prefill drops `kwargs_data` after the
57
+EC connector has published embeddings.
58
+
59
+```python
60
+import httpx
61
+
62
+MODEL = "Qwen/Qwen3-VL-2B-Instruct"
63
+RENDER = "http://localhost:8100" # vllm launch render ...
64
+ENCODE = "http://localhost:8200" # encode worker
65
+PREFILL = "http://localhost:8300" # prefill worker
66
+
67
+chat_request = {
68
+ "model": MODEL,
69
+ "messages": [
70
+ {
71
+ "role": "user",
72
+ "content": [
73
+ {"type": "image_url", "image_url": {"url": "<data-url>"}},
74
+ {"type": "text", "text": "Describe this image."},
75
+ ],
76
+ }
77
+ ],
78
+}
79
+
80
+with httpx.Client(timeout=120.0) as client:
81
+ # 1. Render: preprocess into token IDs and multimodal features.
82
+ render_response = client.post(
83
+ f"{RENDER}/v1/chat/completions/render", json=chat_request
84
+ ).json()
85
+
86
+ features = render_response["features"]
87
+ # features["kwargs_data"]["image"][0] -> pixel_values + image_grid_thw
88
+ # features["mm_metadata"]["image"][0] -> image_grid_thw only
89
+
90
+ # 2. Encode: send full kwargs_data so the encoder can run vision towers.
91
+ encode_response = client.post(
92
+ f"{ENCODE}/inference/v1/generate",
93
+ json={
94
+ "token_ids": render_response["token_ids"],
95
+ "features": {
96
+ "mm_hashes": features["mm_hashes"],
97
+ "mm_placeholders": features["mm_placeholders"],
98
+ "kwargs_data": features["kwargs_data"],
99
+ },
100
+ "sampling_params": {"max_tokens": 1},
101
+ },
102
+ ).json()
103
+ ec_transfer_params = encode_response["ec_transfer_params"]
104
+
105
+ # 3. Prefill: omit kwargs_data; load embeddings via EC connector.
106
+ prefill_response = client.post(
107
+ f"{PREFILL}/inference/v1/generate",
108
+ json={
109
+ "token_ids": render_response["token_ids"],
110
+ "features": {
111
+ "mm_hashes": features["mm_hashes"],
112
+ "mm_placeholders": features["mm_placeholders"],
113
+ "mm_metadata": features["mm_metadata"],
114
+ },
115
+ "ec_transfer_params": ec_transfer_params,
116
+ "sampling_params": {"max_tokens": 64},
117
+ },
118
+ ).json()
119
+
120
+print(prefill_response["choices"][0]["token_ids"])
121
+```
122
+
123
+Single-process clients can keep passing the full render response to
124
+`/inference/v1/generate` unchanged; `mm_metadata` is optional and ignored
125
+when `kwargs_data` is present.
126
+
127
+### Payload shape
128
+
129
+#### Render response
130
+
131
+`/v1/chat/completions/render` returns both `kwargs_data` and `mm_metadata`.
132
+The arrays share the same per-modality item order. Base64 blobs are truncated
133
+below for readability.
134
+
135
+```json
136
+{
137
+ "token_ids": [151644, 872],
138
+ "features": {
139
+ "mm_hashes": {"image": ["abc123..."]},
140
+ "mm_placeholders": {"image": [{"offset": 0, "length": 256}]},
141
+ "kwargs_data": {
142
+ "image": ["<base64 MultiModalKwargsItem: pixel_values + image_grid_thw>"]
143
+ },
144
+ "mm_metadata": {
145
+ "image": ["<base64 MultiModalKwargsItem: image_grid_thw only>"]
146
+ }
147
+ }
148
+}
149
+```
150
+
151
+Forward `kwargs_data` to the encode worker. Keep `mm_metadata` for prefill.
152
+
153
+#### Prefill request
154
+
155
+Prefill omits `kwargs_data` and sends `mm_metadata` with
156
+`ec_transfer_params` from the encode response:
157
+
158
+```json
159
+{
160
+ "token_ids": [151644, 872],
161
+ "features": {
162
+ "mm_hashes": {"image": ["abc123..."]},
163
+ "mm_placeholders": {"image": [{"offset": 0, "length": 256}]},
164
+ "mm_metadata": {
165
+ "image": ["<base64 MultiModalKwargsItem: image_grid_thw only>"]
166
+ }
167
+ },
168
+ "ec_transfer_params": {
169
+ "ec_items": [{"mm_hash": "abc123...", "peer_host": "10.0.0.1"}]
170
+ },
171
+ "sampling_params": {"max_tokens": 64}
172
+}
173
+```
tests/entrypoints/scale_out/token_in_token_out/test_mm_serde.py
+214
@@ -6,21 +6,30 @@ token_in_token_out generate endpoint.
6
"""
7
8
import torch
9
+from pydantic import ValidationError
10
11
+from vllm.entrypoints.scale_out.token_in_token_out.mm_features import (
12
+ extract_mm_features,
13
+ merge_mm_kwargs_items,
14
+ mm_kwargs_from_features,
15
+)
16
from vllm.entrypoints.scale_out.token_in_token_out.mm_serde import (
17
decode_mm_kwargs_item,
18
encode_mm_kwargs_item,
19
)
20
from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
21
+ GenerateRequest,
22
MultiModalFeatures,
23
PlaceholderRangeInfo,
24
)
25
+from vllm.inputs import mm_input
26
from vllm.multimodal.inputs import (
27
MultiModalBatchedField,
28
MultiModalFieldElem,
29
MultiModalFlatField,
30
MultiModalKwargsItem,
31
MultiModalSharedField,
32
+ PlaceholderRange,
33
)
34
35
@@ -112,3 +121,208 @@ def test_mm_features_with_kwargs_data():
121
122
decoded = decode_mm_kwargs_item(features2.kwargs_data["image"][0])
123
assert torch.equal(elem.data, decoded["pixel_values"].data)
124
+
125
+
126
+def _image_engine_input(
127
+ *,
128
+ pixel_keep_on_cpu: bool = False,
129
+ grid_keep_on_cpu: bool = True,
130
+) -> tuple[object, MultiModalFieldElem, MultiModalFieldElem]:
131
+ pixel_values = MultiModalFieldElem(
132
+ data=torch.randn(5, 3, dtype=torch.float32),
133
+ field=MultiModalBatchedField(keep_on_cpu=pixel_keep_on_cpu),
134
+ )
135
+ image_grid_thw = MultiModalFieldElem(
136
+ data=torch.tensor([[1, 24, 24]], dtype=torch.int32),
137
+ field=MultiModalBatchedField(keep_on_cpu=grid_keep_on_cpu),
138
+ )
139
+ item = MultiModalKwargsItem(
140
+ {"pixel_values": pixel_values, "image_grid_thw": image_grid_thw}
141
+ )
142
+ engine_input = mm_input(
143
+ prompt_token_ids=[1, 2, 3],
144
+ mm_kwargs={"image": [item]},
145
+ mm_hashes={"image": ["abc123"]},
146
+ mm_placeholders={"image": [PlaceholderRange(offset=0, length=1)]},
147
+ )
148
+ return engine_input, pixel_values, image_grid_thw
149
+
150
+
151
+def test_render_extracts_metadata_fields_separately():
152
+ """Render metadata contains CPU fields but excludes encoder inputs."""
153
+ engine_input, pixel_values, image_grid_thw = _image_engine_input()
154
+
155
+ features = extract_mm_features(engine_input)
156
+
157
+ assert features is not None
158
+ assert features.mm_metadata is not None
159
+ features = MultiModalFeatures.model_validate_json(features.model_dump_json())
160
+ assert features.mm_metadata is not None
161
+ metadata = decode_mm_kwargs_item(features.mm_metadata["image"][0])
162
+ assert set(metadata) == {"image_grid_thw"}
163
+ assert torch.equal(metadata["image_grid_thw"].data, image_grid_thw.data)
164
+
165
+ assert features.kwargs_data is not None
166
+ full_data = decode_mm_kwargs_item(features.kwargs_data["image"][0])
167
+ assert set(full_data) == {"pixel_values", "image_grid_thw"}
168
+ assert torch.equal(full_data["pixel_values"].data, pixel_values.data)
169
+
170
+
171
+def test_extract_includes_declared_placeholder_metadata_fields():
172
+ """EC placeholder metadata is kept even when keep_on_cpu is unset."""
173
+ engine_input, _pixel_values, image_grid_thw = _image_engine_input(
174
+ grid_keep_on_cpu=False
175
+ )
176
+
177
+ features = extract_mm_features(
178
+ engine_input,
179
+ metadata_fields_for=lambda modality: (
180
+ {"image_grid_thw"} if modality == "image" else set()
181
+ ),
182
+ )
183
+
184
+ assert features is not None
185
+ assert features.mm_metadata is not None
186
+ metadata = decode_mm_kwargs_item(features.mm_metadata["image"][0])
187
+ assert set(metadata) == {"image_grid_thw"}
188
+ assert torch.equal(metadata["image_grid_thw"].data, image_grid_thw.data)
189
+
190
+
191
+def test_legacy_kwargs_only_generate_keeps_full_payload():
192
+ """Old clients that omit mm_metadata still reconstruct full kwargs."""
193
+ engine_input, pixel_values, image_grid_thw = _image_engine_input()
194
+ rendered = extract_mm_features(engine_input)
195
+ assert rendered is not None
196
+ assert rendered.kwargs_data is not None
197
+
198
+ features = MultiModalFeatures(
199
+ mm_hashes=rendered.mm_hashes,
200
+ mm_placeholders=rendered.mm_placeholders,
201
+ kwargs_data=rendered.kwargs_data,
202
+ )
203
+ merged = mm_kwargs_from_features(features)
204
+ item = merged["image"][0]
205
+ assert item is not None
206
+ assert set(item) == {"pixel_values", "image_grid_thw"}
207
+ assert torch.equal(item["pixel_values"].data, pixel_values.data)
208
+ assert torch.equal(item["image_grid_thw"].data, image_grid_thw.data)
209
+
210
+
211
+def test_metadata_only_generate_requires_ec_transfer_params():
212
+ engine_input, _, _ = _image_engine_input()
213
+ rendered = extract_mm_features(engine_input)
214
+ assert rendered is not None
215
+ payload = {
216
+ "token_ids": [1, 2, 3],
217
+ "sampling_params": {"max_tokens": 1},
218
+ "features": {
219
+ "mm_hashes": rendered.mm_hashes,
220
+ "mm_placeholders": {
221
+ modality: [p.model_dump() for p in ranges]
222
+ for modality, ranges in rendered.mm_placeholders.items()
223
+ },
224
+ "mm_metadata": rendered.mm_metadata,
225
+ },
226
+ }
227
+
228
+ try:
229
+ GenerateRequest.model_validate(payload)
230
+ raise AssertionError("expected metadata-only request without EC to fail")
231
+ except ValidationError as exc:
232
+ assert "ec_transfer_params" in str(exc)
233
+
234
+ payload["ec_transfer_params"] = {"image-hash": {"peer_host": "10.0.0.1"}}
235
+ request = GenerateRequest.model_validate(payload)
236
+ assert request.features is not None
237
+ assert request.features.kwargs_data is None
238
+ merged = mm_kwargs_from_features(request.features)
239
+ item = merged["image"][0]
240
+ assert item is not None
241
+ assert set(item) == {"image_grid_thw"}
242
+
243
+
244
+def test_kwargs_and_metadata_generate_does_not_require_ec():
245
+ engine_input, pixel_values, image_grid_thw = _image_engine_input()
246
+ rendered = extract_mm_features(engine_input)
247
+ assert rendered is not None
248
+ request = GenerateRequest.model_validate(
249
+ {
250
+ "token_ids": [1, 2, 3],
251
+ "sampling_params": {"max_tokens": 1},
252
+ "features": rendered.model_dump(),
253
+ }
254
+ )
255
+ assert request.features is not None
256
+ merged = mm_kwargs_from_features(request.features)
257
+ item = merged["image"][0]
258
+ assert item is not None
259
+ assert set(item) == {"pixel_values", "image_grid_thw"}
260
+ assert torch.equal(item["pixel_values"].data, pixel_values.data)
261
+ assert torch.equal(item["image_grid_thw"].data, image_grid_thw.data)
262
+
263
+
264
+def test_cache_hit_null_kwargs_keeps_metadata_slot():
265
+ pixel_values = MultiModalFieldElem(
266
+ data=torch.randn(5, 3, dtype=torch.float32),
267
+ field=MultiModalBatchedField(),
268
+ )
269
+ image_grid_thw = MultiModalFieldElem(
270
+ data=torch.tensor([[1, 24, 24]], dtype=torch.int32),
271
+ field=MultiModalBatchedField(keep_on_cpu=True),
272
+ )
273
+ item = MultiModalKwargsItem(
274
+ {"pixel_values": pixel_values, "image_grid_thw": image_grid_thw}
275
+ )
276
+ engine_input = mm_input(
277
+ prompt_token_ids=[1, 2, 3, 4],
278
+ mm_kwargs={"image": [item, None]},
279
+ mm_hashes={"image": ["hit", "cached"]},
280
+ mm_placeholders={
281
+ "image": [
282
+ PlaceholderRange(offset=0, length=1),
283
+ PlaceholderRange(offset=2, length=1),
284
+ ]
285
+ },
286
+ )
287
+
288
+ features = extract_mm_features(engine_input)
289
+ assert features is not None
290
+ assert features.kwargs_data is not None
291
+ assert features.mm_metadata is not None
292
+ assert features.kwargs_data["image"][1] is None
293
+ assert features.mm_metadata["image"][1] is None
294
+ assert len(features.mm_metadata["image"]) == 2
295
+ metadata = decode_mm_kwargs_item(features.mm_metadata["image"][0])
296
+ assert torch.equal(metadata["image_grid_thw"].data, image_grid_thw.data)
297
+
298
+
299
+def test_features_alignment_rejects_length_mismatch():
300
+ try:
301
+ MultiModalFeatures(
302
+ mm_hashes={"image": ["a", "b"]},
303
+ mm_placeholders={"image": [PlaceholderRangeInfo(offset=0, length=1)]},
304
+ )
305
+ except ValidationError:
306
+ return
307
+ raise AssertionError("expected placeholder length mismatch to fail")
308
+
309
+
310
+def test_metadata_can_replace_or_extend_full_mm_data():
311
+ """Metadata-only requests retain the fields required by the model."""
312
+ metadata_elem = MultiModalFieldElem(
313
+ data=torch.tensor([[1, 24, 24]], dtype=torch.int32),
314
+ field=MultiModalBatchedField(keep_on_cpu=True),
315
+ )
316
+ metadata_item = MultiModalKwargsItem({"image_grid_thw": metadata_elem})
317
+ pixel_elem = MultiModalFieldElem(
318
+ data=torch.randn(5, 3),
319
+ field=MultiModalBatchedField(),
320
+ )
321
+ full_item = MultiModalKwargsItem(
322
+ {"pixel_values": pixel_elem, "image_grid_thw": metadata_elem}
323
+ )
324
+
325
+ assert merge_mm_kwargs_items(None, metadata_item) == metadata_item
326
+ merged = merge_mm_kwargs_items(full_item, metadata_item)
327
+ assert merged is not None
328
+ assert set(merged) == {"pixel_values", "image_grid_thw"}
vllm/entrypoints/scale_out/derender/serving.py
+3
-43
@@ -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 typing import cast
4
5
import vllm.envs as envs
6
from vllm.entrypoints.openai.chat_completion.protocol import (
@@ -19,16 +18,11 @@ from vllm.entrypoints.openai.models.serving import (
18
from vllm.entrypoints.serve.engine.protocol import ErrorResponse, UsageInfo
19
from vllm.entrypoints.serve.engine.serving import BaseServing
20
from vllm.entrypoints.serve.utils.request_logger import RequestLogger
22
-from vllm.inputs import (
23
- EngineInput,
24
- MultiModalHashes,
25
- MultiModalInput,
26
- MultiModalPlaceholders,
27
-)
21
+from vllm.inputs import EngineInput
22
from vllm.logger import init_logger
23
from vllm.renderers.online_derenderer import OnlineDerenderer
24
31
-from ..token_in_token_out.mm_serde import encode_mm_kwargs_item
25
+from ..token_in_token_out.mm_features import extract_mm_features
26
from ..token_in_token_out.protocol import (
27
DerenderChatRequest,
28
DerenderChatStreamRequest,
@@ -37,7 +31,6 @@ from ..token_in_token_out.protocol import (
31
DerenderStreamState,
32
GenerateResponse,
33
MultiModalFeatures,
40
- PlaceholderRangeInfo,
34
)
35
36
logger = init_logger(__name__)
@@ -337,37 +330,4 @@ class ServingDerender(BaseServing):
330
def _extract_mm_features(
331
engine_input: EngineInput,
332
) -> MultiModalFeatures | None:
340
- """Extract multimodal metadata from a rendered engine prompt.
341
-
342
- Returns ``None`` for text-only prompts.
343
- """
344
- if engine_input.get("type") != "multimodal":
345
- return None
346
-
347
- # At this point engine_input is a MultiModalInput TypedDict.
348
- mm_engine_input = cast(MultiModalInput, engine_input)
349
- mm_hashes: MultiModalHashes = mm_engine_input["mm_hashes"]
350
- raw_placeholders: MultiModalPlaceholders = mm_engine_input["mm_placeholders"]
351
-
352
- mm_placeholders = {
353
- modality: [
354
- PlaceholderRangeInfo(offset=p.offset, length=p.length) for p in ranges
355
- ]
356
- for modality, ranges in raw_placeholders.items()
357
- }
358
-
359
- # Serialize tensor data per modality.
360
- kwargs_data: dict[str, list[str | None]] | None = None
361
- if raw_mm_kwargs := mm_engine_input.get("mm_kwargs"):
362
- kwargs_data = {}
363
- for modality, items in raw_mm_kwargs.items():
364
- kwargs_data[modality] = [
365
- encode_mm_kwargs_item(item) if item is not None else None
366
- for item in items
367
- ]
368
-
369
- return MultiModalFeatures(
370
- mm_hashes=mm_hashes,
371
- mm_placeholders=mm_placeholders,
372
- kwargs_data=kwargs_data,
373
- )
333
+ return extract_mm_features(engine_input)
vllm/entrypoints/scale_out/render/serving.py
+36
-44
@@ -1,7 +1,5 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-from typing import cast
4
-
3
from vllm.entrypoints.anthropic.protocol import AnthropicMessagesRequest
4
from vllm.entrypoints.anthropic.serving import AnthropicServingMessages
5
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
@@ -10,23 +8,20 @@ from vllm.entrypoints.openai.models.serving import (
8
OpenAIModelRegistry,
9
OpenAIServingModels,
10
)
13
-from vllm.entrypoints.scale_out.token_in_token_out.mm_serde import encode_mm_kwargs_item
11
+from vllm.entrypoints.scale_out.token_in_token_out.mm_features import (
12
+ extract_mm_features,
13
+)
14
from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
15
GenerateRequest,
16
MultiModalFeatures,
17
- PlaceholderRangeInfo,
17
)
18
from vllm.entrypoints.serve.engine.protocol import ErrorResponse
19
from vllm.entrypoints.serve.engine.serving import BaseServing
20
from vllm.entrypoints.serve.utils.api_utils import get_max_tokens
21
from vllm.entrypoints.serve.utils.request_logger import RequestLogger
23
-from vllm.inputs import (
24
- EngineInput,
25
- MultiModalHashes,
26
- MultiModalInput,
27
- MultiModalPlaceholders,
28
-)
22
+from vllm.inputs import EngineInput
23
from vllm.logger import init_logger
24
+from vllm.multimodal.parse import MultiModalDataParser
25
from vllm.renderers.inputs.preprocess import (
26
extract_prompt_components,
27
extract_prompt_len,
@@ -59,6 +54,9 @@ class ServingRender(BaseServing):
54
)
55
)
56
57
+ self._placeholder_metadata_parser: MultiModalDataParser | None = None
58
+ self._placeholder_metadata_parser_failed = False
59
+
60
self.default_sampling_params = (
61
online_renderer.model_config.get_diff_sampling_param()
62
)
@@ -231,41 +229,35 @@ class ServingRender(BaseServing):
229
230
return generate_requests
231
234
- @staticmethod
232
+ def _placeholder_metadata_fields(self, modality: str) -> set[str]:
233
+ """Fields the EC consumer still requires after embeddings transfer."""
234
+ if self._placeholder_metadata_parser_failed:
235
+ return set()
236
+ if self._placeholder_metadata_parser is None:
237
+ try:
238
+ from vllm.multimodal import MULTIMODAL_REGISTRY
239
+
240
+ self._placeholder_metadata_parser = (
241
+ MULTIMODAL_REGISTRY.create_processor(
242
+ self.model_config
243
+ ).info.data_parser
244
+ )
245
+ except Exception:
246
+ logger.debug(
247
+ "Could not load placeholder metadata fields; "
248
+ "mm_metadata will use keep_on_cpu fields only."
249
+ )
250
+ self._placeholder_metadata_parser_failed = True
251
+ return set()
252
+ return set(
253
+ self._placeholder_metadata_parser.placeholder_metadata_fields(modality)
254
+ )
255
+
256
def _extract_mm_features(
257
+ self,
258
engine_input: EngineInput,
259
) -> MultiModalFeatures | None:
238
- """Extract multimodal metadata from a rendered engine prompt.
239
-
240
- Returns ``None`` for text-only prompts.
241
- """
242
- if engine_input.get("type") != "multimodal":
243
- return None
244
-
245
- # At this point engine_input is a MultiModalInput TypedDict.
246
- mm_engine_input = cast(MultiModalInput, engine_input)
247
- mm_hashes: MultiModalHashes = mm_engine_input["mm_hashes"]
248
- raw_placeholders: MultiModalPlaceholders = mm_engine_input["mm_placeholders"]
249
-
250
- mm_placeholders = {
251
- modality: [
252
- PlaceholderRangeInfo(offset=p.offset, length=p.length) for p in ranges
253
- ]
254
- for modality, ranges in raw_placeholders.items()
255
- }
256
-
257
- # Serialize tensor data per modality.
258
- kwargs_data: dict[str, list[str | None]] | None = None
259
- if raw_mm_kwargs := mm_engine_input.get("mm_kwargs"):
260
- kwargs_data = {}
261
- for modality, items in raw_mm_kwargs.items():
262
- kwargs_data[modality] = [
263
- encode_mm_kwargs_item(item) if item is not None else None
264
- for item in items
265
- ]
266
-
267
- return MultiModalFeatures(
268
- mm_hashes=mm_hashes,
269
- mm_placeholders=mm_placeholders,
270
- kwargs_data=kwargs_data,
260
+ return extract_mm_features(
261
+ engine_input,
262
+ metadata_fields_for=self._placeholder_metadata_fields,
263
)
vllm/entrypoints/scale_out/token_in_token_out/mm_features.py
new
+159
@@ -0,0 +1,159 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Helpers for render-time multimodal feature extraction and generate input."""
4
+
5
+from __future__ import annotations
6
+
7
+from collections.abc import Callable, Collection, Sequence
8
+from typing import cast
9
+
10
+from vllm.entrypoints.scale_out.token_in_token_out.mm_serde import (
11
+ decode_mm_kwargs_item,
12
+ encode_mm_kwargs_item,
13
+)
14
+from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
15
+ MultiModalFeatures,
16
+ PlaceholderRangeInfo,
17
+)
18
+from vllm.inputs import (
19
+ EngineInput,
20
+ MultiModalHashes,
21
+ MultiModalInput,
22
+ MultiModalPlaceholders,
23
+)
24
+from vllm.multimodal.inputs import (
25
+ MultiModalKwargsItem,
26
+ MultiModalKwargsOptionalItems,
27
+)
28
+
29
+
30
+def merge_mm_kwargs_items(
31
+ kwargs_item: MultiModalKwargsItem | None,
32
+ metadata_item: MultiModalKwargsItem | None,
33
+) -> MultiModalKwargsItem | None:
34
+ """Merge full multimodal data with its metadata-only counterpart."""
35
+ if kwargs_item is None:
36
+ return metadata_item
37
+ if metadata_item is None:
38
+ return kwargs_item
39
+ return MultiModalKwargsItem({**kwargs_item, **metadata_item})
40
+
41
+
42
+def _encode_metadata_items(
43
+ items: Sequence[MultiModalKwargsItem | None],
44
+ *,
45
+ declared: Collection[str],
46
+) -> list[str | None]:
47
+ """Serialize placeholder-metadata and ``keep_on_cpu`` fields per item."""
48
+ metadata_items: list[str | None] = []
49
+ for item in items:
50
+ if item is None:
51
+ metadata_items.append(None)
52
+ continue
53
+
54
+ metadata_item = MultiModalKwargsItem(
55
+ {
56
+ key: elem
57
+ for key, elem in item.items()
58
+ if elem.field.keep_on_cpu or key in declared
59
+ }
60
+ )
61
+ metadata_items.append(
62
+ encode_mm_kwargs_item(metadata_item) if metadata_item else None
63
+ )
64
+ return metadata_items
65
+
66
+
67
+def _encode_mm_kwargs_with_metadata(
68
+ raw_mm_kwargs: MultiModalKwargsOptionalItems,
69
+ *,
70
+ metadata_fields_for: Callable[[str], Collection[str]] | None = None,
71
+) -> tuple[dict[str, list[str | None]], dict[str, list[str | None]] | None]:
72
+ """Serialize full kwargs and their metadata-only subsets per modality."""
73
+ kwargs_data: dict[str, list[str | None]] = {}
74
+ metadata_by_modality: dict[str, list[str | None]] = {}
75
+
76
+ for modality, items in raw_mm_kwargs.items():
77
+ kwargs_data[modality] = [
78
+ encode_mm_kwargs_item(item) if item is not None else None for item in items
79
+ ]
80
+
81
+ declared = (
82
+ set(metadata_fields_for(modality))
83
+ if metadata_fields_for is not None
84
+ else set()
85
+ )
86
+ metadata_items = _encode_metadata_items(items, declared=declared)
87
+ if any(item is not None for item in metadata_items):
88
+ metadata_by_modality[modality] = metadata_items
89
+
90
+ mm_metadata = metadata_by_modality or None
91
+ return kwargs_data, mm_metadata
92
+
93
+
94
+def mm_kwargs_from_features(
95
+ features: MultiModalFeatures,
96
+) -> dict[str, list[MultiModalKwargsItem | None]]:
97
+ """Deserialize ``features`` into per-modality kwargs for ``mm_input``."""
98
+ mm_kwargs: dict[str, list[MultiModalKwargsItem | None]] = {}
99
+ kwargs_data = features.kwargs_data or {}
100
+ mm_metadata = features.mm_metadata or {}
101
+ for modality, hashes in features.mm_hashes.items():
102
+ n = len(hashes)
103
+ kwargs_items = [
104
+ decode_mm_kwargs_item(item) if item is not None else None
105
+ for item in kwargs_data.get(modality, [None] * n)
106
+ ]
107
+ metadata_items = [
108
+ decode_mm_kwargs_item(item) if item is not None else None
109
+ for item in mm_metadata.get(modality, [None] * n)
110
+ ]
111
+ mm_kwargs[modality] = [
112
+ merge_mm_kwargs_items(kwargs_item, metadata_item)
113
+ for kwargs_item, metadata_item in zip(
114
+ kwargs_items, metadata_items, strict=True
115
+ )
116
+ ]
117
+ return mm_kwargs
118
+
119
+
120
+def extract_mm_features(
121
+ engine_input: EngineInput,
122
+ *,
123
+ metadata_fields_for: Callable[[str], Collection[str]] | None = None,
124
+) -> MultiModalFeatures | None:
125
+ """Extract multimodal features from a rendered engine prompt.
126
+
127
+ Returns ``None`` for text-only prompts. ``mm_metadata`` keeps the
128
+ intersection of processed kwargs that prefill needs after EC transfer:
129
+ fields declared as embedding metadata, plus fields marked
130
+ ``keep_on_cpu`` (for example M-RoPE grid dims).
131
+ """
132
+ if engine_input.get("type") != "multimodal":
133
+ return None
134
+
135
+ mm_engine_input = cast(MultiModalInput, engine_input)
136
+ mm_hashes: MultiModalHashes = mm_engine_input["mm_hashes"]
137
+ raw_placeholders: MultiModalPlaceholders = mm_engine_input["mm_placeholders"]
138
+
139
+ mm_placeholders = {
140
+ modality: [
141
+ PlaceholderRangeInfo(offset=p.offset, length=p.length) for p in ranges
142
+ ]
143
+ for modality, ranges in raw_placeholders.items()
144
+ }
145
+
146
+ kwargs_data: dict[str, list[str | None]] | None = None
147
+ mm_metadata: dict[str, list[str | None]] | None = None
148
+ if raw_mm_kwargs := mm_engine_input.get("mm_kwargs"):
149
+ kwargs_data, mm_metadata = _encode_mm_kwargs_with_metadata(
150
+ raw_mm_kwargs,
151
+ metadata_fields_for=metadata_fields_for,
152
+ )
153
+
154
+ return MultiModalFeatures(
155
+ mm_hashes=mm_hashes,
156
+ mm_placeholders=mm_placeholders,
157
+ kwargs_data=kwargs_data,
158
+ mm_metadata=mm_metadata,
159
+ )
vllm/entrypoints/scale_out/token_in_token_out/protocol.py
+45
@@ -44,6 +44,14 @@ class PlaceholderRangeInfo(BaseModel):
44
# placeholder masks that cannot be recomputed from offset+length alone.
45
46
47
+def _has_serialized_mm_items(
48
+ payload: dict[str, list[str | None]] | None,
49
+) -> bool:
50
+ if not payload:
51
+ return False
52
+ return any(item is not None for items in payload.values() for item in items)
53
+
54
+
55
class MultiModalFeatures(BaseModel):
56
"""Lightweight multimodal metadata produced by the render step.
57
@@ -67,6 +75,18 @@ class MultiModalFeatures(BaseModel):
75
``None`` for metadata-only (cache-hit) responses.
76
"""
77
78
+ mm_metadata: dict[str, list[str | None]] | None = None
79
+ """Per-modality serialized metadata for disaggregated prefill.
80
+
81
+ Each value is a list parallel to ``mm_hashes[modality]``. A ``str``
82
+ entry is a base64-encoded ``MultiModalKwargsItem`` containing only
83
+ placeholder-metadata and ``keep_on_cpu`` fields. ``None`` means that
84
+ the metadata is unavailable for that item. Prefill can use this
85
+ instead of ``kwargs_data`` only when ``ec_transfer_params`` is also
86
+ set, so embeddings arrive through the EC connector rather than from
87
+ ``pixel_values``.
88
+ """
89
+
90
@model_validator(mode="after")
91
def _validate_parallel_fields(self) -> "MultiModalFeatures":
92
modalities = set(self.mm_hashes)
@@ -76,6 +96,8 @@ class MultiModalFeatures(BaseModel):
96
)
97
if self.kwargs_data is not None and set(self.kwargs_data) != modalities:
98
raise ValueError("kwargs_data must use the same modalities as mm_hashes")
99
+ if self.mm_metadata is not None and set(self.mm_metadata) != modalities:
100
+ raise ValueError("mm_metadata must use the same modalities as mm_hashes")
101
102
flattened_ranges: list[tuple[int, int]] = []
103
for modality in modalities:
@@ -93,6 +115,13 @@ class MultiModalFeatures(BaseModel):
115
raise ValueError(
116
f"{modality} kwargs_data and mm_hashes must have the same length"
117
)
118
+ if (
119
+ self.mm_metadata is not None
120
+ and len(self.mm_metadata[modality]) != num_hashes
121
+ ):
122
+ raise ValueError(
123
+ f"{modality} mm_metadata and mm_hashes must have the same length"
124
+ )
125
flattened_ranges.extend(
126
(placeholder.offset, placeholder.offset + placeholder.length)
127
for placeholder in self.mm_placeholders[modality]
@@ -156,6 +185,22 @@ class GenerateRequest(BaseModel):
185
raise ValueError("content_parts and features are mutually exclusive")
186
return self
187
188
+ @model_validator(mode="after")
189
+ def _require_ec_for_metadata_only(self) -> "GenerateRequest":
190
+ features = self.features
191
+ if features is None:
192
+ return self
193
+ if not _has_serialized_mm_items(features.mm_metadata):
194
+ return self
195
+ if _has_serialized_mm_items(features.kwargs_data):
196
+ return self
197
+ if self.ec_transfer_params:
198
+ return self
199
+ raise ValueError(
200
+ "features.mm_metadata without features.kwargs_data requires "
201
+ "ec_transfer_params so embeddings can be loaded by the EC connector"
202
+ )
203
+
204
sampling_params: SamplingParams
205
"""The sampling parameters for the model."""
206
vllm/entrypoints/scale_out/token_in_token_out/serving.py
+4
-13
@@ -35,7 +35,6 @@ from vllm.inputs import EngineInput, TokensPrompt, mm_input
35
from vllm.logger import init_logger
36
from vllm.logprobs import Logprob
37
from vllm.multimodal.inputs import (
38
- MultiModalKwargsItem,
38
MultiModalKwargsItems,
39
PlaceholderRange,
40
)
@@ -45,7 +44,7 @@ from vllm.sampling_params import RequestOutputKind, SamplingParams
44
from vllm.utils.collection_utils import as_list
45
from vllm.utils.serial_utils import numpy2base64
46
48
-from .mm_serde import decode_mm_kwargs_item
47
+from .mm_features import mm_kwargs_from_features
48
from .protocol import (
49
GenerateRequest,
50
GenerateResponse,
@@ -177,17 +176,9 @@ class ServingTokens(GenerateBaseServing):
176
for modality, ranges in features.mm_placeholders.items()
177
}
178
180
- # Deserialize tensor data when present; None → cache hit.
181
- mm_kwargs: dict[str, list[MultiModalKwargsItem | None]] = {}
182
- if features.kwargs_data is not None:
183
- for modality, items in features.kwargs_data.items():
184
- mm_kwargs[modality] = [
185
- decode_mm_kwargs_item(item) if item is not None else None
186
- for item in items
187
- ]
188
- else:
189
- for modality, hashes in features.mm_hashes.items():
190
- mm_kwargs[modality] = [None] * len(hashes)
179
+ # Deserialize full tensor data and optional metadata-only data.
180
+ # Metadata-only items are valid when ec_transfer_params is set.
181
+ mm_kwargs = mm_kwargs_from_features(features)
182
183
engine_input = mm_input(
184
prompt_token_ids=request.token_ids,