main
py 380 lines 14.2 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for EncoderRunner.gather_mm_embeddings (model runner V2).
4
5 Covers the speculative-drafter encoder-cache handling: the drafter reads one
6 position ahead of the target model (``draft_lookahead``). The +1 look-ahead
7 feature past the processed boundary is used when its encoder output is present
8 and tolerated (token-embedding fallback) when it is not, while a miss within
9 the processed range still fails loudly.
10 """
11
12 from types import SimpleNamespace
13 from unittest.mock import MagicMock, patch
14
15 import numpy as np
16 import pytest
17 import torch
18
19 from vllm.config.compilation import CUDAGraphMode
20 from vllm.model_executor.models.interfaces import SupportsEncoderCudaGraph
21 from vllm.multimodal.inputs import (
22 MultiModalFeatureSpec,
23 MultiModalFieldElem,
24 MultiModalKwargsItem,
25 MultiModalSharedField,
26 PlaceholderRange,
27 )
28 from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
29 from vllm.v1.worker.gpu.mm.encoder_runner import EncoderRunner
30 from vllm.v1.worker.gpu.model_states.interface import ModelState
31
32 pytestmark = pytest.mark.cpu_test
33
34 HIDDEN = 4
35
36
37 @pytest.mark.parametrize(
38 "enabled,enforce_eager,supported,expected",
39 [
40 (True, False, True, True),
41 (False, False, True, False),
42 (True, True, True, False),
43 (True, False, False, False),
44 ],
45 )
46 def test_encoder_graph_configuration(enabled, enforce_eager, supported, expected):
47 """Encoder graph eligibility is independent of decoder graph mode."""
48 config = MagicMock()
49 config.model_config.enforce_eager = enforce_eager
50 config.model_config.dtype = torch.float32
51 config.model_config.get_inputs_embeds_size.return_value = HIDDEN
52 config.scheduler_config.max_num_batched_tokens = 8
53 config.compilation_config.cudagraph_mode = CUDAGraphMode.NONE
54 config.compilation_config.cudagraph_mm_encoder = enabled
55 model = MagicMock(spec=SupportsEncoderCudaGraph) if supported else torch.nn.Module()
56 state = _model_state(EncoderCache())
57 with patch("vllm.v1.worker.gpu.model_states.interface.EncoderCudaGraphManager"):
58 ModelState.__init__(state, config, model, state.encoder_cache, state.device)
59 assert state.encoder_runner.has_cudagraph() is expected
60
61
62 def test_encoder_graph_unsupported_modality_uses_eager_output():
63 """An image graph must not intercept another modality's encoder output."""
64 from vllm.v1.worker.encoder_cudagraph import EncoderCudaGraphManager
65
66 runner = _make_runner([], [])
67 output = torch.ones(2, HIDDEN)
68 runner.model = MagicMock()
69 runner.model.embed_multimodal.return_value = [output]
70 manager = MagicMock(spec=EncoderCudaGraphManager)
71 manager.config = SimpleNamespace(modalities=["image"])
72 manager.is_captured.return_value = True
73 manager.supports_modality.side_effect = lambda modality: (
74 EncoderCudaGraphManager.supports_modality(manager, modality)
75 )
76 runner.cudagraph_manager = manager
77 with patch(
78 "vllm.v1.worker.gpu.mm.encoder_runner.group_and_batch_mm_kwargs",
79 return_value=[("audio", 1, {"audio_values": torch.zeros(2)})],
80 ):
81 result = runner.execute_mm_encoder([])
82 assert len(result) == 1
83 assert result[0] is output
84 manager.execute.assert_not_called()
85
86
87 def _model_state(cache: EncoderCache) -> MagicMock:
88 """A mock ModelState backed by a real EncoderCache."""
89 state = MagicMock()
90 state.encoder_cache = cache
91 state.device = torch.device("cpu")
92 return state
93
94
95 def _embeds_item(embeds: torch.Tensor) -> MultiModalKwargsItem:
96 """A `prompt_embeds` kwargs item, as the HF renderer builds it."""
97 return MultiModalKwargsItem(
98 {
99 "embedding": MultiModalFieldElem(
100 data=embeds, field=MultiModalSharedField(batch_size=1)
101 )
102 }
103 )
104
105
106 def _feature(identifier: str, offset: int, length: int) -> MultiModalFeatureSpec:
107 return MultiModalFeatureSpec(
108 data=None,
109 modality="image",
110 identifier=identifier,
111 mm_position=PlaceholderRange(offset=offset, length=length),
112 )
113
114
115 def _make_runner(
116 features: list[MultiModalFeatureSpec],
117 cached: list[MultiModalFeatureSpec],
118 ) -> EncoderRunner:
119 cache = EncoderCache()
120 cache.mm_features["req0"] = features
121 for f in cached:
122 length = f.mm_position.length
123 cache.encoder_outputs[f.identifier] = torch.arange(
124 length * HIDDEN, dtype=torch.float32
125 ).reshape(length, HIDDEN)
126 return EncoderRunner(
127 model=None, # unused by gather_mm_embeddings
128 max_num_tokens=64,
129 hidden_size=HIDDEN,
130 encoder_cache=cache,
131 dtype=torch.float32,
132 device=torch.device("cpu"),
133 )
134
135
136 def _gather(runner: EncoderRunner, *, num_scheduled: int, draft_lookahead: int):
137 # Single prefilling request, num_computed_tokens=0, prefill_len large.
138 return runner.gather_mm_embeddings(
139 req_ids=["req0"],
140 total_num_scheduled_tokens=num_scheduled,
141 num_scheduled_tokens=np.array([num_scheduled]),
142 query_start_loc=np.array([0]),
143 prefill_lens=np.array([1000]),
144 num_computed_tokens=np.array([0]),
145 draft_lookahead=draft_lookahead,
146 )
147
148
149 def test_draft_lookahead_uses_boundary_feature_when_cached():
150 """The drafter's +1 look-ahead can reach the feature at offset ==
151 processed_end (the next chunk). When its encoder output is already cached
152 (the scheduler encoded it ahead), it is used for the look-ahead position
153 rather than ignored."""
154 f0 = _feature("h0", offset=0, length=8)
155 f1 = _feature("h1", offset=8, length=8) # starts exactly at processed_end
156 runner = _make_runner([f0, f1], cached=[f0, f1])
157
158 mm_embeds, is_mm_embed = _gather(runner, num_scheduled=8, draft_lookahead=1)
159
160 # f0 covers positions 0..6 (+1 skew); f1's first embed covers position 7.
161 assert len(mm_embeds) == 2
162 assert [e.modality for e in mm_embeds] == ["image", "image"]
163 assert bool(is_mm_embed[7])
164 assert int(is_mm_embed.sum()) == 8
165
166
167 def test_draft_lookahead_tolerates_missing_boundary_feature():
168 """When the +1 look-ahead feature past the processed boundary is not yet
169 encoded, fall back to the token embedding (the draft token is verified by
170 the target) instead of raising."""
171 f0 = _feature("h0", offset=0, length=8)
172 f1 = _feature("h1", offset=8, length=8) # boundary feature, not cached
173 runner = _make_runner([f0, f1], cached=[f0])
174
175 mm_embeds, is_mm_embed = _gather(runner, num_scheduled=8, draft_lookahead=1)
176
177 # Only f0 is gathered; f1's boundary position falls back silently.
178 assert len(mm_embeds) == 1
179 assert [e.modality for e in mm_embeds] == ["image"]
180 assert not bool(is_mm_embed[7])
181 assert int(is_mm_embed.sum()) == 7
182
183
184 def test_draft_lookahead_raises_on_interior_miss():
185 """A miss for a feature within the processed range (not the look-ahead
186 boundary) is a real invariant violation and must fail loudly, even on the
187 drafter path."""
188 f0 = _feature("h0", offset=0, length=8) # interior, within processed range
189 runner = _make_runner([f0], cached=[])
190
191 with pytest.raises(RuntimeError, match="Encoder cache miss"):
192 _gather(runner, num_scheduled=8, draft_lookahead=1)
193
194
195 def test_target_path_raises_on_encoder_cache_miss():
196 """On the target path (no look-ahead) a miss is a real invariant
197 violation and must fail loudly."""
198 f0 = _feature("h0", offset=0, length=8)
199 runner = _make_runner([f0], cached=[])
200
201 with pytest.raises(RuntimeError, match="Encoder cache miss"):
202 _gather(runner, num_scheduled=8, draft_lookahead=0)
203
204
205 @pytest.mark.parametrize("draft_lookahead", [0, 1])
206 def test_multi_request_batch_gathers_per_request(draft_lookahead):
207 """Two prefilling requests in one batch: per-request query bounds must be
208 indexed by request, not applied as whole arrays."""
209 a0 = _feature("a0", offset=0, length=8)
210 b0 = _feature("b0", offset=0, length=8)
211 cache = EncoderCache()
212 cache.mm_features["req0"] = [a0]
213 cache.mm_features["req1"] = [b0]
214 for f in (a0, b0):
215 cache.encoder_outputs[f.identifier] = torch.arange(
216 f.mm_position.length * HIDDEN, dtype=torch.float32
217 ).reshape(f.mm_position.length, HIDDEN)
218 runner = EncoderRunner(
219 model=None,
220 max_num_tokens=64,
221 hidden_size=HIDDEN,
222 encoder_cache=cache,
223 dtype=torch.float32,
224 device=torch.device("cpu"),
225 )
226
227 mm_embeds, is_mm_embed = runner.gather_mm_embeddings(
228 req_ids=["req0", "req1"],
229 total_num_scheduled_tokens=16,
230 num_scheduled_tokens=np.array([8, 8]),
231 query_start_loc=np.array([0, 8]),
232 prefill_lens=np.array([1000, 1000]),
233 num_computed_tokens=np.array([0, 0]),
234 draft_lookahead=draft_lookahead,
235 )
236
237 # Both requests contribute a feature; with the +1 skew each marks 7 of its
238 # 8 positions (the skew drops one), otherwise all 8.
239 assert len(mm_embeds) == 2
240 assert [e.modality for e in mm_embeds] == ["image", "image"]
241 assert int(is_mm_embed.sum()) == (14 if draft_lookahead else 16)
242
243
244 def test_gather_preserves_mixed_modalities():
245 """Modalities must be attached on tensors in gather order."""
246 video = MultiModalFeatureSpec(
247 data=None,
248 modality="video",
249 identifier="v0",
250 mm_position=PlaceholderRange(offset=0, length=4),
251 )
252 audio = MultiModalFeatureSpec(
253 data=None,
254 modality="audio",
255 identifier="a0",
256 mm_position=PlaceholderRange(offset=4, length=4),
257 )
258 runner = _make_runner([video, audio], cached=[video, audio])
259
260 mm_embeds, is_mm_embed = _gather(runner, num_scheduled=8, draft_lookahead=0)
261
262 assert len(mm_embeds) == 2
263 assert [e.modality for e in mm_embeds] == ["video", "audio"]
264 assert int(is_mm_embed.sum()) == 8
265
266
267 def test_execute_mm_encoder_caches_outputs_without_gathering():
268 """An encoder instance encodes and publishes, and must stop there.
269
270 `ModelState.execute_mm_encoder` is the half of `get_mm_embeddings` that an
271 EPD encoder instance needs: it runs no language model, so gathering would
272 build an `inputs_embeds` nobody reads -- and the gather raises
273 `Encoder cache miss` for any scheduled item absent from the local cache,
274 which on a producer takes the whole engine down (the scheduler hands it
275 items the connector already holds, and a producer has no load path).
276 """
277 cache = EncoderCache()
278 state = _model_state(cache)
279 embedding = torch.ones(2, HIDDEN)
280 # (mm_hashes, [(modality, kwargs item), ...]), as prepare_mm_inputs returns.
281 state.encoder_runner.prepare_mm_inputs.return_value = (
282 ["hash0"],
283 [("image", MagicMock())],
284 )
285 state.encoder_runner.execute_mm_encoder.return_value = [embedding]
286
287 ModelState.execute_mm_encoder(state, {"req0": [0]})
288
289 assert cache.encoder_outputs == {"hash0": embedding}
290 state.encoder_runner.gather_mm_embeddings.assert_not_called()
291
292
293 def test_execute_mm_encoder_is_a_noop_without_scheduled_items():
294 """A step that schedules no encoder input must not touch the encoder."""
295 cache = EncoderCache()
296 state = _model_state(cache)
297 state.encoder_runner.prepare_mm_inputs.return_value = ([], [])
298
299 ModelState.execute_mm_encoder(state, {})
300
301 assert not cache.encoder_outputs
302 state.encoder_runner.execute_mm_encoder.assert_not_called()
303
304
305 def _pe_feature(identifier: str, embeds: torch.Tensor, offset: int = 0):
306 return MultiModalFeatureSpec(
307 data=_embeds_item(embeds),
308 modality="prompt_embeds",
309 identifier=identifier,
310 mm_position=PlaceholderRange(offset=offset, length=embeds.shape[0]),
311 )
312
313
314 def test_prepare_mm_inputs_passes_prompt_embeds_through():
315 """`prompt_embeds` is already in embedding space, so no encoder may run.
316
317 The renderer delivers prompt_embeds mixed with real media as an ordinary MM
318 modality. prepare_mm_inputs must cache the tensor directly and keep it out
319 of the encoder batch -- the vision encoder cannot consume it, and a missing
320 cache entry makes the subsequent gather raise "Encoder cache miss".
321 """
322 prompt_embeds = torch.arange(2 * HIDDEN, dtype=torch.float32).view(2, HIDDEN)
323 image_feature = MultiModalFeatureSpec(
324 data=MagicMock(),
325 modality="image",
326 identifier="hash_img",
327 mm_position=PlaceholderRange(offset=2, length=2),
328 )
329 runner = _make_runner(
330 [_pe_feature("hash_pe", prompt_embeds), image_feature], cached=[]
331 )
332
333 mm_hashes, mm_kwargs = runner.prepare_mm_inputs({"req0": [0, 1]})
334
335 # Only the image remains for the encoder; the embeds are already cached.
336 assert mm_hashes == ["hash_img"]
337 assert [modality for modality, _ in mm_kwargs] == ["image"]
338 assert torch.equal(runner.encoder_cache.encoder_outputs["hash_pe"], prompt_embeds)
339
340
341 def test_prepare_mm_inputs_skips_cached_prompt_embeds():
342 """A prompt_embeds item already in the cache must not be re-uploaded."""
343 prompt_embeds = torch.ones(3, HIDDEN)
344 feature = _pe_feature("hash_pe", prompt_embeds)
345 runner = _make_runner([feature], cached=[feature])
346 sentinel = runner.encoder_cache.encoder_outputs["hash_pe"]
347
348 mm_hashes, mm_kwargs = runner.prepare_mm_inputs({"req0": [0]})
349
350 assert mm_hashes == [] and mm_kwargs == []
351 assert runner.encoder_cache.encoder_outputs["hash_pe"] is sentinel
352
353
354 def test_execute_mm_encoder_skips_encoder_for_prompt_embeds_only():
355 """A batch of nothing but prompt_embeds must not invoke the encoder."""
356 prompt_embeds = torch.ones(3, HIDDEN)
357 runner = _make_runner([_pe_feature("hash_pe", prompt_embeds)], cached=[])
358 state = _model_state(runner.encoder_cache)
359 state.encoder_runner.prepare_mm_inputs.side_effect = runner.prepare_mm_inputs
360
361 ModelState.execute_mm_encoder(state, {"req0": [0]})
362
363 state.encoder_runner.execute_mm_encoder.assert_not_called()
364 assert torch.equal(runner.encoder_cache.encoder_outputs["hash_pe"], prompt_embeds)
365
366
367 def test_encoder_timing_stats_registry():
368 runner = _make_runner([], [])
369 runner.enable_timing = True
370
371 with runner.timed_encoder_operation({"r1"}):
372 pass
373 with runner.timed_encoder_operation({"r1"}):
374 pass
375
376 stats = runner.get_encoder_timing_stats()
377 assert set(stats) == {"r1"}
378 assert stats["r1"]["num_encoder_calls"] == 2
379 assert stats["r1"]["encoder_forward_secs"] >= 0
380 assert runner.get_encoder_timing_stats() == {}