[Perf][EPD] Batch image requests per encoder (#57095)

Signed-off-by: Tianyu Guo <guoty@inferact.ai> Signed-off-by: Zhou ziheng <jiaranran2@gmail.com> Co-authored-by: Zhou ziheng <jiaranran2@gmail.com> Co-authored-by: OpenAI Codex <codex@openai.com>

Tianyu Guo committed Sep 17, 2026 at 18:45 UTC 0d726275653800e82c324e1d32b074f782e89315
6 files changed +329 -59
examples/disaggregated/disaggregated_encoder/README.md
+16
@@ -106,6 +106,22 @@ If you enable prefill instance (`--prefill-servers-urls` not disabled), you will
106 | `--decode-servers-urls` | Comma-separated list of decode endpoints. Non-stream and stream paths both round-robin over this list. |
107 | `--host`, `--port` | Bind address for the proxy itself (defaults: `0.0.0.0:8000`). |
108
109 +The proxy batches images from the same user request assigned to the same encoder.
110 +Set the proxy environment variable `ENCODER_MAX_BATCH_SIZE` to limit the number
111 +of images per encoder subrequest. It defaults to `0` (unlimited); `1` sends each
112 +image separately. Audio and video remain separate subrequests.
113 +
114 +For encoders with a smaller `--limit-mm-per-prompt` image limit than P/PD, set
115 +`ENCODER_MAX_BATCH_SIZE` no higher than the smallest encoder image limit.
116 +For example, for encoders configured with `--limit-mm-per-prompt '{"image": 2}'`:
117 +
118 +```bash
119 +ENCODER_MAX_BATCH_SIZE=2 python disagg_epd_proxy.py \
120 + --encode-servers-urls "http://e1:8001,http://e2:8002" \
121 + --prefill-servers-urls disable \
122 + --decode-servers-urls "http://pd1:8003"
123 +```
124 +
125 Example usage:
126 For E + PD setup:
127
examples/disaggregated/disaggregated_encoder/disagg_epd_proxy.py
+88 -38
@@ -10,8 +10,8 @@ clusters:
10
11 For MM input we:
12 1. Extract *every* image/audio/video item.
13 - 2. Fire N concurrent requests to the encoder cluster
14 - (one request per item, with **all text removed**).
13 + 2. Send concurrent encoder requests with all text removed,
14 + grouping images assigned to the same encoder.
15 3. Wait for all of them to succeed.
16 4. Forward the *original* request to a decode server.
17 """
@@ -99,6 +99,11 @@ def validate_ec_consumer_routing(
99 # Diagnostic switch: forward the original request to the decoder so the
100 # only difference from the rewrite path is the rewrite itself.
101 NO_REWRITE = False
102 +# Maximum images per encoder subrequest; 0 leaves batches unlimited.
103 +ENCODER_MAX_BATCH_SIZE = int(os.getenv("ENCODER_MAX_BATCH_SIZE", "0"))
104 +if ENCODER_MAX_BATCH_SIZE < 0:
105 + raise ValueError("ENCODER_MAX_BATCH_SIZE must be non-negative")
106 +
107 # Decode-side retries for a retryable internal error (`finish_reason="error"`,
108 # e.g. an encoder embedding the connector could not deliver). Re-issuing runs
109 # the encode again, which produces a fresh transfer.
@@ -222,7 +227,7 @@ async def fanout_encoder_primer(
227 req_id: str,
228 consumer_zmq: str | None = None,
229 ) -> tuple[dict[int, dict], dict[str, Any]]:
225 - """1. Build one request *per MM item* with all text removed.
230 + """1. Group images by encoder, retaining per-item round-robin assignment.
231 2. Send them concurrently to the encode cluster.
232 3. Raise if any of them fails.
233
@@ -248,6 +253,7 @@ async def fanout_encoder_primer(
253 item_uuids: dict[int, str] = {}
254 item_transfer_ids: dict[int, str] = {}
255 item_meta: dict[int, dict] = {}
256 + transfer_items: dict[int, dict[str, str]] = {}
257 ec_params: dict[str, Any] = {}
258
259 # Round-robin over encode servers to distribute load a bit. The cursor
@@ -259,29 +265,45 @@ async def fanout_encoder_primer(
265 e_urls, encoder_rr_idx, len(mm_items)
266 )
267
268 + groups: list[tuple[str, list[int]]] = []
269 + image_groups: dict[str, list[int]] = {}
270 for idx, (item, target_url) in enumerate(zip(mm_items, url_cycle)):
271 + if item["type"] == "image_url":
272 + indices = image_groups.get(target_url)
273 + if indices is None or (
274 + ENCODER_MAX_BATCH_SIZE and len(indices) >= ENCODER_MAX_BATCH_SIZE
275 + ):
276 + indices = []
277 + image_groups[target_url] = indices
278 + groups.append((target_url, indices))
279 + indices.append(idx)
280 + else:
281 + groups.append((target_url, [idx]))
282 +
283 + for target_url, indices in groups:
284 # Derive a *child* request id: <parent>:<index>:<random-short>
264 - child_req_id = f"{req_id}:{idx}:{uuid.uuid4().hex[:6]}"
285 + child_req_id = f"{req_id}:{indices[0]}:{uuid.uuid4().hex[:6]}"
286 headers = {"x-request-id": child_req_id, "Content-Type": "application/json"}
287
288 # With --no-rewrite the decoder still receives the raw image and derives
289 # the cache key by hashing it, so the encoder must do the same -- passing
290 # a uuid here would make the two disagree and silently defeat the EC
291 # transfer, leaving the decoder to encode the image itself.
271 - item_uuid = None if NO_REWRITE else content_uuid(item)
272 - if item_uuid is not None:
273 - item_uuids[idx] = item_uuid
274 - transfer_id = uuid.uuid4().hex
275 - item_transfer_ids[idx] = transfer_id
292 + content = []
293 + for idx in indices:
294 + item = mm_items[idx]
295 + item_uuid = None if NO_REWRITE else content_uuid(item)
296 + if item_uuid is not None:
297 + item_uuids[idx] = item_uuid
298 + item_transfer_ids[idx] = uuid.uuid4().hex
299 + content.append(item if item_uuid is None else {**item, "uuid": item_uuid})
300
301 encoder_req = {
302 "model": orig_request.get("model"),
303 "messages": [
304 {
305 "role": "user",
282 - "content": [
283 - item if item_uuid is None else {**item, "uuid": item_uuid}
284 - ],
306 + "content": content,
307 },
308 ],
309 # No max_tokens cap: the encoder instance never samples, it finishes
@@ -297,17 +319,13 @@ async def fanout_encoder_primer(
319 if key in orig_request:
320 encoder_req[key] = orig_request[key]
321 if consumer_zmq is not None:
300 - # No mm_hash here on purpose. The encoder's own
301 - # `mm_features[i].identifier` is derived from the uuid *and* the
302 - # engine's media_io_kwargs / mm_processor_kwargs, so this proxy
303 - # cannot know it before the encoder runs. Sending the bare uuid
304 - # would fail the connector's hash match and make the producer
305 - # invent its own transfer id, which the consumer could then never
306 - # cancel. Omitting it lets the connector match by position, which
307 - # is exact: one encoder request carries exactly one item.
322 + # The engine may rehash UUIDs with processing options. Match by
323 + # position instead; batches contain only images in input order.
324 encoder_req["ec_transfer_params"] = {
325 "consumer_zmq": consumer_zmq,
310 - "ec_items": [{"transfer_id": transfer_id}],
326 + "ec_items": [
327 + {"transfer_id": item_transfer_ids[idx]} for idx in indices
328 + ],
329 }
330 tasks.append(
331 encode_session.post(
@@ -320,7 +338,8 @@ async def fanout_encoder_primer(
338 results = await asyncio.gather(*tasks, return_exceptions=True)
339
340 # Fail fast if any sub-request failed
323 - for idx, r in enumerate(results):
341 + for (_, indices), r in zip(groups, results):
342 + idx = indices[0]
343 if isinstance(r, Exception):
344 logger.error(
345 "[%s] Encoder request #%d raised exception: %s",
@@ -357,18 +376,45 @@ async def fanout_encoder_primer(
376 logger.warning("[%s] Could not read encoder metadata #%d", req_id, idx)
377 params = {}
378 if params:
360 - # One encoder request carries exactly one item, so there is a
361 - # single reported entry. Do not key it by this proxy's uuid: when
362 - # media_io_kwargs or mm_processor_kwargs are set the engine
363 - # re-hashes the uuid together with them, so the encoder's own
364 - # `mm_features[i].identifier` is a derived value this proxy cannot
365 - # predict. Fall back to the sole entry, and carry the key the
366 - # encoder actually used through as `ec_mm_hash`.
367 - ec_mm_hash = item_uuids.get(idx)
368 - reported = params.get(ec_mm_hash)
369 - if reported is None and len(params) == 1:
370 - ((ec_mm_hash, reported),) = params.items()
371 - if reported:
379 + by_index = {}
380 + for mm_hash, reported in params.items():
381 + if len(indices) == 1:
382 + break
383 + for local_index in reported.get("item_indices", []):
384 + if type(local_index) is not int or not 0 <= local_index < len(
385 + indices
386 + ):
387 + raise HTTPException(502, "Invalid encoder item index")
388 + if local_index in by_index:
389 + raise HTTPException(502, "Duplicate encoder item index")
390 + by_index[local_index] = (mm_hash, reported)
391 + for local_index, idx in enumerate(indices):
392 + matched = by_index.get(local_index)
393 + if matched is None:
394 + # Compatibility with encoders without position metadata.
395 + mm_hash = item_uuids.get(idx)
396 + if mm_hash in params:
397 + matched = (mm_hash, params[mm_hash])
398 + elif len(indices) == 1 and len(params) == 1:
399 + matched = next(iter(params.items()))
400 + elif (
401 + mm_items[idx]["type"] == "video_url"
402 + and (orig_request.get("mm_processor_kwargs") or {}).get(
403 + "use_audio_in_video"
404 + )
405 + and len(params) == 2
406 + and consumer_zmq is None
407 + and all(
408 + entry.keys() <= {"metadata", "item_indices"}
409 + for entry in params.values()
410 + )
411 + ):
412 + # Keep raw video for its audio/video features when no transfer
413 + # handles need to be forwarded.
414 + continue
415 + else:
416 + raise HTTPException(502, "Encoder metadata cannot be matched")
417 + ec_mm_hash, reported = matched
418 metadata = reported.get("metadata") or {}
419 if metadata:
420 item_meta[idx] = {
@@ -384,12 +430,16 @@ async def fanout_encoder_primer(
430 # looks it up by mm_hash on the request, so carry it through.
431 ec_params[ec_mm_hash] = reported
432 if NO_REWRITE and consumer_zmq is not None:
387 - ec_params.setdefault("ec_items", []).append(
388 - {"mm_hash": ec_mm_hash, "transfer_id": item_transfer_ids[idx]}
389 - )
433 + transfer_items[idx] = {
434 + "mm_hash": ec_mm_hash,
435 + "transfer_id": item_transfer_ids[idx],
436 + }
437 +
438 + if transfer_items:
439 + ec_params["ec_items"] = [transfer_items[idx] for idx in sorted(transfer_items)]
440
441 logger.info(
392 - "[%s] All %d encoder requests completed successfully", req_id, len(mm_items)
442 + "[%s] All %d encoder requests completed successfully", req_id, len(groups)
443 )
444 return item_meta, ec_params
445
tests/v1/ec_connector/unit/test_ec_example_connector.py
+15
@@ -16,6 +16,7 @@ from vllm.distributed.ec_transfer.ec_connector.example_connector import (
16 ECExampleConnectorMetadata,
17 MMMeta,
18 )
19 +from vllm.distributed.ec_transfer.ec_connector.utils import collect_ec_item_metadata
20 from vllm.multimodal.inputs import MultiModalFeatureSpec, PlaceholderRange
21 from vllm.v1.core.sched.output import SchedulerOutput
22
@@ -41,6 +42,20 @@ class MockRequest:
42 return self._token_counts[input_id]
43
44
45 +def test_metadata_preserves_repeated_item_positions():
46 + request = MockRequest("request", ["A", "B", "A"], [4, 4, 4])
47 + resolver = Mock()
48 + resolver.fields_for.return_value = {"image_grid_thw"}
49 + for feature in request.mm_features:
50 + feature.data = Mock()
51 + feature.data.get_data.return_value = {"image_grid_thw": torch.tensor([1, 2, 2])}
52 + items = collect_ec_item_metadata(request.mm_features, resolver)
53 + assert items == {
54 + "A": {"metadata": {"image_grid_thw": [1, 2, 2]}, "item_indices": [0, 2]},
55 + "B": {"metadata": {"image_grid_thw": [1, 2, 2]}, "item_indices": [1]},
56 + }
57 +
58 +
59 @pytest.fixture
60 def temp_storage(tmp_path):
61 """Fixture providing temporary storage path."""
tests/v1/ec_connector/unit/test_epd_proxy_retry.py
+200 -16
@@ -9,6 +9,8 @@ change to them is what these tests catch.
9 import asyncio
10 import importlib.util
11 from pathlib import Path
12 +from types import SimpleNamespace
13 +from typing import Any
14
15 import httpx
16 import msgspec
@@ -83,6 +85,128 @@ class _EncoderSession:
85 return _EncoderResponse(self._replies.pop(0))
86
87
88 +@pytest.mark.parametrize("no_rewrite", [False, True])
89 +@pytest.mark.parametrize(
90 + "batch_size, expected_sizes", [(0, [3, 3]), (1, [1] * 6), (2, [2, 2, 1, 1])]
91 +)
92 +def test_image_batches_preserve_item_identity(
93 + proxy, monkeypatch, no_rewrite, batch_size, expected_sizes
94 +):
95 + """Rehashed and repeated images retain metadata and per-occurrence transfers."""
96 + from vllm.distributed.ec_transfer.ec_connector.mooncake.scheduler import (
97 + ECMooncakeScheduler,
98 + )
99 +
100 + seen = []
101 +
102 + class Session:
103 + async def post(self, url, data=None, headers=None):
104 + body = msgspec.json.decode(data)
105 + seen.append((url, body))
106 + params: dict[str, dict[str, Any]] = {}
107 + for index, item in enumerate(body["messages"][0]["content"]):
108 + image = item["image_url"]["url"]
109 + entry = params.setdefault(
110 + image + "-processed",
111 + {
112 + "metadata": {"image_grid_thw": [1, 2, ord(image)]},
113 + "item_indices": [],
114 + "peer_port": 1234,
115 + },
116 + )
117 + entry["item_indices"].append(index)
118 + # Response ordering must not affect the image-to-metadata mapping.
119 + return _EncoderResponse(dict(reversed(list(params.items()))))
120 +
121 + monkeypatch.setattr(proxy, "encode_session", Session())
122 + monkeypatch.setattr(proxy, "NO_REWRITE", no_rewrite)
123 + monkeypatch.setattr(proxy, "ENCODER_MAX_BATCH_SIZE", batch_size)
124 + monkeypatch.setattr(proxy, "encoder_rr_idx", 0)
125 + images = ["A", "A", "C", "D", "A", "F"]
126 + body = {
127 + "messages": [
128 + {
129 + "role": "user",
130 + "content": [
131 + {"type": "image_url", "image_url": {"url": image}}
132 + for image in images
133 + ],
134 + }
135 + ],
136 + "mm_processor_kwargs": {"max_pixels": 262144},
137 + }
138 + meta, handles = asyncio.run(
139 + proxy.fanout_encoder_primer(
140 + body, ["http://e0", "http://e1"], "r", "tcp://consumer:1"
141 + )
142 + )
143 + assert [len(b["messages"][0]["content"]) for _, b in seen] == expected_sizes
144 + assert (
145 + len(
146 + {
147 + x["transfer_id"]
148 + for _, b in seen
149 + for x in b["ec_transfer_params"]["ec_items"]
150 + }
151 + )
152 + == 6
153 + )
154 + for url, batch in seen:
155 + assert batch["mm_processor_kwargs"] == body["mm_processor_kwargs"]
156 + for item in batch["messages"][0]["content"]:
157 + assert item["image_url"]["url"] in (
158 + {"A", "C"} if url.startswith("http://e0/") else {"A", "D", "F"}
159 + )
160 + assert [meta[i]["image_grid_thw"][-1] for i in range(6)] == list(map(ord, images))
161 + assert [meta[i]["ec_mm_hash"] for i in range(6)] == [
162 + x + "-processed" for x in images
163 + ]
164 + assert handles["A-processed"]["peer_port"] == 1234
165 + assert "ec_transfer_params" not in body
166 +
167 + transfers_by_encoder: list[list[dict[str, str]]] = [[], []]
168 + for url, batch in seen:
169 + rank = 0 if url.startswith("http://e0/") else 1
170 + transfers_by_encoder[rank].extend(batch["ec_transfer_params"]["ec_items"])
171 + expected_transfers = [
172 + transfers_by_encoder[i % 2][i // 2]["transfer_id"] for i in range(len(images))
173 + ]
174 + params = (
175 + handles
176 + if no_rewrite
177 + else proxy.rewrite_for_decode(body, meta)["ec_transfer_params"]
178 + )
179 + request = SimpleNamespace(
180 + ec_transfer_params=params,
181 + mm_features=[
182 + SimpleNamespace(identifier=image + "-processed") for image in images
183 + ],
184 + )
185 + assert [
186 + ECMooncakeScheduler._request_transfer_id(request, i) for i in range(len(images))
187 + ] == expected_transfers
188 +
189 +
190 +def test_batch_rejects_ambiguous_metadata(proxy, monkeypatch):
191 + monkeypatch.setattr(
192 + proxy,
193 + "encode_session",
194 + _EncoderSession([{"unknown": {"metadata": {"image_grid_thw": [1, 2, 2]}}}]),
195 + )
196 + body = {
197 + "messages": [
198 + {
199 + "content": [
200 + {"type": "image_url", "image_url": {"url": image}}
201 + for image in ["A", "B"]
202 + ]
203 + }
204 + ]
205 + }
206 + with pytest.raises(proxy.HTTPException, match="cannot be matched"):
207 + asyncio.run(proxy.fanout_encoder_primer(body, ["http://e0"], "r"))
208 +
209 +
210 def test_a_decode_retry_does_not_inherit_the_previous_handles(proxy, monkeypatch):
211 """The retry loop re-enters `prepare_for_decode` with the same body.
212
@@ -187,20 +311,21 @@ async def test_http_roundtrip_preserves_payload_and_response_bytes(
311 body = await request.json()
312 seen[stage].append(body)
313 if stage == "encode":
190 - item = body["messages"][0]["content"][0]
191 - mm_hash = item["uuid"]
192 - if body.get("mm_processor_kwargs"):
193 - mm_hash += "-processed"
194 - return web.json_response(
195 - {
196 - "ec_transfer_params": {
197 - mm_hash: {
198 - "metadata": {"image_grid_thw": [[1, 2, 2]]},
199 - "peer_port": len(seen[stage]),
200 - }
201 - }
202 - }
203 - )
314 + params: dict[str, dict[str, Any]] = {}
315 + for index, item in enumerate(body["messages"][0]["content"]):
316 + mm_hash = item["uuid"]
317 + if body.get("mm_processor_kwargs"):
318 + mm_hash += "-processed"
319 + entry = params.setdefault(
320 + mm_hash,
321 + {
322 + "metadata": {"image_grid_thw": [[1, 2, 2]]},
323 + "peer_port": len(seen[stage]),
324 + "item_indices": [],
325 + },
326 + )
327 + entry["item_indices"].append(index)
328 + return web.json_response({"ec_transfer_params": params})
329 if stage == "prefill":
330 assert body["max_tokens"] == 1 and body["stream"] is False
331 return web.json_response(
@@ -263,7 +388,7 @@ async def test_http_roundtrip_preserves_payload_and_response_bytes(
388 finally:
389 await proxy.on_shutdown()
390
266 - assert len(seen["encode"]) == 4
391 + assert len(seen["encode"]) == 2
392 assert len(seen["decode"]) == 2
393 for requests in seen.values():
394 for forwarded in requests:
@@ -296,7 +421,7 @@ async def test_http_roundtrip_preserves_payload_and_response_bytes(
421 if request_options:
422 mm_hash += "-processed"
423 assert set(final["ec_transfer_params"]) - {"ec_items"} == {mm_hash}
299 - assert final["ec_transfer_params"][mm_hash]["peer_port"] in (3, 4)
424 + assert final["ec_transfer_params"][mm_hash]["peer_port"] == 2
425 if prefill:
426 assert final["kv_transfer_params"] == {"remote_block_ids": [2]}
427
@@ -324,3 +449,62 @@ def test_pooled_connections_are_retired_before_the_server_closes_them(
449 assert proxy.decode_session.connector._keepalive_timeout == pooled_for
450 finally:
451 asyncio.run(proxy.on_shutdown())
452 +
453 +
454 +@pytest.mark.parametrize(
455 + "no_rewrite, transfer",
456 + [(False, None), (True, None), (False, "push"), (False, "handle")],
457 +)
458 +def test_video_audio_fallback(proxy, monkeypatch, no_rewrite, transfer):
459 + """Keep raw video and neighboring images, but never discard transfer handles."""
460 + import copy
461 +
462 + from vllm.distributed.ec_transfer.ec_connector.utils import collect_ec_item_metadata
463 +
464 + video = {"type": "video_url", "video_url": {"url": "video-with-audio"}}
465 + images = [{"type": "image_url", "image_url": {"url": key}} for key in ("A", "B")]
466 + body: dict[str, Any] = {
467 + "messages": [{"content": [images[0], video, images[1]]}],
468 + "mm_processor_kwargs": {"use_audio_in_video": True},
469 + }
470 + original = copy.deepcopy(body)
471 + # Collector indices describe two processed features from one video item.
472 + video_metadata = collect_ec_item_metadata(
473 + [SimpleNamespace(identifier=key, data=None) for key in ("video", "audio")], None
474 + )
475 + if transfer == "handle":
476 + video_metadata["audio"]["transfer_id"] = "reservation"
477 + image_metadata = {
478 + key: {"metadata": {"image_grid_thw": [1, 2, size]}, "item_indices": [i]}
479 + for i, (key, size) in enumerate((("A", 2), ("B", 4)))
480 + }
481 + # Images form the first group, the singleton video the second.
482 + monkeypatch.setattr(proxy, "ENCODER_MAX_BATCH_SIZE", 0)
483 + monkeypatch.setattr(proxy, "NO_REWRITE", no_rewrite)
484 + replies = _EncoderSession([image_metadata, video_metadata])
485 + monkeypatch.setattr(proxy, "encode_session", replies)
486 + consumer = "tcp://consumer:1" if transfer == "push" else None
487 + preparation = proxy.prepare_for_decode(body, "r", ["http://e0"], "", consumer)
488 + if transfer:
489 + with pytest.raises(proxy.HTTPException, match="cannot be matched"):
490 + asyncio.run(preparation)
491 + return
492 +
493 + prepared, _, _ = asyncio.run(preparation)
494 + assert body == original
495 + assert prepared["mm_processor_kwargs"] == body["mm_processor_kwargs"]
496 + final = prepared["messages"][0]["content"]
497 + assert final[1] == video
498 + handles = prepared["ec_transfer_params"]
499 + assert not video_metadata.keys() & handles.keys()
500 + if no_rewrite:
501 + assert final == original["messages"][0]["content"]
502 + assert "ec_items" not in handles
503 + else:
504 + for position, image, key in ((0, images[0], "A"), (2, images[1], "B")):
505 + assert final[position] == {
506 + "type": "image_embeds",
507 + "image_embeds": image_metadata[key]["metadata"],
508 + "uuid": proxy.content_uuid(image),
509 + }
510 + assert [item["mm_hash"] for item in handles["ec_items"]] == ["A", "B"]
tests/v1/ec_connector/unit/test_scheduler_nixl_producer.py
+3 -1
@@ -67,6 +67,7 @@ def test_request_finished_producer_emits_params(monkeypatch):
67 assert params == {
68 "h1": {
69 "metadata": {},
70 + "item_indices": [0],
71 "peer_host": "1.2.3.4",
72 "peer_port": 5601,
73 "size_bytes": 2 * 32 * 2,
@@ -91,6 +92,7 @@ def test_request_finished_announces_not_ready_entry(monkeypatch):
92 assert params == {
93 "h1": {
94 "metadata": {},
95 + "item_indices": [0],
96 "peer_host": "1.2.3.4",
97 "peer_port": 5601,
98 "size_bytes": 2 * 32 * 2,
@@ -110,5 +112,5 @@ def test_request_finished_skips_unallocated_entry(monkeypatch):
112 assert delay is False
113 # The item's placeholder metadata is still reported even though there's
114 # no cache entry to transfer (empty "metadata": no fields, no transfer).
113 - assert params == {"h1": {"metadata": {}}}
115 + assert params == {"h1": {"metadata": {}, "item_indices": [0]}}
116 s.shutdown()
vllm/distributed/ec_transfer/ec_connector/utils.py
+7 -4
@@ -60,15 +60,16 @@ def collect_ec_item_metadata(
60
61 Keyed by mm_hash, each entry carries a `metadata` dict with whatever
62 placeholder fields `resolver` says this model needs published for its
63 - modality, so a consumer can skip the image transform. `data` is None for
64 - items served from the processor cache, in which case the metadata is
63 + modality, so a consumer can skip the image transform. `item_indices`
64 + identifies every occurrence in `mm_features`, including repeated hashes.
65 + `data` is None for items served from the processor cache, so the metadata is
66 unavailable here and the consumer has to fall back to processing the
67 media itself. A connector that also has transfer coordinates to report
68 (e.g. NIXL peer_host/peer_port/size_bytes) merges those in alongside
69 `metadata`, not into it.
70 """
71 items: dict[str, dict[str, Any]] = {}
71 - for feature in mm_features:
72 + for index, feature in enumerate(mm_features):
73 metadata: dict[str, Any] = {}
74 if feature.data is not None:
75 wanted = resolver.fields_for(feature.modality)
@@ -81,7 +82,9 @@ def collect_ec_item_metadata(
82 # Some metadata (e.g. Qwen3-VL video timestamps) is
83 # produced as a plain list rather than a tensor.
84 metadata[key] = value
84 - items[feature.identifier] = {"metadata": metadata}
85 + entry = items.setdefault(feature.identifier, {"item_indices": []})
86 + entry["metadata"] = metadata
87 + entry["item_indices"].append(index)
88 return items
89
90