[Model Runner V2][MM] Support EVS (#46535)

Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Nick Hill committed Jun 24, 2026 at 07:18 UTC 84c62e1cbdef4250fbfda83782fd250e07ad0256
10 files changed +245 -50
vllm/model_executor/models/diffusion_gemma.py
+9 -9
@@ -49,10 +49,12 @@ from vllm.multimodal import MULTIMODAL_REGISTRY
49 from vllm.v1.outputs import LogprobsTensors
50 from vllm.v1.worker.gpu.attn_utils import build_attn_metadata
51 from vllm.v1.worker.gpu.buffer_utils import UvaBackedTensor, async_copy_to_gpu
52 +from vllm.v1.worker.gpu.input_batch import InputBatch
53 from vllm.v1.worker.gpu.model_states.interface import ModelState
54 from vllm.v1.worker.gpu.sample.logprob import compute_topk_logprobs
55 from vllm.v1.worker.gpu.sample.output import SamplerOutput
56 from vllm.v1.worker.gpu.sample.penalties import use_penalty
57 +from vllm.v1.worker.gpu.states import RequestState
58
59 from .interfaces import (
60 SupportsMultiModal,
@@ -869,7 +871,12 @@ class DiffusionGemmaModelState(ModelState):
871 if idx is not None:
872 self.diffusion_states.remove_request(idx)
873
872 - def get_mm_embeddings(self, scheduled_encoder_inputs, input_batch):
874 + def get_mm_embeddings(
875 + self,
876 + scheduled_encoder_inputs: dict[str, list[int]],
877 + input_batch: InputBatch,
878 + req_states: RequestState,
879 + ) -> torch.Tensor:
880 if not self.supports_mm_inputs:
881 return None
882
@@ -880,14 +887,7 @@ class DiffusionGemmaModelState(ModelState):
887 encoder_outputs = self.encoder_runner.execute_mm_encoder(mm_kwargs)
888 self.encoder_cache.encoder_outputs.update(zip(mm_hashes, encoder_outputs))
889
883 - mm_embeds, is_mm_embed = self.encoder_runner.gather_mm_embeddings(
884 - input_batch.req_ids,
885 - input_batch.num_tokens,
886 - input_batch.num_scheduled_tokens,
887 - input_batch.query_start_loc_np,
888 - input_batch.prefill_len_np,
889 - input_batch.num_computed_prefill_tokens_np,
890 - )
890 + mm_embeds, is_mm_embed = self.gather_mm_embeddings(input_batch)
891
892 if not mm_embeds:
893 # No MM tokens in this batch (e.g. all-decode step).
vllm/model_executor/models/interfaces.py
+6 -5
@@ -421,11 +421,11 @@ class SupportsMultiModalPruning(Protocol):
421
422 def recompute_mrope_positions(
423 self,
424 - input_ids: list[int],
425 - multimodal_embeddings: MultiModalEmbeddings,
424 + input_ids: list[int] | torch.Tensor,
425 + multimodal_embeddings: Sequence[torch.Tensor],
426 mrope_positions: torch.LongTensor,
427 num_computed_tokens: int,
428 - ) -> tuple[MultiModalEmbeddings, Tensor, int]:
428 + ) -> tuple[Sequence[torch.Tensor], Tensor, int]:
429 """
430 Update part of input mrope positions (starting with
431 num_computed_tokens index). Original mrope_positions are computed
@@ -435,8 +435,9 @@ class SupportsMultiModalPruning(Protocol):
435
436 Args:
437 input_ids: (N,) All input tokens of the prompt containing
438 - entire sequence.
439 - multimodal_embeddings: Tuple of multimodal embeddings that
438 + entire sequence. Either a host-side list or an already
439 + device-resident tensor.
440 + multimodal_embeddings: Sequence of multimodal embeddings that
441 fits into the prefill chunk that is being processed.
442 mrope_positions: Existing mrope positions (3, N) for entire
443 sequence
vllm/model_executor/models/qwen2_5_vl.py
+10 -6
@@ -1583,11 +1583,11 @@ class Qwen2_5_VLForConditionalGeneration(
1583
1584 def recompute_mrope_positions(
1585 self,
1586 - input_ids: list[int],
1587 - multimodal_embeddings: tuple[torch.Tensor, ...],
1586 + input_ids: list[int] | torch.Tensor,
1587 + multimodal_embeddings: Sequence[torch.Tensor],
1588 mrope_positions: torch.LongTensor,
1589 num_computed_tokens: int,
1590 - ) -> tuple[tuple[torch.Tensor, ...], torch.Tensor, int]:
1590 + ) -> tuple[Sequence[torch.Tensor], torch.Tensor, int]:
1591 """
1592 Update part of input mrope positions (starting with
1593 num_computed_tokens index). Original mrope_positions are computed
@@ -1618,8 +1618,12 @@ class Qwen2_5_VLForConditionalGeneration(
1618 else mrope_positions.device
1619 )
1620
1621 - # Tensors.
1622 - input_ids_t = async_tensor_h2d(input_ids, dtype=torch.long, device=device)
1621 + # Tensors. input_ids may already be a (device-side) tensor.
1622 + if isinstance(input_ids, torch.Tensor):
1623 + assert input_ids.device == device
1624 + input_ids_t = input_ids.to(torch.long)
1625 + else:
1626 + input_ids_t = async_tensor_h2d(input_ids, dtype=torch.long, device=device)
1627
1628 mm_embeddings_out = [mm[:, :-4] for mm in multimodal_embeddings]
1629 mm_embeddings_pos = [
@@ -1636,7 +1640,7 @@ class Qwen2_5_VLForConditionalGeneration(
1640 video_token_id,
1641 )
1642
1639 - return tuple(mm_embeddings_out), positions, mrope_positions_delta
1643 + return mm_embeddings_out, positions, mrope_positions_delta
1644
1645 def _parse_and_validate_multimodal_inputs(self, **kwargs: object) -> dict:
1646 mm_input_by_modality = {}
vllm/model_executor/models/qwen3_vl.py
+14 -10
@@ -2657,11 +2657,11 @@ class Qwen3VLForConditionalGeneration(
2657
2658 def recompute_mrope_positions(
2659 self,
2660 - input_ids: list[int],
2661 - multimodal_embeddings: MultiModalEmbeddings,
2660 + input_ids: list[int] | torch.Tensor,
2661 + multimodal_embeddings: Sequence[torch.Tensor],
2662 mrope_positions: torch.LongTensor,
2663 num_computed_tokens: int,
2664 - ) -> tuple[MultiModalEmbeddings, torch.Tensor, int]:
2664 + ) -> tuple[Sequence[torch.Tensor], torch.Tensor, int]:
2665 """
2666 Update part of input mrope positions (starting with
2667 num_computed_tokens index). Original mrope_positions are computed
@@ -2672,7 +2672,7 @@ class Qwen3VLForConditionalGeneration(
2672 Args:
2673 input_ids: (N,) All input tokens of the prompt containing
2674 entire sequence.
2675 - multimodal_embeddings: Tuple of multimodal embeddings that
2675 + multimodal_embeddings: Sequence of multimodal embeddings that
2676 fits into the prefill chunk that is being processed.
2677 mrope_positions: Existing mrope positions (3, N) for entire
2678 sequence
@@ -2694,14 +2694,14 @@ class Qwen3VLForConditionalGeneration(
2694
2695 @staticmethod
2696 def _recompute_mrope_positions(
2697 - input_ids: list[int],
2698 - multimodal_embeddings: MultiModalEmbeddings,
2697 + input_ids: list[int] | torch.Tensor,
2698 + multimodal_embeddings: Sequence[torch.Tensor],
2699 mrope_positions: torch.LongTensor,
2700 num_computed_tokens: int,
2701 vision_start_token_id: int,
2702 image_token_id: int,
2703 video_token_id: int,
2704 - ) -> tuple[MultiModalEmbeddings, torch.Tensor, int]:
2704 + ) -> tuple[Sequence[torch.Tensor], torch.Tensor, int]:
2705 # Device
2706 device = (
2707 multimodal_embeddings[0].device
@@ -2709,8 +2709,12 @@ class Qwen3VLForConditionalGeneration(
2709 else mrope_positions.device
2710 )
2711
2712 - # Tensors
2713 - input_ids_t = async_tensor_h2d(input_ids, device=device, dtype=torch.long)
2712 + # Tensors. input_ids may already be a (device-side) tensor.
2713 + if isinstance(input_ids, torch.Tensor):
2714 + assert input_ids.device == device
2715 + input_ids_t = input_ids.to(dtype=torch.long)
2716 + else:
2717 + input_ids_t = async_tensor_h2d(input_ids, device=device, dtype=torch.long)
2718
2719 mm_embeddings_out = []
2720 mm_embeddings_pos = []
@@ -2738,7 +2742,7 @@ class Qwen3VLForConditionalGeneration(
2742 video_token_id,
2743 )
2744
2741 - return tuple(mm_embeddings_out), positions, mrope_positions_delta
2745 + return mm_embeddings_out, positions, mrope_positions_delta
2746
2747 def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings | None:
2748 mm_input_by_modality = self._parse_and_validate_multimodal_inputs(**kwargs)
vllm/v1/worker/gpu/mm/rope.py
+17
@@ -90,6 +90,23 @@ class RopeState:
90 def get_positions(self, num_tokens: int) -> torch.Tensor:
91 return self.positions[:, :num_tokens]
92
93 + def read_prefill_positions(self, req_idx: int, length: int) -> torch.Tensor:
94 + """Return staged per-request prefill positions as [num_dims, length]."""
95 + base = self.num_dims * req_idx
96 + return self.prefill_positions.gpu[base : base + self.num_dims, :length]
97 +
98 + def update_prefill_positions(
99 + self, req_idx: int, positions: torch.Tensor, delta: int
100 + ) -> None:
101 + """Overwrite a request's staged prefill positions with recomputed values."""
102 + base = self.num_dims * req_idx
103 + length = positions.shape[1]
104 + self.prefill_positions.gpu[base : base + self.num_dims, :length].copy_(
105 + positions
106 + )
107 + if self.has_delta:
108 + self.prefill_delta.np[req_idx] = delta
109 +
110 def prepare_positions(
111 self,
112 idx_mapping: torch.Tensor,
vllm/v1/worker/gpu/model_runner.py
+4 -10
@@ -1229,7 +1229,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1229 scheduled_encoder_inputs=scheduler_output.scheduled_encoder_inputs,
1230 )
1231 inputs_embeds = self.model_state.get_mm_embeddings(
1232 - scheduler_output.scheduled_encoder_inputs, input_batch
1232 + scheduler_output.scheduled_encoder_inputs, input_batch, self.req_states
1233 )
1234 if inputs_embeds is not None and not self.model.requires_raw_input_tokens:
1235 input_ids = None
@@ -1413,15 +1413,9 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1413 # Get cached multimodal embeddings for draft forward.
1414 # NOTE: This is done here because postprocess updates
1415 # num_computed_prefill_tokens.
1416 - mm_inputs = self.model_state.encoder_runner.gather_mm_embeddings(
1417 - input_batch.req_ids,
1418 - input_batch.num_tokens,
1419 - input_batch.num_scheduled_tokens,
1420 - input_batch.query_start_loc_np,
1421 - input_batch.prefill_len_np,
1422 - input_batch.num_computed_prefill_tokens_np,
1423 - # The EAGLE/MTP drafter reads one position ahead of the target.
1424 - draft_lookahead=1,
1416 + # The EAGLE/MTP drafter reads one position ahead of the target.
1417 + mm_inputs = self.model_state.gather_mm_embeddings(
1418 + input_batch, draft_lookahead=1
1419 )
1420
1421 # Postprocess results and update request states.
vllm/v1/worker/gpu/model_states/default.py
+25 -8
@@ -15,6 +15,7 @@ from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
15 from vllm.v1.worker.gpu.mm.encoder_runner import EncoderRunner
16 from vllm.v1.worker.gpu.mm.rope import get_rope_state
17 from vllm.v1.worker.gpu.model_states.interface import ModelState
18 +from vllm.v1.worker.gpu.model_states.mm_pruning import maybe_create_mm_pruner
19 from vllm.v1.worker.gpu.states import RequestState
20 from vllm.v1.worker.utils import AttentionGroup
21
@@ -61,6 +62,11 @@ class DefaultModelState(ModelState):
62 device=self.device,
63 )
64
65 + # Pruner is used for multimodal embedding pruning (EVS).
66 + self.mm_pruner = maybe_create_mm_pruner(
67 + self.model_config, model, self.rope_state, encoder_cache
68 + )
69 +
70 def add_request(self, req_index: int, new_req_data: NewRequestData) -> None:
71 if self.rope_state is not None:
72 assert new_req_data.prefill_token_ids is not None
@@ -79,6 +85,7 @@ class DefaultModelState(ModelState):
85 self,
86 scheduled_encoder_inputs: dict[str, list[int]],
87 input_batch: InputBatch,
88 + req_states: RequestState,
89 ) -> torch.Tensor:
90 mm_hashes, mm_kwargs = self.encoder_runner.prepare_mm_inputs(
91 scheduled_encoder_inputs
@@ -89,14 +96,13 @@ class DefaultModelState(ModelState):
96 # Cache the encoder outputs by mm_hash
97 self.encoder_cache.encoder_outputs.update(zip(mm_hashes, encoder_outputs))
98
92 - mm_embeds, is_mm_embed = self.encoder_runner.gather_mm_embeddings(
93 - input_batch.req_ids,
94 - input_batch.num_tokens,
95 - input_batch.num_scheduled_tokens,
96 - input_batch.query_start_loc_np,
97 - input_batch.prefill_len_np,
98 - input_batch.num_computed_prefill_tokens_np,
99 - )
99 + mm_embeds, is_mm_embed = super().gather_mm_embeddings(input_batch)
100 + if self.mm_pruner is not None and mm_embeds:
101 + # EVS: recompute mrope positions for pruned media.
102 + mm_embeds = self.mm_pruner.recompute(mm_embeds, input_batch, req_states)
103 + # We must flush the staged rope updates for prepare_inputs() to pick up.
104 + self.apply_staged_writes()
105 +
106 # Use unpadded input_ids to match is_mm_embed size (num_tokens).
107 # input_batch.input_ids may be padded for CUDA graphs.
108 input_ids_unpadded = input_batch.input_ids[: input_batch.num_tokens]
@@ -105,6 +111,17 @@ class DefaultModelState(ModelState):
111 )
112 return inputs_embeds[: input_batch.num_tokens_after_padding]
113
114 + def gather_mm_embeddings(
115 + self, input_batch: InputBatch, draft_lookahead: int = 0
116 + ) -> tuple[list[torch.Tensor], torch.Tensor]:
117 + mm_embeds, is_mm_embed = super().gather_mm_embeddings(
118 + input_batch, draft_lookahead
119 + )
120 + if self.mm_pruner is not None:
121 + # EVS: strip the appended mrope-position channels.
122 + mm_embeds = self.mm_pruner.strip(mm_embeds)
123 + return mm_embeds, is_mm_embed
124 +
125 def prepare_inputs(
126 self, input_batch: InputBatch, req_states: RequestState
127 ) -> dict[str, torch.Tensor | None]:
vllm/v1/worker/gpu/model_states/encoder_decoder.py
+4 -1
@@ -85,7 +85,10 @@ class EncoderDecoderModelState(ModelState):
85 self.encoder_outputs: list[torch.Tensor] = []
86
87 def get_mm_embeddings(
88 - self, scheduled_encoder_inputs: dict[str, list[int]], input_batch: InputBatch
88 + self,
89 + scheduled_encoder_inputs: dict[str, list[int]],
90 + input_batch: InputBatch,
91 + req_states: RequestState,
92 ) -> None:
93 # Ensure encoder inputs are ordered consistently with input_batch.req_ids.
94 encoder_inputs: dict[str, list[int]] = {}
vllm/v1/worker/gpu/model_states/interface.py
+21 -1
@@ -13,6 +13,7 @@ from vllm.v1.core.sched.output import NewRequestData
13 from vllm.v1.kv_cache_interface import KVCacheConfig
14 from vllm.v1.worker.gpu.input_batch import InputBatch
15 from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
16 +from vllm.v1.worker.gpu.mm.encoder_runner import EncoderRunner
17 from vllm.v1.worker.gpu.states import RequestState
18 from vllm.v1.worker.utils import AttentionGroup
19
@@ -47,6 +48,8 @@ class ModelState(ABC):
48 raise NotImplementedError
49
50 model: nn.Module
51 + # Set by mm-capable states; used by the default gather_mm_embeddings().
52 + encoder_runner: EncoderRunner
53
54 def get_supported_generation_tasks(self) -> tuple[GenerationTask, ...]:
55 from vllm.model_executor.models.interfaces import (
@@ -82,10 +85,27 @@ class ModelState(ABC):
85
86 @abstractmethod
87 def get_mm_embeddings(
85 - self, scheduled_encoder_inputs: dict[str, list[int]], input_batch: InputBatch
88 + self,
89 + scheduled_encoder_inputs: dict[str, list[int]],
90 + input_batch: InputBatch,
91 + req_states: RequestState,
92 ) -> torch.Tensor | None:
93 raise NotImplementedError
94
95 + def gather_mm_embeddings(
96 + self, input_batch: InputBatch, draft_lookahead: int = 0
97 + ) -> tuple[list[torch.Tensor], torch.Tensor]:
98 + """Gather cached multimodal embeddings for a speculator's draft forward."""
99 + return self.encoder_runner.gather_mm_embeddings(
100 + input_batch.req_ids,
101 + input_batch.num_tokens,
102 + input_batch.num_scheduled_tokens,
103 + input_batch.query_start_loc_np,
104 + input_batch.prefill_len_np,
105 + input_batch.num_computed_prefill_tokens_np,
106 + draft_lookahead=draft_lookahead,
107 + )
108 +
109 @abstractmethod
110 def prepare_inputs(
111 self, input_batch: InputBatch, req_states: RequestState
vllm/v1/worker/gpu/model_states/mm_pruning.py new
+135
@@ -0,0 +1,135 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +import torch
4 +import torch.nn as nn
5 +
6 +from vllm.config import ModelConfig
7 +from vllm.model_executor.models.interfaces import supports_multimodal_pruning
8 +from vllm.multimodal.utils import get_mm_features_in_window
9 +from vllm.v1.worker.gpu.input_batch import InputBatch
10 +from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
11 +from vllm.v1.worker.gpu.mm.rope import RopeState
12 +from vllm.v1.worker.gpu.states import RequestState
13 +
14 +
15 +class MultiModalPruner:
16 + """Recomputes M-RoPE positions for multimodal models that prune embeddings
17 + (e.g. Qwen2.5-VL / Qwen3-VL / Nemotron-Nano-VL Efficient Video Sampling).
18 +
19 + Pruning models append their mrope-position channels to the (variable-count)
20 + media embeddings from `embed_multimodal`. Those channels must be split off and
21 + used to recompute mrope positions before the embeddings are merged.
22 + """
23 +
24 + def __init__(
25 + self,
26 + model: nn.Module,
27 + rope_state: RopeState,
28 + encoder_cache: EncoderCache,
29 + inputs_embeds_size: int,
30 + ) -> None:
31 + self.model = model
32 + self.rope_state = rope_state
33 + self.encoder_cache = encoder_cache
34 + # The cleaned embedding width: pruning models append their mrope-position
35 + # channels as trailing columns, so embeds[:, :inputs_embeds_size] strips them.
36 + self.inputs_embeds_size = inputs_embeds_size
37 +
38 + def strip(self, mm_embeds: list[torch.Tensor]) -> list[torch.Tensor]:
39 + """Draft forward: strip the appended position channels only.
40 +
41 + Stripping is per-embedding, so no per-request segmentation is needed. The
42 + speculator reuses the target's already-recomputed positions, hence there is
43 + no position write-back here.
44 + """
45 + return [mm[:, : self.inputs_embeds_size] for mm in mm_embeds]
46 +
47 + def recompute(
48 + self,
49 + mm_embeds: list[torch.Tensor],
50 + input_batch: InputBatch,
51 + req_states: RequestState,
52 + ) -> list[torch.Tensor]:
53 + """Target forward: split the appended mrope-position channels off each
54 + request's media embeddings, recompute the corrected mrope positions, and
55 + stage them back into RopeState. Returns the cleaned, flattened embeddings.
56 + """
57 + cleaned: list[torch.Tensor] = []
58 + pos = 0
59 + req_idx_list = input_batch.idx_mapping_np.tolist()
60 + prefill_lens_list = input_batch.prefill_len_np.tolist()
61 + num_computed_list = input_batch.num_computed_prefill_tokens_np.tolist()
62 + num_scheduled_list = input_batch.num_scheduled_tokens.tolist()
63 + for batch_idx, req_id in enumerate(input_batch.req_ids):
64 + num_computed = num_computed_list[batch_idx]
65 + query_end = num_computed + num_scheduled_list[batch_idx]
66 + num_req_embeds = self._num_window_embeds(req_id, num_computed, query_end)
67 + if num_req_embeds == 0:
68 + continue
69 + req_embeds = mm_embeds[pos : pos + num_req_embeds]
70 + pos += num_req_embeds
71 +
72 + req_idx = req_idx_list[batch_idx]
73 + prefill_len = prefill_lens_list[batch_idx]
74 + input_ids = req_states.all_token_ids.gpu[req_idx, :prefill_len]
75 + mrope_positions = self.rope_state.read_prefill_positions(
76 + req_idx, prefill_len
77 + ).long()
78 + req_cleaned, new_positions, delta = self.model.recompute_mrope_positions(
79 + input_ids=input_ids,
80 + multimodal_embeddings=req_embeds,
81 + mrope_positions=mrope_positions,
82 + num_computed_tokens=num_computed,
83 + )
84 + self.rope_state.update_prefill_positions(req_idx, new_positions, delta)
85 + cleaned.extend(req_cleaned)
86 +
87 + assert pos == len(mm_embeds)
88 + return cleaned
89 +
90 + def _num_window_embeds(self, req_id: str, query_start: int, query_end: int) -> int:
91 + """Count the media items contributing embeddings to [query_start,
92 + query_end), mirroring EncoderRunner.gather_mm_embeddings' per-request
93 + windowing so the flat mm_embeds list can be re-segmented per request.
94 +
95 + Note: This logic is intentionally duplicated here rather than being emitted
96 + from gather_mm_embeddings, to keep the main path cleaner, since this is a niche
97 + feature.
98 + """
99 + mm_features = self.encoder_cache.mm_features[req_id]
100 + lo, hi = get_mm_features_in_window(
101 + mm_features, start=query_start, end=query_end
102 + )
103 + count = 0
104 + for mm_feature in mm_features[lo:hi]:
105 + pos_info = mm_feature.mm_position
106 + start_idx = max(query_start - pos_info.offset, 0)
107 + end_idx = min(query_end - pos_info.offset, pos_info.length)
108 + embeds_start, embeds_end = pos_info.get_embeds_indices_in_range(
109 + start_idx, end_idx
110 + )
111 + if embeds_start != embeds_end:
112 + count += 1
113 + return count
114 +
115 +
116 +def maybe_create_mm_pruner(
117 + model_config: ModelConfig,
118 + model: nn.Module,
119 + rope_state: RopeState | None,
120 + encoder_cache: EncoderCache | None,
121 +) -> MultiModalPruner | None:
122 + """Create a MultiModalPruner if the model prunes embeddings and uses M-RoPE."""
123 + if (
124 + not rope_state
125 + or not rope_state.has_delta
126 + or not encoder_cache
127 + or not model_config.multimodal_config
128 + or not model_config.multimodal_config.is_multimodal_pruning_enabled()
129 + or not supports_multimodal_pruning(model)
130 + ):
131 + return None
132 +
133 + return MultiModalPruner(
134 + model, rope_state, encoder_cache, model_config.get_inputs_embeds_size()
135 + )