[ModelRunnerV2] Support prompt embeds (#42963)
Signed-off-by: gcanlin <canlinguosdu@gmail.com> Signed-off-by: Canlin Guo <961750412@qq.com> Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Nick Hill <nickhill123@gmail.com>
Canlin Guo committed
Aug 18, 2026 at 05:58 UTC
455edc022b45bbb5a279cff21cd3a34e6371aadf
17 files changed
+551
-54
tests/v1/worker/test_encoder_runner.py
+90
-5
@@ -15,7 +15,13 @@ import numpy as np
15
import pytest
16
import torch
17
18
-from vllm.multimodal.inputs import MultiModalFeatureSpec, PlaceholderRange
18
+from vllm.multimodal.inputs import (
19
+ MultiModalFeatureSpec,
20
+ MultiModalFieldElem,
21
+ MultiModalKwargsItem,
22
+ MultiModalSharedField,
23
+ PlaceholderRange,
24
+)
25
from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
26
from vllm.v1.worker.gpu.mm.encoder_runner import EncoderRunner
27
from vllm.v1.worker.gpu.model_states.interface import ModelState
@@ -25,6 +31,25 @@ pytestmark = pytest.mark.cpu_test
31
HIDDEN = 4
32
33
34
+def _model_state(cache: EncoderCache) -> MagicMock:
35
+ """A mock ModelState backed by a real EncoderCache."""
36
+ state = MagicMock()
37
+ state.encoder_cache = cache
38
+ state.device = torch.device("cpu")
39
+ return state
40
+
41
+
42
+def _embeds_item(embeds: torch.Tensor) -> MultiModalKwargsItem:
43
+ """A `prompt_embeds` kwargs item, as the HF renderer builds it."""
44
+ return MultiModalKwargsItem(
45
+ {
46
+ "embedding": MultiModalFieldElem(
47
+ data=embeds, field=MultiModalSharedField(batch_size=1)
48
+ )
49
+ }
50
+ )
51
+
52
+
53
def _feature(identifier: str, offset: int, length: int) -> MultiModalFeatureSpec:
54
return MultiModalFeatureSpec(
55
data=None,
@@ -197,8 +222,7 @@ def test_execute_mm_encoder_caches_outputs_without_gathering():
222
items the connector already holds, and a producer has no load path).
223
"""
224
cache = EncoderCache()
200
- state = MagicMock()
201
- state.encoder_cache = cache
225
+ state = _model_state(cache)
226
embedding = torch.ones(2, HIDDEN)
227
# (mm_hashes, [(modality, kwargs item), ...]), as prepare_mm_inputs returns.
228
state.encoder_runner.prepare_mm_inputs.return_value = (
@@ -216,8 +240,7 @@ def test_execute_mm_encoder_caches_outputs_without_gathering():
240
def test_execute_mm_encoder_is_a_noop_without_scheduled_items():
241
"""A step that schedules no encoder input must not touch the encoder."""
242
cache = EncoderCache()
219
- state = MagicMock()
220
- state.encoder_cache = cache
243
+ state = _model_state(cache)
244
state.encoder_runner.prepare_mm_inputs.return_value = ([], [])
245
246
ModelState.execute_mm_encoder(state, {})
@@ -226,6 +249,68 @@ def test_execute_mm_encoder_is_a_noop_without_scheduled_items():
249
state.encoder_runner.execute_mm_encoder.assert_not_called()
250
251
252
+def _pe_feature(identifier: str, embeds: torch.Tensor, offset: int = 0):
253
+ return MultiModalFeatureSpec(
254
+ data=_embeds_item(embeds),
255
+ modality="prompt_embeds",
256
+ identifier=identifier,
257
+ mm_position=PlaceholderRange(offset=offset, length=embeds.shape[0]),
258
+ )
259
+
260
+
261
+def test_prepare_mm_inputs_passes_prompt_embeds_through():
262
+ """`prompt_embeds` is already in embedding space, so no encoder may run.
263
+
264
+ The renderer delivers prompt_embeds mixed with real media as an ordinary MM
265
+ modality. prepare_mm_inputs must cache the tensor directly and keep it out
266
+ of the encoder batch -- the vision encoder cannot consume it, and a missing
267
+ cache entry makes the subsequent gather raise "Encoder cache miss".
268
+ """
269
+ prompt_embeds = torch.arange(2 * HIDDEN, dtype=torch.float32).view(2, HIDDEN)
270
+ image_feature = MultiModalFeatureSpec(
271
+ data=MagicMock(),
272
+ modality="image",
273
+ identifier="hash_img",
274
+ mm_position=PlaceholderRange(offset=2, length=2),
275
+ )
276
+ runner = _make_runner(
277
+ [_pe_feature("hash_pe", prompt_embeds), image_feature], cached=[]
278
+ )
279
+
280
+ mm_hashes, mm_kwargs = runner.prepare_mm_inputs({"req0": [0, 1]})
281
+
282
+ # Only the image remains for the encoder; the embeds are already cached.
283
+ assert mm_hashes == ["hash_img"]
284
+ assert [modality for modality, _ in mm_kwargs] == ["image"]
285
+ assert torch.equal(runner.encoder_cache.encoder_outputs["hash_pe"], prompt_embeds)
286
+
287
+
288
+def test_prepare_mm_inputs_skips_cached_prompt_embeds():
289
+ """A prompt_embeds item already in the cache must not be re-uploaded."""
290
+ prompt_embeds = torch.ones(3, HIDDEN)
291
+ feature = _pe_feature("hash_pe", prompt_embeds)
292
+ runner = _make_runner([feature], cached=[feature])
293
+ sentinel = runner.encoder_cache.encoder_outputs["hash_pe"]
294
+
295
+ mm_hashes, mm_kwargs = runner.prepare_mm_inputs({"req0": [0]})
296
+
297
+ assert mm_hashes == [] and mm_kwargs == []
298
+ assert runner.encoder_cache.encoder_outputs["hash_pe"] is sentinel
299
+
300
+
301
+def test_execute_mm_encoder_skips_encoder_for_prompt_embeds_only():
302
+ """A batch of nothing but prompt_embeds must not invoke the encoder."""
303
+ prompt_embeds = torch.ones(3, HIDDEN)
304
+ runner = _make_runner([_pe_feature("hash_pe", prompt_embeds)], cached=[])
305
+ state = _model_state(runner.encoder_cache)
306
+ state.encoder_runner.prepare_mm_inputs.side_effect = runner.prepare_mm_inputs
307
+
308
+ ModelState.execute_mm_encoder(state, {"req0": [0]})
309
+
310
+ state.encoder_runner.execute_mm_encoder.assert_not_called()
311
+ assert torch.equal(runner.encoder_cache.encoder_outputs["hash_pe"], prompt_embeds)
312
+
313
+
314
def test_encoder_timing_stats_registry():
315
runner = _make_runner([], [])
316
runner.enable_timing = True
tests/v1/worker/test_gpu_model_runner.py
+10
@@ -68,6 +68,16 @@ NUM_BLOCKS = 10
68
DEVICE_TYPE = current_platform.device_type
69
70
71
+@pytest.fixture(autouse=True)
72
+def _restore_default_dtype():
73
+ """Several tests here set the process-wide default dtype to float16 and
74
+ previously leaked it, corrupting later float-sensitive tests in the same
75
+ pytest process (torch.randn silently produced fp16)."""
76
+ old = torch.get_default_dtype()
77
+ yield
78
+ torch.set_default_dtype(old)
79
+
80
+
81
def initialize_kv_cache(runner: GPUModelRunner):
82
"""
83
Only perform necessary steps in GPUModelRunner.initialize_kv_cache()
tests/v1/worker/test_prompt_embeds_state.py
new
+149
@@ -0,0 +1,149 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Tests for the Model Runner V2 prompt-embeds overlay (PromptEmbedsState).
4
+
5
+The overlay kernel reads each request's GPU-resident prompt embeddings through
6
+a per-request pointer table and writes the rows scheduled this step into
7
+`inputs_embeds`, honoring chunked prefill (`num_computed_tokens` offset), the
8
+prompt/decode boundary (rows past the embeds length untouched), and the
9
+mixed-mode `prompt_is_token_ids` mask (token-id rows keep the base embedding).
10
+"""
11
+
12
+from dataclasses import dataclass
13
+
14
+import pytest
15
+import torch
16
+
17
+pytest.importorskip("triton")
18
+if not torch.cuda.is_available():
19
+ pytest.skip(
20
+ "CUDA required for prompt-embeds overlay tests", allow_module_level=True
21
+ )
22
+
23
+from vllm.v1.worker.gpu.model_states.prompt_embeds import PromptEmbedsState
24
+
25
+HIDDEN = 24
26
+MAX_NUM_REQS = 8
27
+DEVICE = torch.device("cuda")
28
+
29
+
30
+@dataclass
31
+class _NewReqData:
32
+ req_id: str
33
+ prompt_embeds: torch.Tensor | None
34
+ prompt_is_token_ids: list[bool] | None = None
35
+
36
+
37
+@dataclass
38
+class _Batch:
39
+ num_reqs: int
40
+ num_scheduled_tokens: torch.Tensor # np-like, only .max() is used
41
+ idx_mapping: torch.Tensor
42
+ query_start_loc: torch.Tensor
43
+
44
+
45
+def _make_state() -> PromptEmbedsState:
46
+ return PromptEmbedsState(MAX_NUM_REQS, HIDDEN, torch.float32, DEVICE)
47
+
48
+
49
+def _batch(num_scheduled: list[int], idx_mapping: list[int]) -> _Batch:
50
+ query_start_loc = [0]
51
+ for n in num_scheduled:
52
+ query_start_loc.append(query_start_loc[-1] + n)
53
+ return _Batch(
54
+ num_reqs=len(num_scheduled),
55
+ num_scheduled_tokens=torch.tensor(num_scheduled, dtype=torch.int32),
56
+ idx_mapping=torch.tensor(idx_mapping, dtype=torch.int64, device=DEVICE),
57
+ query_start_loc=torch.tensor(query_start_loc, dtype=torch.int32, device=DEVICE),
58
+ )
59
+
60
+
61
+def _apply(
62
+ state: PromptEmbedsState,
63
+ batch: _Batch,
64
+ num_computed: list[int],
65
+ num_tokens: int,
66
+) -> tuple[torch.Tensor, torch.Tensor]:
67
+ """Run the overlay on a fresh base buffer; return (result, base)."""
68
+ num_computed_tokens = torch.zeros(MAX_NUM_REQS, dtype=torch.int32, device=DEVICE)
69
+ for batch_idx, req_index in enumerate(batch.idx_mapping.tolist()):
70
+ num_computed_tokens[req_index] = num_computed[batch_idx]
71
+ base = torch.randn(num_tokens, HIDDEN, dtype=torch.float32, device=DEVICE)
72
+ inputs_embeds = base.clone()
73
+ state.apply(batch, num_computed_tokens, inputs_embeds)
74
+ torch.accelerator.synchronize()
75
+ return inputs_embeds, base
76
+
77
+
78
+def test_overlay_chunked_prefill_and_decode():
79
+ """Rows within the embeds range come from prompt_embeds at the
80
+ num_computed offset; requests without embeds and requests past their
81
+ embeds length (decode) keep the base embedding."""
82
+ state = _make_state()
83
+ embeds_a = torch.randn(6, HIDDEN, dtype=torch.float32)
84
+ embeds_b = torch.randn(5, HIDDEN, dtype=torch.float32)
85
+ state.add_request(0, _NewReqData("a", embeds_a))
86
+ state.add_request(1, _NewReqData("b", embeds_b))
87
+ state.add_request(2, _NewReqData("c", None))
88
+ state.apply_staged_writes()
89
+
90
+ # a: chunk [2, 6) of its embeds; b: fully decoded; c: no embeds.
91
+ batch = _batch(num_scheduled=[4, 1, 3], idx_mapping=[0, 1, 2])
92
+ out, base = _apply(state, batch, num_computed=[2, 7, 1], num_tokens=8)
93
+
94
+ torch.testing.assert_close(out[0:4], embeds_a[2:6].to(DEVICE))
95
+ torch.testing.assert_close(out[4:8], base[4:8])
96
+
97
+
98
+def test_overlay_clamps_to_embeds_length():
99
+ """A window straddling the end of the prompt embeds writes only the
100
+ in-range rows (e.g. final prefill chunk + sampled token)."""
101
+ state = _make_state()
102
+ embeds = torch.randn(4, HIDDEN, dtype=torch.float32)
103
+ state.add_request(3, _NewReqData("a", embeds))
104
+ state.apply_staged_writes()
105
+
106
+ batch = _batch(num_scheduled=[3], idx_mapping=[3])
107
+ out, base = _apply(state, batch, num_computed=[2], num_tokens=3)
108
+
109
+ torch.testing.assert_close(out[0:2], embeds[2:4].to(DEVICE))
110
+ torch.testing.assert_close(out[2:3], base[2:3])
111
+
112
+
113
+def test_overlay_respects_is_token_ids_mask():
114
+ """Mixed mode: positions marked as real token ids keep the base
115
+ embedding; only embed positions are overwritten."""
116
+ state = _make_state()
117
+ embeds = torch.randn(5, HIDDEN, dtype=torch.float32)
118
+ is_token_ids = [True, False, False, True, False]
119
+ state.add_request(0, _NewReqData("a", embeds, is_token_ids))
120
+ state.apply_staged_writes()
121
+
122
+ batch = _batch(num_scheduled=[5], idx_mapping=[0])
123
+ out, base = _apply(state, batch, num_computed=[0], num_tokens=5)
124
+
125
+ embeds_gpu = embeds.to(DEVICE)
126
+ for pos, is_token in enumerate(is_token_ids):
127
+ expected = base[pos] if is_token else embeds_gpu[pos]
128
+ torch.testing.assert_close(out[pos], expected)
129
+
130
+
131
+def test_index_reuse_clears_stale_entry():
132
+ """A request added at a previously-used index without embeds must not
133
+ inherit the prior occupant's pointer-table entry."""
134
+ state = _make_state()
135
+ state.add_request(0, _NewReqData("a", torch.randn(4, HIDDEN, dtype=torch.float32)))
136
+ # A second live embeds request so the kernel actually launches (the
137
+ # overlay is skipped entirely when no request holds embeds).
138
+ other = torch.randn(2, HIDDEN, dtype=torch.float32)
139
+ state.add_request(1, _NewReqData("other", other))
140
+ state.apply_staged_writes()
141
+ state.remove_request("a")
142
+ state.add_request(0, _NewReqData("b", None))
143
+ state.apply_staged_writes()
144
+
145
+ batch = _batch(num_scheduled=[2, 2], idx_mapping=[0, 1])
146
+ out, base = _apply(state, batch, num_computed=[0, 0], num_tokens=4)
147
+
148
+ torch.testing.assert_close(out[0:2], base[0:2])
149
+ torch.testing.assert_close(out[2:4], other.to(DEVICE))
vllm/config/model.py
+6
@@ -926,6 +926,12 @@ class ModelConfig:
926
f"got {type(self.max_model_len).__name__}: {self.max_model_len!r}. "
927
"Example: max_model_len=2048"
928
)
929
+ if self.enable_prompt_embeds and self.is_encoder_decoder:
930
+ # No encoder-decoder model accepts `inputs_embeds`; their decoders
931
+ # embed `input_ids` internally.
932
+ raise ValueError(
933
+ "--enable-prompt-embeds is not supported with encoder-decoder models."
934
+ )
935
return self
936
937
def _resolve_mm_device_do_normalize(
vllm/config/vllm.py
-3
@@ -2449,9 +2449,6 @@ class VllmConfig:
2449
):
2450
unsupported.append("custom logits processors")
2451
2452
- if model_config is not None and model_config.enable_prompt_embeds:
2453
- unsupported.append("prompt embeds")
2454
-
2452
if self.cache_config.kv_sharing_fast_prefill:
2453
# Will be added by https://github.com/vllm-project/vllm/pull/35045
2454
unsupported.append("KV sharing fast prefill")
vllm/model_executor/models/diffusion_gemma.py
+2
-2
@@ -776,7 +776,7 @@ class DiffusionGemmaModelState(ModelState):
776
) -> None:
777
super().__init__(vllm_config, model, encoder_cache, device)
778
779
- # Per-step MM data produced by get_mm_embeddings and consumed by
779
+ # Per-step MM data produced by prepare_inputs_embeds and consumed by
780
# prepare_inputs. Stored as raw (mm_embeds, is_mm_embed) so that
781
# prepare_inputs can call embed_input_ids directly into the
782
# persistent _inputs_embeds_buf, avoiding the intermediate copy
@@ -874,7 +874,7 @@ class DiffusionGemmaModelState(ModelState):
874
if idx is not None:
875
self.diffusion_states.remove_request(idx)
876
877
- def get_mm_embeddings(
877
+ def prepare_inputs_embeds(
878
self,
879
scheduled_encoder_inputs: dict[str, list[int]],
880
input_batch: InputBatch,
vllm/model_executor/models/longcat_flash_ngram.py
+3
@@ -273,6 +273,9 @@ class LongcatFlashNgramForCausalLM(nn.Module, SupportsLoRA, SupportsPP):
273
274
275
class LongcatNgramModelState(DefaultModelState):
276
+ # prepare_inputs builds its own inputs_embeds from n-gram token embeddings.
277
+ supports_prompt_embeds = False
278
+
279
"""Per-request n-gram token history for LongCat-Flash-Lite.
280
281
Maintains a small CPU-side per-slot context (last ``n-1`` processed tokens)
vllm/v1/core/sched/output.py
+8
@@ -68,6 +68,14 @@ class NewRequestData:
68
prefill_token_ids=prefill_token_ids,
69
)
70
71
+ @property
72
+ def prompt_len(self) -> int:
73
+ if self.prompt_token_ids is not None:
74
+ return len(self.prompt_token_ids)
75
+ if self.prompt_embeds is not None:
76
+ return self.prompt_embeds.shape[0]
77
+ return 0
78
+
79
def __repr__(self) -> str:
80
prompt_embeds_shape = (
81
self.prompt_embeds.shape if self.prompt_embeds is not None else None
vllm/v1/request.py
+13
-5
@@ -142,11 +142,19 @@ class Request:
142
prompt_token_ids, prompt_embeds
143
)
144
self._output_token_ids: list[int] = []
145
- self._all_token_ids: list[int] = (
146
- self.prompt_token_ids.copy()
147
- if self.prompt_token_ids is not None
148
- else [0] * self.num_prompt_tokens
149
- )
145
+ if self.prompt_token_ids is None:
146
+ self._all_token_ids: list[int] = [0] * self.num_prompt_tokens
147
+ elif self.prompt_is_token_ids is None:
148
+ self._all_token_ids = self.prompt_token_ids.copy()
149
+ else:
150
+ # Mixed-mode prompt: positions covered by prompt_embeds hold a sentinel
151
+ # special token id that may lie outside the embedding. Zero them, matching
152
+ # the no-token-ids case above, so embedding gathers over these placeholder
153
+ # ids stay in bounds; the actual inputs come from prompt_embeds.
154
+ self._all_token_ids = [
155
+ t if is_tok else 0
156
+ for t, is_tok in zip(self.prompt_token_ids, self.prompt_is_token_ids)
157
+ ]
158
159
# Used in async scheduling.
160
self.num_output_placeholders = 0
vllm/v1/worker/gpu/mm/encoder_runner.py
+12
-1
@@ -18,7 +18,7 @@ from vllm.multimodal.utils import (
18
group_and_batch_mm_kwargs,
19
set_mm_embedding_modality,
20
)
21
-from vllm.utils.torch_utils import PIN_MEMORY
21
+from vllm.utils.torch_utils import PIN_MEMORY, async_tensor_h2d
22
from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
23
from vllm.v1.worker.utils import (
24
EncoderTimingStats,
@@ -91,6 +91,17 @@ class EncoderRunner:
91
continue
92
if mm_feature.identifier in self.encoder_cache.encoder_outputs:
93
continue
94
+ if mm_feature.modality == "prompt_embeds":
95
+ # Passthrough modality: the tensor is already in the
96
+ # model's embedding space, so no encoder runs. Cache it
97
+ # directly so gather_mm_embeddings splices it via the
98
+ # standard is_mm_embed path.
99
+ embeds = mm_feature.data["embedding"].data
100
+ assert isinstance(embeds, torch.Tensor)
101
+ self.encoder_cache.encoder_outputs[mm_feature.identifier] = (
102
+ async_tensor_h2d(embeds, device=self.device)
103
+ )
104
+ continue
105
mm_hashes.append(mm_feature.identifier)
106
mm_kwargs.append((mm_feature.modality, mm_feature.data))
107
vllm/v1/worker/gpu/model_runner.py
+14
-10
@@ -46,6 +46,7 @@ from vllm.model_executor.layers.mamba.ops.ssu_dispatch import (
46
initialize_mamba_ssu_backend,
47
)
48
from vllm.model_executor.model_loader import get_model_loader
49
+from vllm.model_executor.models.interfaces import requires_raw_input_tokens
50
from vllm.model_executor.offloader import (
51
create_offloader,
52
get_offloader,
@@ -231,6 +232,9 @@ class GPUModelRunner(LoRAModelRunnerMixin):
232
self.supports_mm_inputs = self.mm_registry.supports_multimodal_inputs(
233
self.model_config
234
)
235
+ self.uses_inputs_embeds = (
236
+ self.supports_mm_inputs or self.model_config.enable_prompt_embeds
237
+ )
238
self.encoder_cache = None
239
if self.supports_mm_inputs and self.is_first_pp_rank:
240
self.encoder_cache = EncoderCache()
@@ -956,7 +960,6 @@ class GPUModelRunner(LoRAModelRunnerMixin):
960
961
def add_requests(self, scheduler_output: SchedulerOutput) -> None:
962
for new_req_data in scheduler_output.scheduled_new_reqs:
959
- assert new_req_data.prompt_token_ids is not None
963
assert new_req_data.prefill_token_ids is not None
964
req_id = new_req_data.req_id
965
@@ -965,7 +968,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
968
# with the updated prompt_token_ids and mm_features.
969
self._remove_request(req_id)
970
968
- prompt_len = len(new_req_data.prompt_token_ids)
971
+ prompt_len = new_req_data.prompt_len
972
sampling_params = new_req_data.sampling_params
973
self.req_states.add_request(
974
req_id=req_id,
@@ -980,6 +983,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
983
984
if self.pooling_runner is not None:
985
assert new_req_data.pooling_params is not None
986
+ assert new_req_data.prompt_token_ids is not None
987
self.pooling_runner.add_request(
988
req_id,
989
req_index,
@@ -1550,17 +1554,17 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1554
input_ids = input_batch.input_ids
1555
inputs_embeds = None
1556
ec_connector_output = None
1553
- if self.supports_mm_inputs and self.is_first_pp_rank:
1554
- # Run MM encoder (if needed) and get multimodal embeddings.
1555
- # Only first PP rank prepares multimodal embeddings.
1557
+ if self.uses_inputs_embeds and self.is_first_pp_rank:
1558
+ # Prepare inputs_embeds (MM encoder outputs and/or prompt_embeds
1559
+ # overlay). Only first PP rank prepares them.
1560
if dummy_run:
1557
- # Obtain mm embeddings of correct shape for compiled model.
1561
+ # Obtain embeddings of correct shape for compiled model.
1562
inputs_embeds = self.model_state.dummy_inputs_embeds(
1563
input_batch.num_tokens_after_padding
1564
)
1565
else:
1566
scheduled_encoder_inputs = scheduler_output.scheduled_encoder_inputs
1563
- if self.lora_config is not None:
1567
+ if self.supports_mm_inputs and self.lora_config is not None:
1568
set_active_mm_loras(
1569
model=self.model,
1570
lora_manager=self.lora_manager,
@@ -1574,16 +1578,16 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1578
) as ec_connector_output:
1579
if self.is_encoder_only:
1580
# Encode and publish, nothing else: this instance runs no
1577
- # language model, so the gather inside get_mm_embeddings
1581
+ # language model, so the gather inside prepare_inputs_embeds
1582
# would build an inputs_embeds nobody reads -- and it
1583
# raises "Encoder cache miss" for any scheduled item this
1584
# instance did not encode, taking the engine down with it.
1585
self.model_state.execute_mm_encoder(scheduled_encoder_inputs)
1586
else:
1583
- inputs_embeds = self.model_state.get_mm_embeddings(
1587
+ inputs_embeds = self.model_state.prepare_inputs_embeds(
1588
scheduled_encoder_inputs, input_batch, self.req_states
1589
)
1586
- if inputs_embeds is not None and not self.model.requires_raw_input_tokens:
1590
+ if inputs_embeds is not None and not requires_raw_input_tokens(self.model):
1591
input_ids = None
1592
1593
if self.is_encoder_only:
vllm/v1/worker/gpu/model_states/__init__.py
+19
-7
@@ -7,6 +7,7 @@ from vllm.config import VllmConfig, get_layers_from_vllm_config
7
from vllm.model_executor.layers.attention import Attention, CrossAttention
8
from vllm.v1.attention.backend import AttentionType
9
from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
10
+from vllm.v1.worker.gpu.model_states.interface import ModelState
11
12
13
def init_model_state(
@@ -14,11 +15,22 @@ def init_model_state(
15
model: nn.Module,
16
encoder_cache: EncoderCache | None,
17
device: torch.device,
17
-):
18
+) -> ModelState:
19
+ cls = resolve_model_state_cls(vllm_config, model)
20
+
21
+ # Reject enable_prompt_embeds for states that would silently ignore it.
22
+ if vllm_config.model_config.enable_prompt_embeds and not cls.supports_prompt_embeds:
23
+ raise ValueError(f"--enable-prompt-embeds not supported with {cls.__name__}.")
24
+
25
+ return cls(vllm_config, model, encoder_cache, device)
26
+
27
+
28
+def resolve_model_state_cls(
29
+ vllm_config: VllmConfig, model: nn.Module
30
+) -> type[ModelState]:
31
# Let the model provide its own ModelState if it defines one.
32
if hasattr(model, "get_model_state_cls"):
20
- cls = model.get_model_state_cls()
21
- return cls(vllm_config, model, encoder_cache, device)
33
+ return model.get_model_state_cls()
34
35
# Cross-attention encoder-decoder models (Whisper, CohereASR, NemotronParse, ...)
36
if any(isinstance(m, CrossAttention) for m in model.modules()):
@@ -26,7 +38,7 @@ def init_model_state(
38
EncoderDecoderModelState,
39
)
40
29
- return EncoderDecoderModelState(vllm_config, model, encoder_cache, device)
41
+ return EncoderDecoderModelState
42
43
# Encoder-only attention is non-causal and needs no KV cache.
44
if any(
@@ -35,13 +47,13 @@ def init_model_state(
47
):
48
from vllm.v1.worker.gpu.model_states.encoder_only import EncoderOnlyModelState
49
38
- return EncoderOnlyModelState(vllm_config, model, encoder_cache, device)
50
+ return EncoderOnlyModelState
51
52
if vllm_config.model_config.is_hybrid or vllm_config.model_config.is_attention_free:
53
from vllm.v1.worker.gpu.model_states.mamba_hybrid import MambaHybridModelState
54
43
- return MambaHybridModelState(vllm_config, model, encoder_cache, device)
55
+ return MambaHybridModelState
56
57
from vllm.v1.worker.gpu.model_states.default import DefaultModelState
58
47
- return DefaultModelState(vllm_config, model, encoder_cache, device)
59
+ return DefaultModelState
vllm/v1/worker/gpu/model_states/default.py
+59
-18
@@ -18,11 +18,14 @@ from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
18
from vllm.v1.worker.gpu.mm.rope import get_rope_state
19
from vllm.v1.worker.gpu.model_states.interface import ModelState
20
from vllm.v1.worker.gpu.model_states.mm_pruning import maybe_create_mm_pruner
21
+from vllm.v1.worker.gpu.model_states.prompt_embeds import PromptEmbedsState
22
from vllm.v1.worker.gpu.states import RequestState
23
from vllm.v1.worker.utils import AttentionGroup
24
25
26
class DefaultModelState(ModelState):
27
+ supports_prompt_embeds = True
28
+
29
def __init__(
30
self,
31
vllm_config: VllmConfig,
@@ -32,6 +35,18 @@ class DefaultModelState(ModelState):
35
):
36
super().__init__(vllm_config, model, encoder_cache, device)
37
38
+ self.prompt_embeds_state: PromptEmbedsState | None = None
39
+ if self.model_config.enable_prompt_embeds:
40
+ self.prompt_embeds_state = PromptEmbedsState(
41
+ self.max_num_reqs, self.inputs_embeds_size, self.dtype, self.device
42
+ )
43
+ if not self.supports_mm_inputs:
44
+ # Persistent buffer analogous to encoder_runner.inputs_embeds.
45
+ embeds_buffer_size = (self.max_num_tokens, self.inputs_embeds_size)
46
+ self.inputs_embeds = torch.zeros(
47
+ embeds_buffer_size, dtype=self.dtype, device=self.device
48
+ )
49
+
50
self.rope_state = get_rope_state(
51
self.model_config,
52
model,
@@ -49,42 +64,69 @@ class DefaultModelState(ModelState):
64
def add_request(self, req_index: int, new_req_data: NewRequestData) -> None:
65
if self.rope_state is not None:
66
assert new_req_data.prefill_token_ids is not None
67
+ # `prompt_embeds` is a passthrough modality with no grid info, but
68
+ # M-RoPE assumes per-feature grids. Filter it out.
69
+ mm_features = [
70
+ f for f in new_req_data.mm_features if f.modality != "prompt_embeds"
71
+ ]
72
self.rope_state.init_prefill_positions(
73
req_index,
74
self.model,
75
new_req_data.prefill_token_ids,
56
- mm_features=new_req_data.mm_features,
76
+ mm_features=mm_features,
77
)
78
+ if self.prompt_embeds_state is not None:
79
+ self.prompt_embeds_state.add_request(req_index, new_req_data)
80
+
81
+ def remove_request(self, req_id: str) -> None:
82
+ if self.prompt_embeds_state is not None:
83
+ self.prompt_embeds_state.remove_request(req_id)
84
85
def apply_staged_writes(self) -> None:
86
if self.rope_state is not None:
87
self.rope_state.apply_staged_writes()
88
+ if self.prompt_embeds_state is not None:
89
+ self.prompt_embeds_state.apply_staged_writes()
90
91
def dummy_inputs_embeds(self, num_tokens: int) -> torch.Tensor:
92
"""Pre-allocated inputs_embeds buffer for dummy runs (contents unused)."""
65
- return self.encoder_runner.inputs_embeds[:num_tokens]
93
+ if self.supports_mm_inputs:
94
+ return self.encoder_runner.inputs_embeds[:num_tokens]
95
+ return self.inputs_embeds[:num_tokens]
96
67
- def get_mm_embeddings(
97
+ def prepare_inputs_embeds(
98
self,
99
scheduled_encoder_inputs: dict[str, list[int]],
100
input_batch: InputBatch,
101
req_states: RequestState,
102
) -> torch.Tensor:
73
- self.execute_mm_encoder(scheduled_encoder_inputs)
74
-
75
- mm_embeds, is_mm_embed = super().gather_mm_embeddings(input_batch)
76
- if self.mm_pruner is not None and mm_embeds:
77
- # EVS: recompute mrope positions for pruned media.
78
- mm_embeds = self.mm_pruner.recompute(mm_embeds, input_batch, req_states)
79
- # We must flush the staged rope updates for prepare_inputs() to pick up.
80
- self.apply_staged_writes()
81
-
103
# Use unpadded input_ids to match is_mm_embed size (num_tokens).
104
# input_batch.input_ids may be padded for CUDA graphs.
105
input_ids_unpadded = input_batch.input_ids[: input_batch.num_tokens]
85
- inputs_embeds = self.encoder_runner.get_inputs_embeds(
86
- input_ids_unpadded, mm_embeds, is_mm_embed
87
- )
106
+
107
+ if self.supports_mm_inputs:
108
+ self.execute_mm_encoder(scheduled_encoder_inputs)
109
+
110
+ mm_embeds, is_mm_embed = super().gather_mm_embeddings(input_batch)
111
+ if self.mm_pruner is not None and mm_embeds:
112
+ # EVS: recompute mrope positions for pruned media.
113
+ mm_embeds = self.mm_pruner.recompute(mm_embeds, input_batch, req_states)
114
+ # We must flush the staged rope updates for prepare_inputs() to pick up.
115
+ self.apply_staged_writes()
116
+
117
+ inputs_embeds = self.encoder_runner.get_inputs_embeds(
118
+ input_ids_unpadded, mm_embeds, is_mm_embed
119
+ )
120
+ else:
121
+ input_embeddings = self.model.embed_input_ids(input_ids_unpadded)
122
+ self.inputs_embeds[: input_embeddings.shape[0]] = input_embeddings
123
+ inputs_embeds = self.inputs_embeds
124
+
125
+ if self.prompt_embeds_state is not None:
126
+ self.prompt_embeds_state.apply(
127
+ input_batch, req_states.num_computed_tokens.gpu, inputs_embeds
128
+ )
129
+
130
return inputs_embeds[: input_batch.num_tokens_after_padding]
131
132
def gather_mm_embeddings(
@@ -115,9 +157,8 @@ class DefaultModelState(ModelState):
157
158
def prepare_dummy_inputs(self, num_reqs: int, num_tokens: int) -> dict[str, Any]:
159
model_inputs = {}
118
- if self.supports_mm_inputs:
119
- inputs_embeds = self.encoder_runner.inputs_embeds[:num_tokens]
120
- model_inputs["inputs_embeds"] = inputs_embeds
160
+ if self.supports_mm_inputs or self.prompt_embeds_state is not None:
161
+ model_inputs["inputs_embeds"] = self.dummy_inputs_embeds(num_tokens)
162
if self.rope_state is not None:
163
model_inputs["positions"] = self.rope_state.get_positions(num_tokens)
164
return model_inputs
vllm/v1/worker/gpu/model_states/encoder_decoder.py
+5
-1
@@ -54,6 +54,10 @@ class EncoderDecoderModelState(ModelState):
54
device: torch.device,
55
) -> None:
56
assert encoder_cache is not None
57
+ if vllm_config.model_config.enable_prompt_embeds:
58
+ raise ValueError(
59
+ "--enable-prompt-embeds is not supported with encoder-decoder models."
60
+ )
61
super().__init__(vllm_config, model, encoder_cache, device)
62
63
self.max_encoder_len = getattr(
@@ -67,7 +71,7 @@ class EncoderDecoderModelState(ModelState):
71
72
self.encoder_outputs: list[torch.Tensor] = []
73
70
- def get_mm_embeddings(
74
+ def prepare_inputs_embeds(
75
self,
76
scheduled_encoder_inputs: dict[str, list[int]],
77
input_batch: InputBatch,
vllm/v1/worker/gpu/model_states/encoder_only.py
+3
@@ -34,6 +34,9 @@ class EncoderOnlyModelState(DefaultModelState):
34
the normal KV-backed path untouched.
35
"""
36
37
+ # The V2 pooling path is not wired for prompt embeds.
38
+ supports_prompt_embeds = False
39
+
40
def __init__(
41
self,
42
vllm_config: VllmConfig,
vllm/v1/worker/gpu/model_states/interface.py
+6
-2
@@ -1,7 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
from abc import ABC, abstractmethod
4
-from typing import Any, cast
4
+from typing import Any, ClassVar, cast
5
6
import torch
7
import torch.nn as nn
@@ -43,6 +43,9 @@ class ModelSpecificAttnMetadata:
43
44
45
class ModelState(ABC):
46
+ supports_prompt_embeds: ClassVar[bool] = False
47
+ """Whether this state implements user-provided prompt embeddings."""
48
+
49
def __init__(
50
self,
51
vllm_config: VllmConfig,
@@ -153,12 +156,13 @@ class ModelState(ABC):
156
return None
157
158
@abstractmethod
156
- def get_mm_embeddings(
159
+ def prepare_inputs_embeds(
160
self,
161
scheduled_encoder_inputs: dict[str, list[int]],
162
input_batch: InputBatch,
163
req_states: RequestState,
164
) -> torch.Tensor | None:
165
+ """Prepare the ``inputs_embeds`` tensor for the current forward pass."""
166
raise NotImplementedError
167
168
def dummy_inputs_embeds(self, num_tokens: int) -> torch.Tensor | None:
vllm/v1/worker/gpu/model_states/prompt_embeds.py
new
+152
@@ -0,0 +1,152 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+import torch
4
+
5
+from vllm.triton_utils import tl, triton
6
+from vllm.utils.torch_utils import async_tensor_h2d
7
+from vllm.v1.core.sched.output import NewRequestData
8
+from vllm.v1.worker.gpu.buffer_utils import UvaBackedTensor
9
+from vllm.v1.worker.gpu.input_batch import InputBatch
10
+
11
+TOKEN_BLOCK = 16
12
+
13
+
14
+class PromptEmbedsState:
15
+ """GPU-side state for user-provided prompt embeddings.
16
+
17
+ Each request's embeddings are copied to the GPU once at `add_request`,
18
+ off the per-step hot path. A per-request pointer table (UVA) then lets a
19
+ single triton kernel overlay all scheduled prompt-embeds rows onto
20
+ `inputs_embeds` each step, with no python loops or per-request H2D copies.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ max_num_reqs: int,
26
+ hidden_size: int,
27
+ dtype: torch.dtype,
28
+ device: torch.device,
29
+ ):
30
+ self.hidden_size = hidden_size
31
+ self.dtype = dtype
32
+ self.device = device
33
+
34
+ # req_id -> (embeds, is_token_ids mask or None). Holds the references
35
+ # that keep the pointer table below valid.
36
+ self.gpu_tensors: dict[str, tuple[torch.Tensor, torch.Tensor | None]] = {}
37
+
38
+ # Indexed by req_state index. Stale entries after removal are
39
+ # harmless: add_request rewrites all fields for every index it claims.
40
+ self.embeds_ptrs = UvaBackedTensor(max_num_reqs, dtype=torch.int64)
41
+ self.mask_ptrs = UvaBackedTensor(max_num_reqs, dtype=torch.int64)
42
+ self.embeds_lens = UvaBackedTensor(max_num_reqs, dtype=torch.int32)
43
+
44
+ def add_request(self, req_index: int, new_req_data: NewRequestData) -> None:
45
+ prompt_embeds = new_req_data.prompt_embeds
46
+ if prompt_embeds is None:
47
+ self.gpu_tensors.pop(new_req_data.req_id, None)
48
+ self.embeds_lens.np[req_index] = 0
49
+ return
50
+
51
+ embeds = async_tensor_h2d(prompt_embeds, device=self.device, dtype=self.dtype)
52
+ embeds = embeds.contiguous()
53
+ is_token_ids = new_req_data.prompt_is_token_ids
54
+ mask = None
55
+ if is_token_ids is not None:
56
+ mask = async_tensor_h2d(is_token_ids, device=self.device, dtype=torch.uint8)
57
+ self.gpu_tensors[new_req_data.req_id] = (embeds, mask)
58
+ self.embeds_ptrs.np[req_index] = embeds.data_ptr()
59
+ self.mask_ptrs.np[req_index] = 0 if mask is None else mask.data_ptr()
60
+ self.embeds_lens.np[req_index] = embeds.shape[0]
61
+
62
+ def remove_request(self, req_id: str) -> None:
63
+ self.gpu_tensors.pop(req_id, None)
64
+
65
+ def apply_staged_writes(self) -> None:
66
+ self.embeds_ptrs.copy_to_uva()
67
+ self.mask_ptrs.copy_to_uva()
68
+ self.embeds_lens.copy_to_uva()
69
+
70
+ def apply(
71
+ self,
72
+ input_batch: InputBatch,
73
+ num_computed_tokens: torch.Tensor,
74
+ inputs_embeds: torch.Tensor,
75
+ ) -> None:
76
+ """Overlay prompt embeddings onto `inputs_embeds` for the batch."""
77
+ if not self.gpu_tensors:
78
+ return
79
+ # The kernel reinterprets raw source pointers as inputs_embeds' dtype.
80
+ assert inputs_embeds.dtype == self.dtype
81
+ num_reqs = input_batch.num_reqs
82
+ max_query_len = int(input_batch.num_scheduled_tokens.max())
83
+ grid = (num_reqs, triton.cdiv(max_query_len, TOKEN_BLOCK))
84
+ _apply_prompt_embeds_kernel[grid](
85
+ inputs_embeds,
86
+ inputs_embeds.stride(0),
87
+ self.embeds_ptrs.gpu,
88
+ self.mask_ptrs.gpu,
89
+ self.embeds_lens.gpu,
90
+ input_batch.idx_mapping,
91
+ input_batch.query_start_loc,
92
+ num_computed_tokens,
93
+ self.hidden_size,
94
+ TOKEN_BLOCK=TOKEN_BLOCK,
95
+ BLOCK_SIZE=1024,
96
+ )
97
+
98
+
99
+@triton.jit
100
+def _apply_prompt_embeds_kernel(
101
+ inputs_embeds_ptr,
102
+ inputs_embeds_stride,
103
+ embeds_ptrs_ptr, # int64 [max_num_reqs], device pointers (0-len = unused)
104
+ mask_ptrs_ptr, # int64 [max_num_reqs], 0 = no is-token-ids mask
105
+ embeds_lens_ptr, # int32 [max_num_reqs]
106
+ idx_mapping_ptr,
107
+ query_start_loc_ptr,
108
+ num_computed_tokens_ptr,
109
+ hidden_size: tl.constexpr,
110
+ TOKEN_BLOCK: tl.constexpr,
111
+ BLOCK_SIZE: tl.constexpr,
112
+):
113
+ batch_idx = tl.program_id(0)
114
+ req_state_idx = tl.load(idx_mapping_ptr + batch_idx)
115
+ embeds_len = tl.load(embeds_lens_ptr + req_state_idx)
116
+ num_computed = tl.load(num_computed_tokens_ptr + req_state_idx)
117
+ if num_computed >= embeds_len:
118
+ # No prompt embeds for this request, or they are fully consumed.
119
+ return
120
+
121
+ query_start = tl.load(query_start_loc_ptr + batch_idx)
122
+ query_end = tl.load(query_start_loc_ptr + batch_idx + 1)
123
+ num_rows = tl.minimum(query_end - query_start, embeds_len - num_computed)
124
+
125
+ t_start = tl.program_id(1) * TOKEN_BLOCK
126
+ if t_start >= num_rows:
127
+ return
128
+
129
+ src_ptr = tl.load(embeds_ptrs_ptr + req_state_idx).to(
130
+ tl.pointer_type(inputs_embeds_ptr.dtype.element_ty)
131
+ )
132
+ mask_int = tl.load(mask_ptrs_ptr + req_state_idx)
133
+ mask_ptr = mask_int.to(tl.pointer_type(tl.int8))
134
+
135
+ for t_offset in tl.static_range(TOKEN_BLOCK):
136
+ t = t_start + t_offset
137
+ if t < num_rows:
138
+ src_row = (num_computed + t).to(tl.int64)
139
+ is_token_id = 0
140
+ if mask_int != 0:
141
+ is_token_id = tl.load(mask_ptr + src_row).to(tl.int32)
142
+ if is_token_id == 0:
143
+ dst_row = (query_start + t).to(tl.int64)
144
+ for h in tl.range(0, hidden_size, BLOCK_SIZE):
145
+ offs = h + tl.arange(0, BLOCK_SIZE)
146
+ h_mask = offs < hidden_size
147
+ row = tl.load(src_ptr + src_row * hidden_size + offs, mask=h_mask)
148
+ tl.store(
149
+ inputs_embeds_ptr + dst_row * inputs_embeds_stride + offs,
150
+ row,
151
+ mask=h_mask,
152
+ )