A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 7, 14:00 UTC
[Model Runner V2] Move MM encoder to Model States [3/N] (#35564)
Signed-off-by: Woosuk Kwon <woosuk@inferact.ai>
Woosuk Kwon committed
Feb 27, 2026 at 18:32 UTC
1a014a0a9327ed64a1bdec8e1afa43b9ea70a3c1
7 files changed
+135
-111
vllm/v1/worker/gpu/cudagraph_utils.py
-6
@@ -89,7 +89,6 @@ class CudaGraphManager:
89
model: nn.Module,
90
model_state: ModelState,
91
input_buffers: InputBuffers,
92
- inputs_embeds: torch.Tensor | None,
92
block_tables: BlockTables,
93
attn_groups: list[list[AttentionGroup]],
94
kv_cache_config: KVCacheConfig,
@@ -116,9 +115,6 @@ class CudaGraphManager:
115
model_inputs = {
116
"input_ids": input_buffers.input_ids[:num_tokens],
117
"positions": input_buffers.positions[:num_tokens],
119
- "inputs_embeds": (
120
- inputs_embeds[:num_tokens] if inputs_embeds is not None else None
121
- ),
118
# NOTE: Values returned by `prepare_dummy_inputs` will override the
119
# default values above.
120
**model_state.prepare_dummy_inputs(num_reqs, num_tokens),
@@ -255,7 +251,6 @@ class CudaGraphManager:
251
model: nn.Module,
252
model_state: ModelState,
253
input_buffers: InputBuffers,
258
- inputs_embeds: torch.Tensor | None,
254
block_tables: BlockTables,
255
attn_groups: list[list[AttentionGroup]],
256
kv_cache_config: KVCacheConfig,
@@ -267,7 +262,6 @@ class CudaGraphManager:
262
model=model,
263
model_state=model_state,
264
input_buffers=input_buffers,
270
- inputs_embeds=inputs_embeds,
265
block_tables=block_tables,
266
attn_groups=attn_groups,
267
kv_cache_config=kv_cache_config,
vllm/v1/worker/gpu/input_batch.py
-3
@@ -66,8 +66,6 @@ class InputBatch:
66
input_ids: torch.Tensor
67
# [num_tokens_after_padding]
68
positions: torch.Tensor
69
- # [num_tokens_after_padding, hidden_size]
70
- inputs_embeds: torch.Tensor | None
69
70
# [total_num_logits]
71
logits_indices: torch.Tensor
@@ -138,7 +136,6 @@ class InputBatch:
136
dcp_local_seq_lens=None,
137
input_ids=input_ids,
138
positions=positions,
141
- inputs_embeds=None,
139
logits_indices=logits_indices,
140
cu_num_logits=cu_num_logits,
141
cu_num_logits_np=cu_num_logits_np,
vllm/v1/worker/gpu/mm/encoder_cache.py
new
+40
@@ -0,0 +1,40 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+import torch
4
+
5
+from vllm.multimodal.inputs import MultiModalFeatureSpec
6
+
7
+
8
+class EncoderCache:
9
+ def __init__(self):
10
+ # req_id -> MM features
11
+ self.mm_features: dict[str, list[MultiModalFeatureSpec]] = {}
12
+ # MM hash -> encoder outputs
13
+ self.encoder_outputs: dict[str, torch.Tensor] = {}
14
+
15
+ def add_request(
16
+ self, req_id: str, mm_features: list[MultiModalFeatureSpec]
17
+ ) -> None:
18
+ self.mm_features[req_id] = mm_features
19
+
20
+ def remove_request(self, req_id: str) -> None:
21
+ self.mm_features.pop(req_id, None)
22
+
23
+ def reset_mm_cache(self) -> None:
24
+ """
25
+ Clear the multi-modal cache that was used during profiling,
26
+ but no longer needed during inference.
27
+ """
28
+ # TODO: Implement MM budget for encoder dummy run
29
+ pass
30
+
31
+ def reset_encoder_cache(self) -> None:
32
+ """Clear the GPU-side encoder cache storing vision embeddings.
33
+
34
+ This should be called when model weights are updated to ensure
35
+ stale embeddings computed with old weights are not reused.
36
+ """
37
+ self.encoder_outputs.clear()
38
+
39
+ def free_encoder_cache(self, mm_hash: str) -> None:
40
+ self.encoder_outputs.pop(mm_hash, None)
vllm/v1/worker/gpu/mm/encoder_runner.py
+8
-32
@@ -4,8 +4,9 @@ import numpy as np
4
import torch
5
6
from vllm.model_executor.models.interfaces import SupportsMultiModal
7
-from vllm.multimodal.inputs import MultiModalFeatureSpec, MultiModalKwargsItem
7
+from vllm.multimodal.inputs import MultiModalKwargsItem
8
from vllm.multimodal.utils import group_mm_kwargs_by_modality
9
+from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
10
from vllm.v1.worker.utils import sanity_check_mm_encoder_outputs
11
12
@@ -14,44 +15,19 @@ class EncoderRunner:
15
self,
16
max_num_tokens: int,
17
hidden_size: int,
18
+ encoder_cache: EncoderCache,
19
dtype: torch.dtype,
20
device: torch.device,
21
):
22
self.max_num_tokens = max_num_tokens
23
self.hidden_size = hidden_size
24
+ self.encoder_cache = encoder_cache
25
self.dtype = dtype
26
self.device = device
27
28
self.inputs_embeds = torch.zeros(
29
max_num_tokens, hidden_size, dtype=dtype, device=device
30
)
28
- self.req_id_to_mm_features: dict[str, list[MultiModalFeatureSpec]] = {}
29
- self.encoder_cache: dict[str, torch.Tensor] = {}
30
-
31
- def reset_mm_cache(self) -> None:
32
- """
33
- Clear the multi-modal cache that was used during profiling,
34
- but no longer needed during inference.
35
- """
36
- # TODO: Implement MM budget for encoder dummy run
37
- pass
38
-
39
- def reset_encoder_cache(self) -> None:
40
- """Clear the GPU-side encoder cache storing vision embeddings.
41
-
42
- This should be called when model weights are updated to ensure
43
- stale embeddings computed with old weights are not reused.
44
- """
45
- self.encoder_cache.clear()
46
-
47
- def add_request(self, req_id: str, mm_features: list[MultiModalFeatureSpec]):
48
- self.req_id_to_mm_features[req_id] = mm_features
49
-
50
- def free_encoder_cache(self, mm_hash: str) -> None:
51
- self.encoder_cache.pop(mm_hash, None)
52
-
53
- def remove_request(self, req_id: str) -> None:
54
- self.req_id_to_mm_features.pop(req_id, None)
31
32
def prepare_mm_inputs(
33
self, scheduled_encoder_inputs: dict[str, list[int]]
@@ -59,7 +35,7 @@ class EncoderRunner:
35
mm_hashes: list[str] = []
36
mm_kwargs: list[tuple[str, MultiModalKwargsItem]] = []
37
for req_id, encoder_input_ids in scheduled_encoder_inputs.items():
62
- mm_features = self.req_id_to_mm_features[req_id]
38
+ mm_features = self.encoder_cache.mm_features[req_id]
39
for mm_input_id in encoder_input_ids:
40
mm_feature = mm_features[mm_input_id]
41
if mm_feature.data is None:
@@ -90,7 +66,7 @@ class EncoderRunner:
66
encoder_outputs.extend(curr_group_outputs)
67
68
# Cache the encoder outputs by mm_hash
93
- self.encoder_cache.update(zip(mm_hashes, encoder_outputs))
69
+ self.encoder_cache.encoder_outputs.update(zip(mm_hashes, encoder_outputs))
70
return encoder_outputs
71
72
def gather_mm_embeddings(
@@ -122,7 +98,7 @@ class EncoderRunner:
98
# OPTIMIZATION: Skip decode requests.
99
continue
100
125
- mm_features = self.req_id_to_mm_features[req_id]
101
+ mm_features = self.encoder_cache.mm_features[req_id]
102
for mm_feature in mm_features:
103
pos_info = mm_feature.mm_position
104
start_pos = pos_info.offset
@@ -148,7 +124,7 @@ class EncoderRunner:
124
continue
125
126
mm_hash = mm_feature.identifier
151
- encoder_output = self.encoder_cache.get(mm_hash, None)
127
+ encoder_output = self.encoder_cache.encoder_outputs.get(mm_hash, None)
128
assert encoder_output is not None, f"Encoder cache miss for {mm_hash}."
129
130
if (is_embed := pos_info.is_embed) is not None:
vllm/v1/worker/gpu/model_runner.py
+34
-64
@@ -77,7 +77,7 @@ from vllm.v1.worker.gpu.kv_connector import (
77
get_kv_connector,
78
)
79
from vllm.v1.worker.gpu.lora_utils import LoraState
80
-from vllm.v1.worker.gpu.mm.encoder_runner import EncoderRunner
80
+from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
81
from vllm.v1.worker.gpu.model_states import ModelState
82
from vllm.v1.worker.gpu.pool.pooling_runner import PoolingRunner
83
from vllm.v1.worker.gpu.pp_utils import pp_broadcast, pp_receive
@@ -127,20 +127,6 @@ class GPUModelRunner(LoRAModelRunnerMixin):
127
self.max_model_len = self.model_config.max_model_len
128
self.max_num_tokens = self.scheduler_config.max_num_batched_tokens
129
self.max_num_reqs = self.scheduler_config.max_num_seqs
130
- self.inputs_embeds_size = self.model_config.get_inputs_embeds_size()
131
-
132
- # Multimodal
133
- self.mm_registry = MULTIMODAL_REGISTRY
134
- self.supports_mm_inputs = self.mm_registry.supports_multimodal_inputs(
135
- self.model_config
136
- )
137
- if self.supports_mm_inputs:
138
- self.encoder_runner = EncoderRunner(
139
- max_num_tokens=self.max_num_tokens,
140
- hidden_size=self.inputs_embeds_size,
141
- dtype=self.dtype,
142
- device=self.device,
143
- )
130
131
self.use_async_scheduling = self.scheduler_config.async_scheduling
132
self.output_copy_stream = torch.cuda.Stream(self.device)
@@ -162,6 +148,15 @@ class GPUModelRunner(LoRAModelRunnerMixin):
148
self.dcp_rank = get_dcp_group().rank_in_group if self.use_dcp else 0
149
self.cp_interleave = self.parallel_config.cp_kv_cache_interleave_size
150
151
+ # Multimodal
152
+ self.mm_registry = MULTIMODAL_REGISTRY
153
+ self.supports_mm_inputs = self.mm_registry.supports_multimodal_inputs(
154
+ self.model_config
155
+ )
156
+ self.encoder_cache = None
157
+ if self.supports_mm_inputs and self.is_first_pp_rank:
158
+ self.encoder_cache = EncoderCache()
159
+
160
self.speculator = None
161
self.num_speculative_steps = 0
162
self.use_aux_hidden_state_outputs = False
@@ -272,7 +267,9 @@ class GPUModelRunner(LoRAModelRunnerMixin):
267
prepare_communication_buffer_for_model(self.speculator)
268
269
# Initialize the components that require the model.
275
- self.model_state = ModelState(self.vllm_config, self.model, self.device)
270
+ self.model_state = ModelState(
271
+ self.vllm_config, self.model, self.encoder_cache, self.device
272
+ )
273
if self.is_pooling_model:
274
self.pooling_runner = PoolingRunner(self.model)
275
@@ -435,12 +432,12 @@ class GPUModelRunner(LoRAModelRunnerMixin):
432
gc.collect()
433
434
def reset_mm_cache(self) -> None:
438
- if self.supports_mm_inputs:
439
- self.encoder_runner.reset_mm_cache()
435
+ if self.encoder_cache is not None:
436
+ self.encoder_cache.reset_mm_cache()
437
438
def reset_encoder_cache(self) -> None:
442
- if self.supports_mm_inputs:
443
- self.encoder_runner.reset_encoder_cache()
439
+ if self.encoder_cache is not None:
440
+ self.encoder_cache.reset_encoder_cache()
441
442
def _get_num_input_tokens(self, num_scheduled_tokens: int) -> int:
443
# SP is not supported yet.
@@ -469,14 +466,10 @@ class GPUModelRunner(LoRAModelRunnerMixin):
466
start_free_gpu_memory = torch.cuda.mem_get_info()[0]
467
468
with self.maybe_setup_dummy_loras(self.lora_config):
472
- inputs_embeds = None
473
- if self.supports_mm_inputs:
474
- inputs_embeds = self.encoder_runner.inputs_embeds
469
self.cudagraph_manager.capture(
470
model=self.model,
471
model_state=self.model_state,
472
input_buffers=self.input_buffers,
479
- inputs_embeds=inputs_embeds,
473
block_tables=self.block_tables,
474
attn_groups=self.attn_groups,
475
kv_cache_config=self.kv_cache_config,
@@ -511,15 +504,15 @@ class GPUModelRunner(LoRAModelRunnerMixin):
504
finished_req_ids = finished_req_ids.union(preempted_req_ids)
505
for req_id in finished_req_ids:
506
self.req_states.remove_request(req_id)
514
- if self.supports_mm_inputs:
515
- self.encoder_runner.remove_request(req_id)
507
+ if self.encoder_cache is not None:
508
+ self.encoder_cache.remove_request(req_id)
509
self.prompt_logprobs_worker.remove_request(req_id)
510
self.lora_state.remove_request(req_id)
511
512
def free_states(self, scheduler_output: SchedulerOutput) -> None:
520
- if self.supports_mm_inputs:
513
+ if self.encoder_cache is not None:
514
for mm_hash in scheduler_output.free_encoder_mm_hashes:
522
- self.encoder_runner.free_encoder_cache(mm_hash)
515
+ self.encoder_cache.free_encoder_cache(mm_hash)
516
517
def add_requests(self, scheduler_output: SchedulerOutput) -> None:
518
for new_req_data in scheduler_output.scheduled_new_reqs:
@@ -535,8 +528,8 @@ class GPUModelRunner(LoRAModelRunnerMixin):
528
)
529
req_index = self.req_states.req_id_to_index[req_id]
530
538
- if self.supports_mm_inputs:
539
- self.encoder_runner.add_request(req_id, new_req_data.mm_features)
531
+ if self.encoder_cache is not None:
532
+ self.encoder_cache.add_request(req_id, new_req_data.mm_features)
533
534
self.model_state.add_request(req_index, new_req_data)
535
self.block_tables.append_block_ids(
@@ -695,7 +688,6 @@ class GPUModelRunner(LoRAModelRunnerMixin):
688
dcp_local_seq_lens=dcp_local_seq_lens,
689
input_ids=self.input_buffers.input_ids[:num_tokens_after_padding],
690
positions=self.input_buffers.positions[:num_tokens_after_padding],
698
- inputs_embeds=None,
691
logits_indices=logits_indices,
692
cu_num_logits=cu_num_logits,
693
cu_num_logits_np=cu_num_logits_np,
@@ -724,26 +716,6 @@ class GPUModelRunner(LoRAModelRunnerMixin):
716
)
717
return block_tables, slot_mappings
718
727
- @torch.inference_mode()
728
- def get_mm_embeddings(
729
- self,
730
- scheduled_encoder_inputs: dict[str, list[int]],
731
- input_batch: InputBatch,
732
- ) -> tuple[list[torch.Tensor], torch.Tensor]:
733
- mm_hashes, mm_kwargs = self.encoder_runner.prepare_mm_inputs(
734
- scheduled_encoder_inputs
735
- )
736
- self.encoder_runner.execute_mm_encoder(self.model, mm_hashes, mm_kwargs)
737
- mm_embeds, is_mm_embed = self.encoder_runner.gather_mm_embeddings(
738
- input_batch.req_ids,
739
- input_batch.num_tokens,
740
- input_batch.num_scheduled_tokens,
741
- input_batch.query_start_loc_np,
742
- self.req_states.prefill_len.np[input_batch.idx_mapping_np],
743
- self.req_states.num_computed_prefill_tokens[input_batch.idx_mapping_np],
744
- )
745
- return mm_embeds, is_mm_embed
746
-
719
def sample(
720
self,
721
hidden_states: torch.Tensor,
@@ -890,18 +862,6 @@ class GPUModelRunner(LoRAModelRunnerMixin):
862
input_batch.num_scheduled_tokens,
863
)
864
self._set_active_loras(*lora_inputs)
893
-
894
- # Only first PP rank prepares multimodal embeddings.
895
- if self.supports_mm_inputs and self.is_first_pp_rank:
896
- mm_embeds, is_mm_embed = self.get_mm_embeddings(
897
- scheduler_output.scheduled_encoder_inputs, input_batch
898
- )
899
- inputs_embeds = self.encoder_runner.get_inputs_embeds(
900
- self.model, input_batch.input_ids, mm_embeds, is_mm_embed
901
- )
902
- input_batch.inputs_embeds = inputs_embeds[
903
- : input_batch.num_tokens_after_padding
904
- ]
865
else:
866
# No actual tokens to run. A dummy run for DP or memory profiling.
867
num_reqs = min(num_tokens_after_padding, self.max_num_reqs)
@@ -934,10 +894,20 @@ class GPUModelRunner(LoRAModelRunnerMixin):
894
self.kv_cache_config,
895
)
896
897
+ inputs_embeds = None
898
+ if self.supports_mm_inputs and self.is_first_pp_rank and not dummy_run:
899
+ # Run MM encoder (if needed) and get multimodal embeddings.
900
+ # Only first PP rank prepares multimodal embeddings.
901
+ inputs_embeds = self.model_state.get_mm_embeddings(
902
+ scheduler_output.scheduled_encoder_inputs,
903
+ input_batch,
904
+ self.req_states,
905
+ )
906
+
907
model_inputs = {
908
"input_ids": input_batch.input_ids,
909
"positions": input_batch.positions,
940
- "inputs_embeds": input_batch.inputs_embeds,
910
+ "inputs_embeds": inputs_embeds,
911
# NOTE: Values returned by `prepare_inputs` will override the default
912
# values above.
913
**self.model_state.prepare_inputs(input_batch, self.req_states),
vllm/v1/worker/gpu/model_states.py
+53
-5
@@ -10,22 +10,43 @@ from vllm.v1.core.sched.output import NewRequestData
10
from vllm.v1.kv_cache_interface import KVCacheConfig
11
from vllm.v1.worker.gpu.attn_utils import build_attn_metadata
12
from vllm.v1.worker.gpu.input_batch import InputBatch
13
+from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
14
+from vllm.v1.worker.gpu.mm.encoder_runner import EncoderRunner
15
from vllm.v1.worker.gpu.mm.mrope_utils import MRopeState
16
from vllm.v1.worker.gpu.states import RequestState
17
from vllm.v1.worker.utils import AttentionGroup
18
19
20
class ModelState:
19
- def __init__(self, vllm_config: VllmConfig, model: nn.Module, device: torch.device):
21
+ def __init__(
22
+ self,
23
+ vllm_config: VllmConfig,
24
+ model: nn.Module,
25
+ encoder_cache: EncoderCache | None,
26
+ device: torch.device,
27
+ ):
28
self.vllm_config = vllm_config
29
self.model_config = vllm_config.model_config
30
self.scheduler_config = vllm_config.scheduler_config
31
self.model = model
32
self.device = device
33
34
+ self.supports_mm_inputs = encoder_cache is not None
35
self.max_model_len = self.model_config.max_model_len
36
self.max_num_reqs = self.scheduler_config.max_num_seqs
37
self.max_num_tokens = self.scheduler_config.max_num_batched_tokens
38
+ self.inputs_embeds_size = self.model_config.get_inputs_embeds_size()
39
+ self.dtype = self.model_config.dtype
40
+
41
+ if self.supports_mm_inputs:
42
+ assert encoder_cache is not None
43
+ self.encoder_runner = EncoderRunner(
44
+ max_num_tokens=self.max_num_tokens,
45
+ hidden_size=self.inputs_embeds_size,
46
+ encoder_cache=encoder_cache,
47
+ dtype=self.dtype,
48
+ device=self.device,
49
+ )
50
51
self.uses_mrope = self.model_config.uses_mrope
52
if self.uses_mrope:
@@ -51,6 +72,29 @@ class ModelState:
72
if self.uses_mrope:
73
self.mrope_state.apply_staged_writes()
74
75
+ def get_mm_embeddings(
76
+ self,
77
+ scheduled_encoder_inputs: dict[str, list[int]],
78
+ input_batch: InputBatch,
79
+ req_states: RequestState,
80
+ ) -> torch.Tensor:
81
+ mm_hashes, mm_kwargs = self.encoder_runner.prepare_mm_inputs(
82
+ scheduled_encoder_inputs
83
+ )
84
+ self.encoder_runner.execute_mm_encoder(self.model, mm_hashes, mm_kwargs)
85
+ mm_embeds, is_mm_embed = self.encoder_runner.gather_mm_embeddings(
86
+ input_batch.req_ids,
87
+ input_batch.num_tokens,
88
+ input_batch.num_scheduled_tokens,
89
+ input_batch.query_start_loc_np,
90
+ req_states.prefill_len.np[input_batch.idx_mapping_np],
91
+ req_states.num_computed_prefill_tokens[input_batch.idx_mapping_np],
92
+ )
93
+ inputs_embeds = self.encoder_runner.get_inputs_embeds(
94
+ self.model, input_batch.input_ids, mm_embeds, is_mm_embed
95
+ )
96
+ return inputs_embeds[: input_batch.num_tokens_after_padding]
97
+
98
def prepare_inputs(
99
self, input_batch: InputBatch, req_states: RequestState
100
) -> dict[str, torch.Tensor | None]:
@@ -73,10 +117,14 @@ class ModelState:
117
def prepare_dummy_inputs(
118
self, num_reqs: int, num_tokens: int
119
) -> dict[str, torch.Tensor | None]:
76
- if not self.uses_mrope:
77
- return {}
78
- mrope_positions = self.mrope_state.mrope_positions[:, :num_tokens]
79
- return {"positions": mrope_positions}
120
+ model_inputs = {}
121
+ if self.supports_mm_inputs:
122
+ inputs_embeds = self.encoder_runner.inputs_embeds[:num_tokens]
123
+ model_inputs["inputs_embeds"] = inputs_embeds
124
+ if self.uses_mrope:
125
+ mrope_positions = self.mrope_state.mrope_positions[:, :num_tokens]
126
+ model_inputs["positions"] = mrope_positions
127
+ return model_inputs
128
129
def prepare_attn(
130
self,
vllm/v1/worker/gpu/spec_decode/eagle/speculator.py
-1
@@ -44,7 +44,6 @@ class EagleSpeculator:
44
# the draft model's hidden size can be different from the target model's
45
# hidden size (e.g., Llama 3.3 70B).
46
self.hidden_size = self.draft_model_config.get_hidden_size()
47
- self.inputs_embeds_size = self.draft_model_config.get_inputs_embeds_size()
47
self.vocab_size = self.draft_model_config.get_vocab_size()
48
self.dtype = vllm_config.model_config.dtype
49