main
py 486 lines 19.6 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Unit tests for BaseRenderer.warmup MM-warmup behavior.
4
5 These tests exercise:
6 - Zero-limit modalities are filtered from mm_counts passed to
7 get_dummy_inputs (e.g. --limit-mm-per-prompt image=0 ...)
8 - The warmup runs through the processor-only cache and clears it afterwards,
9 so its dummy inputs never land in the sender cache used by the serving path
10 - MM warmup is skipped entirely when mm_processor is None
11 - The multimodal warmup is launched as a task on the single-worker
12 _mm_executor to overlap engine-core init (future lifecycle, join by
13 warmup/reset/shutdown, no double-run). Routing it through the same
14 executor that serves _process_multimodal keeps the numba workqueue
15 parallel region single-threaded, avoiding the "Concurrent access has been
16 detected" fatal abort when a request arrives during warmup.
17 - That overlap only exists for the multiprocess clients: the in-process
18 EngineCore is built synchronously, so InprocClient rejects a renderer
19 (assert) and warmup stays inside renderer.warmup().
20
21 No model weights are required: warmup() is called directly on a MagicMock
22 that acts as the renderer instance.
23 """
24
25 from concurrent.futures import Future, ThreadPoolExecutor
26 from types import MethodType, SimpleNamespace
27 from unittest.mock import MagicMock, patch
28
29 import pytest
30
31 from vllm.entrypoints.chat_utils import ChatTemplateResolutionError
32 from vllm.multimodal.processing import BaseMultiModalProcessor
33 from vllm.renderers.base import BaseRenderer
34 from vllm.renderers.params import ChatParams
35
36
37 def _make_renderer_mock(mm_limits: dict[str, int]) -> MagicMock:
38 """Return a MagicMock that quacks like a BaseRenderer instance.
39
40 render_chat is mocked to raise ChatTemplateResolutionError so the chat
41 warmup block is skipped cleanly, keeping the test focused on MM warmup.
42
43 The processor is a mock at its I/O boundaries (``dummy_inputs`` and
44 ``apply``) but keeps the real ``get_dummy_mm_inputs``, since that is where
45 the warmup's seq_len capping and cache plumbing actually live.
46 """
47 renderer = MagicMock()
48
49 # chat warmup: make render_chat raise so we skip past it cleanly
50 renderer.render_chat.side_effect = ChatTemplateResolutionError("no template")
51
52 # MM processor with configurable limits
53 mm_processor = MagicMock()
54 mm_processor.info.allowed_mm_limits = mm_limits
55 mm_processor.info.ctx.model_config = renderer.model_config
56 mm_processor.apply.return_value = {"prompt_token_ids": [1]}
57 mm_processor.get_dummy_mm_inputs = MethodType(
58 BaseMultiModalProcessor.get_dummy_mm_inputs, mm_processor
59 )
60 renderer.mm_processor = mm_processor
61 renderer._mm_processor_cache = MagicMock(name="sender_cache")
62 renderer._mm_processor_only_cache = MagicMock(name="processor_only_cache")
63 renderer._warmup_mm_processor = BaseRenderer._warmup_mm_processor.__get__(
64 renderer, BaseRenderer
65 )
66 renderer.warmup_mm = BaseRenderer.warmup_mm.__get__(renderer, BaseRenderer)
67 renderer.start_mm_warmup_in_background = (
68 BaseRenderer.start_mm_warmup_in_background.__get__(renderer, BaseRenderer)
69 )
70 renderer._join_mm_warmup = BaseRenderer._join_mm_warmup.__get__(
71 renderer, BaseRenderer
72 )
73 renderer.shutdown = BaseRenderer.shutdown.__get__(renderer, BaseRenderer)
74 # No background warmup launched by default; warmup() takes the inline path.
75 renderer._mm_warmup_future = None
76 # MM warmup has not run yet; warmup_mm must actually execute on the mock.
77 renderer._mm_warmup_done = False
78 renderer.model_config.max_model_len = 128
79 renderer.config.scheduler_config.max_num_batched_tokens = 8192
80 renderer.config.scheduler_config.enable_chunked_prefill = True
81 renderer.model_config.get_multimodal_config.return_value.limit_per_prompt = {}
82
83 return renderer
84
85
86 class TestMmWarmupZeroLimitFiltering:
87 """Zero-limit modalities must be excluded from mm_counts."""
88
89 def test_zero_limit_modality_excluded_from_mm_counts(self):
90 """A modality with limit=0 must not appear in mm_counts."""
91 renderer = _make_renderer_mock({"image": 1, "video": 0})
92
93 BaseRenderer.warmup(renderer, ChatParams())
94
95 get_inputs = renderer.mm_processor.get_dummy_inputs
96 get_inputs.assert_called_once()
97 _, kwargs = get_inputs.call_args
98 assert "video" not in kwargs["mm_counts"]
99 assert kwargs["mm_counts"]["image"] == 1
100
101 def test_all_zero_limits_passes_empty_mm_counts(self):
102 """When all limits are 0, mm_counts must be empty."""
103 renderer = _make_renderer_mock({"image": 0, "video": 0})
104
105 BaseRenderer.warmup(renderer, ChatParams())
106
107 get_inputs = renderer.mm_processor.get_dummy_inputs
108 get_inputs.assert_called_once()
109 _, kwargs = get_inputs.call_args
110 assert kwargs["mm_counts"] == {}
111
112 def test_positive_limits_all_included_in_mm_counts(self):
113 """All modalities with limit > 0 must be present in mm_counts."""
114 renderer = _make_renderer_mock({"image": 2, "video": 1})
115
116 BaseRenderer.warmup(renderer, ChatParams())
117
118 get_inputs = renderer.mm_processor.get_dummy_inputs
119 get_inputs.assert_called_once()
120 _, kwargs = get_inputs.call_args
121 assert kwargs["mm_counts"] == {"image": 1, "video": 1}
122
123
124 class TestMmWarmupRunsNormally:
125 # MM warmup must run when mm_processor is set and limits > 0; the chat
126 # template warmup must run alongside it.
127
128 @pytest.mark.parametrize(
129 ("max_model_len", "expected_seq_len"), [(491520, 8192), (128, 128)]
130 )
131 def test_warmup_sequence_length_is_bounded(self, max_model_len, expected_seq_len):
132 renderer = _make_renderer_mock({"image": 1, "video": 1})
133 renderer.model_config.max_model_len = max_model_len
134
135 BaseRenderer.warmup(renderer, ChatParams())
136
137 get_inputs = renderer.mm_processor.get_dummy_inputs
138 assert get_inputs.call_args.kwargs["seq_len"] == expected_seq_len
139
140 def test_processor_apply_called(self):
141 renderer = _make_renderer_mock({"image": 1})
142
143 BaseRenderer.warmup(renderer, ChatParams())
144
145 renderer.mm_processor.apply.assert_called_once()
146
147 def test_render_chat_called_with_warmup_message(self):
148 renderer = _make_renderer_mock({"image": 1})
149
150 BaseRenderer.warmup(renderer, ChatParams())
151
152 renderer.render_chat.assert_called_once()
153
154
155 class TestMmWarmupCacheRouting:
156 # The warmup runs through the processor-only cache and drops it afterwards,
157 # so the dummy inputs never reach the sender cache used by the serving path.
158
159 def test_warmup_passes_processor_only_cache(self):
160 renderer = _make_renderer_mock({"image": 1})
161
162 with patch.object(
163 renderer.mm_processor,
164 "get_dummy_mm_inputs",
165 wraps=renderer.mm_processor.get_dummy_mm_inputs,
166 ) as spy:
167 BaseRenderer.warmup(renderer, ChatParams())
168
169 assert spy.call_args.kwargs["cache"] is renderer._mm_processor_only_cache
170 assert (
171 spy.call_args.kwargs["scheduler_config"] is renderer.config.scheduler_config
172 )
173
174 def test_only_cache_cleared_and_sender_cache_untouched(self):
175 renderer = _make_renderer_mock({"image": 1})
176
177 BaseRenderer.warmup(renderer, ChatParams())
178
179 renderer._mm_processor_only_cache.clear_cache.assert_called_once()
180 renderer._mm_processor_cache.clear_cache.assert_not_called()
181
182
183 class TestMmWarmupSkippedWhenNoProcessor:
184 """MM warmup must be skipped when mm_processor is None (text-only model)."""
185
186 def test_no_warmup_without_processor(self):
187 renderer = _make_renderer_mock({})
188 renderer.mm_processor = None # override to None
189
190 BaseRenderer.warmup(renderer, ChatParams())
191
192 renderer.model_config.get_multimodal_config.assert_not_called()
193
194
195 class TestWarmupFaultIsolation:
196 # A failure during a warmup step is caught so it does not abort the other
197 # step; warmup itself must not raise.
198
199 def test_chat_failure_does_not_abort_mm_warmup(self):
200 renderer = _make_renderer_mock({"image": 1})
201 renderer.render_chat.side_effect = RuntimeError("chat boom")
202
203 BaseRenderer.warmup(renderer, ChatParams()) # must not raise
204
205 renderer.mm_processor.apply.assert_called_once()
206
207 def test_mm_failure_is_swallowed_and_cache_cleared(self):
208 renderer = _make_renderer_mock({"image": 1})
209 # the warmup blows up before apply()
210 renderer.mm_processor.get_dummy_inputs.side_effect = RuntimeError("mm boom")
211
212 BaseRenderer.warmup(renderer, ChatParams()) # must not raise
213
214 renderer.mm_processor.apply.assert_not_called()
215 # the cache is still cleared in the failed warmup's finally
216 renderer._mm_processor_only_cache.clear_cache.assert_called_once()
217
218
219 class TestBackgroundMmWarmup:
220 # The multimodal warmup is launched as a task on the single-worker
221 # _mm_executor so it overlaps engine-core init (fork) while staying
222 # serialized with the serving path (_process_multimodal runs on the same
223 # executor). warmup()/reset_mm_cache/shutdown join it. This also fixes the
224 # numba workqueue "Concurrent access has been detected" abort: warmup and a
225 # concurrent serving request can no longer both enter the numba parallel
226 # region at once.
227
228 def _make_bg_renderer(self, mm_limits: dict[str, int]):
229 # A renderer mock with a real single-worker executor so submit()
230 # returns a real Future and warmup_mm actually runs (in the worker).
231 renderer = _make_renderer_mock(mm_limits)
232 renderer._mm_executor = ThreadPoolExecutor(max_workers=1)
233 return renderer
234
235 def test_start_submits_future_to_mm_executor(self):
236 renderer = self._make_bg_renderer({"image": 1})
237 try:
238 # Spy on the real executor's submit to confirm the warmup is
239 # dispatched through _mm_executor (not a separate Thread).
240 with patch.object(
241 renderer._mm_executor, "submit", wraps=renderer._mm_executor.submit
242 ) as spy:
243 renderer.start_mm_warmup_in_background()
244 assert isinstance(renderer._mm_warmup_future, Future)
245 assert spy.called
246 renderer._join_mm_warmup()
247 assert renderer._mm_warmup_future is None
248 renderer.mm_processor.apply.assert_called_once()
249 finally:
250 renderer._mm_executor.shutdown(wait=True)
251
252 def test_start_noop_for_text_only_model(self):
253 renderer = _make_renderer_mock({})
254 renderer.mm_processor = None
255
256 renderer.start_mm_warmup_in_background()
257
258 assert renderer._mm_warmup_future is None
259
260 def test_start_is_run_at_most_once(self):
261 renderer = self._make_bg_renderer({"image": 1})
262 try:
263 renderer.start_mm_warmup_in_background()
264 first = renderer._mm_warmup_future
265 renderer.start_mm_warmup_in_background() # must not spawn a second
266 assert renderer._mm_warmup_future is first
267 renderer._join_mm_warmup()
268 finally:
269 renderer._mm_executor.shutdown(wait=True)
270
271 def test_warmup_joins_background_and_does_not_rerun_mm(self):
272 # When a background MM warmup is in flight, warmup() must join it and
273 # run only the chat warmup — the MM warmup must not run twice.
274 renderer = self._make_bg_renderer({"image": 1})
275 try:
276 renderer.start_mm_warmup_in_background()
277 BaseRenderer.warmup(renderer, ChatParams())
278
279 # MM apply called exactly once (by the background warmup task).
280 renderer.mm_processor.apply.assert_called_once()
281 # Chat warmup ran exactly once.
282 renderer.render_chat.assert_called_once()
283 # Background task has been joined and cleared.
284 assert renderer._mm_warmup_future is None
285 finally:
286 renderer._mm_executor.shutdown(wait=True)
287
288 def test_warmup_does_not_rerun_mm_after_reset_joins_background(self):
289 # Regression: reset_mm_cache joins the background warmup before
290 # warmup() runs (it clears _mm_warmup_future). warmup() must still not
291 # re-run the MM warmup — the _mm_warmup_done flag survives the join.
292 renderer = self._make_bg_renderer({"image": 1})
293 try:
294 renderer.start_mm_warmup_in_background()
295 # Simulate reset_mm_cache joining the background warmup first
296 # (clears _mm_warmup_future, sets _mm_warmup_done via the join).
297 renderer._join_mm_warmup()
298 assert renderer._mm_warmup_future is None # joined & cleared
299 BaseRenderer.warmup(renderer, ChatParams())
300
301 # MM apply called exactly once (by the background warmup task);
302 # warmup() did not re-run warmup_mm despite the future being cleared.
303 renderer.mm_processor.apply.assert_called_once()
304 renderer.render_chat.assert_called_once()
305 finally:
306 renderer._mm_executor.shutdown(wait=True)
307
308 def test_warmup_mm_runs_at_most_once(self):
309 # Direct repeated calls to warmup_mm run the MM warmup only once.
310 renderer = _make_renderer_mock({"image": 1})
311
312 renderer.warmup_mm()
313 renderer.warmup_mm() # second call must be a no-op
314
315 renderer.mm_processor.apply.assert_called_once()
316
317 def test_shutdown_joins_background_warmup(self):
318 # shutdown() must join the background warmup before closing caches,
319 # so the mm_processor_cache is never touched concurrently.
320 renderer = self._make_bg_renderer({"image": 1})
321
322 renderer.start_mm_warmup_in_background()
323 future = renderer._mm_warmup_future
324 renderer.shutdown()
325
326 assert renderer._mm_warmup_future is None
327 # The background warmup was allowed to complete (apply ran) and the
328 # future is done after shutdown joined it.
329 assert future.done()
330 renderer.mm_processor.apply.assert_called_once()
331
332
333 class TestEngineStartWarmupHook:
334 # The renderer is handed to the client, which starts the MM warmup
335 # (renderer.start_mm_warmup_in_background) only after engine-core
336 # processes have been forked — a live warmup thread at fork() time would
337 # deadlock the child (it inherits locks whose owning thread vanishes).
338 # These tests pin the renderer plumbing contract on EngineCoreClient.
339
340 def _mock_config(self):
341 cfg = SimpleNamespace(
342 parallel_config=SimpleNamespace(
343 data_parallel_size=1,
344 data_parallel_external_lb=False,
345 ),
346 model_config=SimpleNamespace(multimodal_config=None),
347 )
348 return cfg
349
350 def _mock_renderer(self):
351 return MagicMock()
352
353 def test_inproc_client_rejects_renderer(self):
354 # InprocClient has no _start_mm_warmup (only MPClient does), so it
355 # takes no renderer at all: passing one is a TypeError at the call
356 # site rather than a silently-ignored kwarg.
357 from vllm.v1.engine import core_client as cc
358
359 with pytest.raises(TypeError, match="renderer"):
360 cc.InprocClient(
361 MagicMock(),
362 MagicMock(),
363 MagicMock(),
364 renderer=self._mock_renderer(),
365 )
366
367 def test_inproc_client_forwards_fail_callback_but_not_renderer(self):
368 # EngineCore args (incl. executor_fail_callback) pass through; the
369 # renderer kwarg is intercepted by InprocClient.__init__ (keyword-only
370 # after *), so it is never forwarded to EngineCore — which has no
371 # renderer parameter at all.
372 from vllm.v1.engine import core_client as cc
373
374 callback = MagicMock()
375 with patch.object(cc, "EngineCore") as mock_engine_core:
376 cc.InprocClient(
377 MagicMock(),
378 MagicMock(),
379 MagicMock(),
380 executor_fail_callback=callback,
381 )
382
383 mock_engine_core.assert_called_once()
384 args, kwargs = mock_engine_core.call_args
385 assert len(args) == 3 # vllm_config, executor_class, log_stats
386 assert "renderer" not in kwargs
387 assert kwargs.get("executor_fail_callback") is callback
388
389 def test_make_client_does_not_pass_renderer_to_inproc_client(self):
390 # In-process: the renderer must never reach InprocClient, so MM
391 # warmup stays a plain inline call inside renderer.warmup().
392 from vllm.v1.engine import core_client as cc
393
394 cfg = self._mock_config()
395 renderer = self._mock_renderer()
396
397 with (
398 patch.object(cc, "InprocClient") as mock_inproc,
399 patch.object(cc, "SyncMPClient") as mock_sync,
400 ):
401 client = cc.EngineCoreClient.make_client(
402 multiprocess_mode=False,
403 asyncio_mode=False,
404 vllm_config=cfg,
405 executor_class=MagicMock(),
406 log_stats=False,
407 renderer=renderer,
408 )
409
410 assert client is mock_inproc.return_value
411 mock_inproc.assert_called_once()
412 assert "renderer" not in mock_inproc.call_args.kwargs
413 mock_sync.assert_not_called()
414 renderer.start_mm_warmup_in_background.assert_not_called()
415
416 @pytest.mark.parametrize(
417 ("client_cls", "multiprocess_mode"),
418 [
419 # SyncMPClient forks engine-core: the renderer must be handed to
420 # it so the warmup fires after proc.start(), not before fork().
421 ("SyncMPClient", True),
422 ],
423 )
424 def test_make_client_passes_renderer(self, client_cls, multiprocess_mode):
425 from vllm.v1.engine import core_client as cc
426
427 cfg = self._mock_config()
428 renderer = self._mock_renderer()
429
430 with patch.object(cc, client_cls) as mock_client:
431 cc.EngineCoreClient.make_client(
432 multiprocess_mode=multiprocess_mode,
433 asyncio_mode=False,
434 vllm_config=cfg,
435 executor_class=MagicMock(),
436 log_stats=False,
437 renderer=renderer,
438 )
439
440 mock_client.assert_called_once()
441 assert mock_client.call_args.kwargs["renderer"] is renderer
442
443 @pytest.mark.parametrize(
444 ("client_cls", "dp_size", "external_lb"),
445 [
446 ("AsyncMPClient", 1, False),
447 ("DPAsyncMPClient", 2, True),
448 ],
449 )
450 def test_make_async_mp_client_passes_renderer(
451 self, client_cls, dp_size, external_lb
452 ):
453 from vllm.v1.engine import core_client as cc
454
455 cfg = self._mock_config()
456 cfg.parallel_config.data_parallel_size = dp_size
457 cfg.parallel_config.data_parallel_external_lb = external_lb
458 renderer = self._mock_renderer()
459
460 with patch.object(cc, client_cls) as mock_client:
461 cc.EngineCoreClient.make_async_mp_client(
462 cfg,
463 executor_class=MagicMock(),
464 log_stats=False,
465 renderer=renderer,
466 )
467
468 mock_client.assert_called_once()
469 assert mock_client.call_args.kwargs["renderer"] is renderer
470
471 def test_mp_client_starts_mm_warmup_only_with_renderer(self):
472 # MPClient must call renderer.start_mm_warmup_in_background when a
473 # renderer was given, and skip it (no crash) when not.
474 from vllm.v1.engine import core_client as cc
475
476 # No renderer: _start_mm_warmup is a no-op.
477 client = cc.MPClient.__new__(cc.MPClient)
478 client._renderer = None
479 client._start_mm_warmup()
480
481 # With a renderer: it starts the background MM warmup.
482 renderer = self._mock_renderer()
483 client2 = cc.MPClient.__new__(cc.MPClient)
484 client2._renderer = renderer
485 client2._start_mm_warmup()
486 renderer.start_mm_warmup_in_background.assert_called_once()