[EPD] Allow KV consumers to omit MM embeddings (#52697)

Signed-off-by: zhenwei-intel <zhenwei.liu@intel.com>

liuzhenwei committed Aug 19, 2026 at 10:16 UTC b1d9337e92692da328bdf921286e59d8f8f36725
16 files changed +43 -46
tests/v1/ec_connector/integration/run_epd_correctness_test.sh
+3
@@ -183,6 +183,7 @@ run_epd_1e_1pd() {
183 --port "$PREFILL_DECODE_PORT" \
184 --max-model-len "$MAX_MODEL_LEN" \
185 --enforce-eager \
186 + --enable-mm-embeds \
187 --gpu-memory-utilization "$GPU_MEMORY_UTILIZATION" \
188 --enable-request-id-headers \
189 --max-num-seqs "$MAX_NUM_SEQS" \
@@ -389,6 +390,7 @@ run_epd_1e_1p_1d() {
390 --port "$PREFILL_PORT" \
391 --max-model-len "$MAX_MODEL_LEN" \
392 --enforce-eager \
393 + --enable-mm-embeds \
394 --gpu-memory-utilization "$GPU_MEMORY_UTILIZATION" \
395 --enable-request-id-headers \
396 --max-num-seqs "$MAX_NUM_SEQS" \
@@ -415,6 +417,7 @@ run_epd_1e_1p_1d() {
417 --port "$DECODE_PORT" \
418 --max-model-len "$MAX_MODEL_LEN" \
419 --enforce-eager \
420 + --enable-mm-embeds \
421 --gpu-memory-utilization "$GPU_MEMORY_UTILIZATION" \
422 --enable-request-id-headers \
423 --max-num-seqs "$MAX_NUM_SEQS" \
vllm/config/multimodal.py
+6 -6
@@ -236,17 +236,17 @@ class MultiModalConfig:
236 - "direct_rpc": Use msgspec serialization via RPC
237 - "torch_shm": Use torch.multiprocessing shared memory for zero-copy IPC
238 Defaults to "direct_rpc". """
239 - mm_embeds_from_ec_connector: bool = False
239 + allow_missing_mm_embeddings: bool = False
240 """Whether a pre-computed-embedding input may omit the `*_embeds` tensor.
241
242 In an encode/prefill/decode (EPD) deployment the encoder instance publishes
243 - embeddings through the EC connector, so the request that reaches the
244 - prefill/decode instance only needs to carry the grid/size metadata that
245 - sizes the placeholder range — the embeddings themselves come from the
246 - connector, keyed by `mm_hash`.
243 + embeddings through the EC connector. An EC consumer loads those embeddings
244 + from the connector, while a KV consumer receives the resulting prompt KV
245 + cache. Their requests only need the grid/size metadata that sizes the
246 + placeholder range.
247
248 Derived, not user-settable: `VllmConfig.__post_init__` sets this to True
249 - exactly on EC consumers. Everywhere else it stays False so that a request
249 + on EC and KV consumers. Everywhere else it stays False so that a request
250 which forgets its embeddings still fails fast in the frontend, with a clear
251 error, rather than deep inside the model."""
252
vllm/config/vllm.py
+13 -13
@@ -1577,7 +1577,7 @@ class VllmConfig:
1577 )
1578 current_platform.check_and_update_config(self)
1579
1580 - self._resolve_mm_embeds_from_ec_connector()
1580 + self._resolve_allow_missing_mm_embeddings()
1581 self._resolve_mm_processor_device()
1582 self._validate_mm_processor_device()
1583
@@ -2254,14 +2254,13 @@ class VllmConfig:
2254 f"kernel_config={self.kernel_config!r}"
2255 )
2256
2257 - def _resolve_mm_embeds_from_ec_connector(self) -> None:
2258 - """Allow `*_embeds` to be omitted only where the connector supplies them.
2257 + def _resolve_allow_missing_mm_embeddings(self) -> None:
2258 + """Allow `*_embeds` tensors to be omitted on disaggregated consumers.
2259
2260 - That is exactly an EC consumer: the encoder instance publishes the
2261 - embeddings through the EC connector, so the request only has to carry
2262 - the grid metadata that sizes the placeholder range. On every other
2263 - deployment a missing `*_embeds` is a client error and must keep failing
2264 - fast in the frontend.
2260 + An EC consumer loads embeddings from its connector. A KV consumer
2261 + receives the prompt KV produced from those embeddings, so it does not
2262 + need the tensors either. On every other deployment a missing tensor is
2263 + a client error and must keep failing fast in the frontend.
2264 """
2265 model_config = self.model_config
2266 if model_config is None:
@@ -2271,15 +2270,16 @@ class VllmConfig:
2270 return
2271
2272 ec_config = self.ec_transfer_config
2273 + kv_config = self.kv_transfer_config
2274 # Derived, so overwrite unconditionally rather than honouring a value
2275 # that was set by hand.
2276 - mm_config.mm_embeds_from_ec_connector = (
2276 + mm_config.allow_missing_mm_embeddings = (
2277 ec_config is not None and ec_config.is_ec_consumer
2278 - )
2279 - if mm_config.mm_embeds_from_ec_connector:
2278 + ) or (kv_config is not None and kv_config.is_kv_consumer)
2279 + if mm_config.allow_missing_mm_embeddings:
2280 logger.info_once(
2281 - "EC consumer: pre-computed-embedding inputs may omit the "
2282 - "embedding tensor; embeddings are loaded from the EC connector."
2281 + "EC/KV consumer: pre-computed-embedding inputs may "
2282 + "omit the embedding tensor."
2283 )
2284
2285 def _resolve_mm_processor_device(self) -> None:
vllm/model_executor/models/colqwen3.py
+1 -1
@@ -104,7 +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,
107 + allow_missing_mm_embeddings=self.allow_missing_mm_embeddings,
108 )
109
110
vllm/model_executor/models/colqwen3_5.py
+1 -1
@@ -101,7 +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,
104 + allow_missing_mm_embeddings=self.allow_missing_mm_embeddings,
105 )
106
107
vllm/model_executor/models/hunyuan_vision.py
+1 -1
@@ -599,7 +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,
602 + allow_missing_mm_embeddings=self.allow_missing_mm_embeddings,
603 )
604
605 def get_supported_mm_limits(self) -> Mapping[str, int | None]:
vllm/model_executor/models/keye.py
+1 -1
@@ -936,7 +936,7 @@ class KeyeProcessingInfo(BaseProcessingInfo):
936 def get_data_parser(self):
937 return KeyeMultiModalDataParser(
938 expected_hidden_size=self._get_expected_hidden_size(),
939 - embeds_from_ec_connector=self.embeds_from_ec_connector,
939 + allow_missing_mm_embeddings=self.allow_missing_mm_embeddings,
940 )
941
942 def get_supported_mm_limits(
vllm/model_executor/models/keye_vl1_5.py
+1 -1
@@ -363,7 +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,
366 + allow_missing_mm_embeddings=self.allow_missing_mm_embeddings,
367 )
368
369 def get_max_frame_per_video(self) -> int:
vllm/model_executor/models/llava_onevision2.py
+1 -1
@@ -1279,7 +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,
1282 + allow_missing_mm_embeddings=self.allow_missing_mm_embeddings,
1283 )
1284
1285 def get_hf_processor(self, **kwargs: object):
vllm/model_executor/models/minicpmv.py
+1 -1
@@ -653,7 +653,7 @@ class MiniCPMVProcessingInfo(BaseProcessingInfo):
653 def get_data_parser(self):
654 return MiniCPMVMultiModalDataParser(
655 expected_hidden_size=self._get_expected_hidden_size(),
656 - embeds_from_ec_connector=self.embeds_from_ec_connector,
656 + allow_missing_mm_embeddings=self.allow_missing_mm_embeddings,
657 )
658
659 def get_model_version(self):
vllm/model_executor/models/opencua.py
+1 -1
@@ -57,7 +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,
60 + allow_missing_mm_embeddings=self.allow_missing_mm_embeddings,
61 )
62
63 def get_hf_config(self):
vllm/model_executor/models/qwen2_5_omni_thinker.py
+1 -1
@@ -373,7 +373,7 @@ class Qwen2_5OmniThinkerProcessingInfo(
373 target_sr=feature_extractor.sampling_rate,
374 target_channels=self.get_target_channels(),
375 expected_hidden_size=self._get_expected_hidden_size(),
376 - embeds_from_ec_connector=self.embeds_from_ec_connector,
376 + allow_missing_mm_embeddings=self.allow_missing_mm_embeddings,
377 )
378
379 def get_target_channels(self) -> int:
vllm/model_executor/models/qwen2_vl.py
+1 -1
@@ -857,7 +857,7 @@ class Qwen2VLProcessingInfo(BaseProcessingInfo):
857 return Qwen2VLMultiModalDataParser(
858 self.get_hf_config().vision_config.spatial_merge_size,
859 expected_hidden_size=self._get_expected_hidden_size(),
860 - embeds_from_ec_connector=self.embeds_from_ec_connector,
860 + allow_missing_mm_embeddings=self.allow_missing_mm_embeddings,
861 )
862
863 def get_supported_mm_limits(self) -> Mapping[str, int | None]:
vllm/model_executor/models/qwen3_vl.py
+1 -1
@@ -910,7 +910,7 @@ class Qwen3VLProcessingInfo(Qwen2VLProcessingInfo):
910 self.get_hf_config().vision_config.spatial_merge_size,
911 video_needs_metadata=True,
912 expected_hidden_size=self._get_expected_hidden_size(),
913 - embeds_from_ec_connector=self.embeds_from_ec_connector,
913 + allow_missing_mm_embeddings=self.allow_missing_mm_embeddings,
914 )
915
916 def _get_vision_info(
vllm/multimodal/parse.py
+6 -7
@@ -554,10 +554,9 @@ 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`.
557 + allow_missing_mm_embeddings (bool): Whether pre-computed embedding
558 + tensors may be absent from the request on a disaggregated consumer.
559 + Derived by `BaseProcessingInfo.allow_missing_mm_embeddings`.
560 """
561
562 embedding_fields: Mapping[str, Mapping[str, EmbeddingFieldRole]] = {}
@@ -594,7 +593,7 @@ class MultiModalDataParser:
593 """
594 metadata = self.placeholder_metadata_fields(modality)
595 values = set(self.embedding_fields.get(modality, {})) - metadata
597 - if self.embeds_from_ec_connector:
596 + if self.allow_missing_mm_embeddings:
597 return metadata, values
598 return metadata | values, set()
599
@@ -606,11 +605,11 @@ class MultiModalDataParser:
605 audio_resample_method: Literal["pyav", "scipy", "soxr"] = "pyav",
606 video_needs_metadata: bool = False,
607 expected_hidden_size: int | None = None,
609 - embeds_from_ec_connector: bool = False,
608 + allow_missing_mm_embeddings: bool = False,
609 ) -> None:
610 super().__init__()
611
613 - self.embeds_from_ec_connector = embeds_from_ec_connector
612 + self.allow_missing_mm_embeddings = allow_missing_mm_embeddings
613
614 self.audio_resampler = AudioResampler(
615 target_sr=target_sr,
vllm/multimodal/processing/context.py
+4 -9
@@ -367,15 +367,10 @@ class BaseProcessingInfo:
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 - """
370 + def allow_missing_mm_embeddings(self) -> bool:
371 + """Whether pre-computed embedding tensors may be omitted."""
372 mm_config = self.ctx.model_config.multimodal_config
378 - return mm_config is not None and mm_config.mm_embeds_from_ec_connector
373 + return mm_config is not None and mm_config.allow_missing_mm_embeddings
374
375 def get_data_parser(self) -> MultiModalDataParser:
376 """
@@ -389,7 +384,7 @@ class BaseProcessingInfo:
384 """
385 return MultiModalDataParser(
386 expected_hidden_size=self._get_expected_hidden_size(),
392 - embeds_from_ec_connector=self.embeds_from_ec_connector,
387 + allow_missing_mm_embeddings=self.allow_missing_mm_embeddings,
388 )
389
390 @cached_property