[warmup] overlap renderer warmup and engine core initialization (#54557)
Signed-off-by: Andy Xie <andy.xning@gmail.com> Co-authored-by: Cyrus Leung <tlleungac@connect.ust.hk>
Ning Xie committed
Sep 4, 2026 at 03:44 UTC
2a336d8239d00565a99752cfa248f2326e2ac8aa
5 files changed
+561
-33
tests/renderers/test_warmup.py
+346
-1
@@ -6,13 +6,25 @@ These tests exercise:
6
- Zero-limit modalities are filtered from mm_counts passed to
7
get_dummy_processor_inputs (e.g. --limit-mm-per-prompt image=0 ...)
8
- MM warmup is skipped entirely when mm_processor is None
9
+ - The multimodal warmup is launched as a task on the single-worker
10
+ _mm_executor to overlap engine-core init (future lifecycle, join by
11
+ warmup/reset/shutdown, no double-run). Routing it through the same
12
+ executor that serves _process_multimodal keeps the numba workqueue
13
+ parallel region single-threaded, avoiding the "Concurrent access has been
14
+ detected" fatal abort when a request arrives during warmup.
15
+ - That overlap only exists for the multiprocess clients: the in-process
16
+ EngineCore is built synchronously, so InprocClient rejects a renderer
17
+ (assert) and warmup stays inside renderer.warmup().
18
19
No model weights are required: warmup() is called directly on a MagicMock
20
that acts as the renderer instance.
21
"""
22
23
+from concurrent.futures import Future, ThreadPoolExecutor
24
from unittest.mock import MagicMock, patch
25
26
+import pytest
27
+
28
from vllm.renderers.base import BaseRenderer
29
from vllm.renderers.params import ChatParams
30
@@ -39,6 +51,18 @@ def _make_renderer_mock(mm_limits: dict[str, int]) -> MagicMock:
51
renderer, BaseRenderer
52
)
53
renderer._clear_processor_cache = BaseRenderer._clear_processor_cache
54
+ renderer.warmup_mm = BaseRenderer.warmup_mm.__get__(renderer, BaseRenderer)
55
+ renderer.start_mm_warmup_in_background = (
56
+ BaseRenderer.start_mm_warmup_in_background.__get__(renderer, BaseRenderer)
57
+ )
58
+ renderer._join_mm_warmup = BaseRenderer._join_mm_warmup.__get__(
59
+ renderer, BaseRenderer
60
+ )
61
+ renderer.shutdown = BaseRenderer.shutdown.__get__(renderer, BaseRenderer)
62
+ # No background warmup launched by default; warmup() takes the inline path.
63
+ renderer._mm_warmup_future = None
64
+ # MM warmup has not run yet; warmup_mm must actually execute on the mock.
65
+ renderer._mm_warmup_done = False
66
renderer.clear_mm_cache = MagicMock()
67
renderer.model_config.max_model_len = 128
68
renderer.model_config.get_multimodal_config.return_value.limit_per_prompt = {}
@@ -88,7 +112,8 @@ class TestMmWarmupZeroLimitFiltering:
112
113
114
class TestMmWarmupRunsNormally:
91
- """MM warmup must run when mm_processor is set and limits > 0."""
115
+ # MM warmup must run when mm_processor is set and limits > 0; the chat
116
+ # template warmup must run alongside it.
117
118
def test_processor_apply_called(self):
119
renderer = _make_renderer_mock({"image": 1})
@@ -106,6 +131,14 @@ class TestMmWarmupRunsNormally:
131
132
renderer.clear_mm_cache.assert_called_once()
133
134
+ def test_render_chat_called_with_warmup_message(self):
135
+ renderer = _make_renderer_mock({"image": 1})
136
+
137
+ with patch("vllm.multimodal.processing.TimingContext", autospec=True):
138
+ BaseRenderer.warmup(renderer, ChatParams())
139
+
140
+ renderer.render_chat.assert_called_once()
141
+
142
143
class TestMmWarmupSkippedWhenNoProcessor:
144
"""MM warmup must be skipped when mm_processor is None (text-only model)."""
@@ -133,3 +166,315 @@ class TestReadonlyMmWarmup:
166
167
readonly_mm_processor.apply.assert_called_once()
168
readonly_mm_processor.cache.clear_cache.assert_called_once()
169
+
170
+
171
+class TestWarmupFaultIsolation:
172
+ # A failure during a multimodal processor warmup is caught so it does not
173
+ # abort the remaining warmup steps; warmup itself must not raise.
174
+
175
+ def test_chat_failure_does_not_abort_mm_warmup(self):
176
+ renderer = _make_renderer_mock({"image": 1})
177
+ renderer.render_chat.side_effect = RuntimeError("chat boom")
178
+
179
+ with patch("vllm.multimodal.processing.TimingContext", autospec=True):
180
+ BaseRenderer.warmup(renderer, ChatParams()) # must not raise
181
+
182
+ renderer.mm_processor.apply.assert_called_once()
183
+
184
+ def test_mm_failure_does_not_abort_readonly_warmup(self):
185
+ renderer = _make_renderer_mock({"image": 1})
186
+ readonly_mm_processor = MagicMock()
187
+ readonly_mm_processor.info.allowed_mm_limits = {"image": 1}
188
+ renderer._readonly_mm_processor = readonly_mm_processor
189
+ # main processor warmup blows up before apply()
190
+ renderer.mm_processor.dummy_inputs.get_dummy_processor_inputs.side_effect = (
191
+ RuntimeError("mm boom")
192
+ )
193
+
194
+ with patch("vllm.multimodal.processing.TimingContext", autospec=True):
195
+ BaseRenderer.warmup(renderer, ChatParams()) # must not raise
196
+
197
+ readonly_mm_processor.apply.assert_called_once()
198
+ # cache is still cleared in the failed task's finally
199
+ renderer.clear_mm_cache.assert_called_once()
200
+
201
+
202
+class TestBackgroundMmWarmup:
203
+ # The multimodal warmup is launched as a task on the single-worker
204
+ # _mm_executor so it overlaps engine-core init (fork) while staying
205
+ # serialized with the serving path (_process_multimodal runs on the same
206
+ # executor). warmup()/reset_mm_cache/shutdown join it. This also fixes the
207
+ # numba workqueue "Concurrent access has been detected" abort: warmup and a
208
+ # concurrent serving request can no longer both enter the numba parallel
209
+ # region at once.
210
+
211
+ def _make_bg_renderer(self, mm_limits: dict[str, int]):
212
+ # A renderer mock with a real single-worker executor so submit()
213
+ # returns a real Future and warmup_mm actually runs (in the worker).
214
+ renderer = _make_renderer_mock(mm_limits)
215
+ renderer._mm_executor = ThreadPoolExecutor(max_workers=1)
216
+ return renderer
217
+
218
+ def test_start_submits_future_to_mm_executor(self):
219
+ renderer = self._make_bg_renderer({"image": 1})
220
+ try:
221
+ with patch("vllm.multimodal.processing.TimingContext", autospec=True):
222
+ # Spy on the real executor's submit to confirm the warmup is
223
+ # dispatched through _mm_executor (not a separate Thread).
224
+ with patch.object(
225
+ renderer._mm_executor, "submit", wraps=renderer._mm_executor.submit
226
+ ) as spy:
227
+ renderer.start_mm_warmup_in_background()
228
+ assert isinstance(renderer._mm_warmup_future, Future)
229
+ assert spy.called
230
+ renderer._join_mm_warmup()
231
+ assert renderer._mm_warmup_future is None
232
+ renderer.mm_processor.apply.assert_called_once()
233
+ finally:
234
+ renderer._mm_executor.shutdown(wait=True)
235
+
236
+ def test_start_noop_for_text_only_model(self):
237
+ renderer = _make_renderer_mock({})
238
+ renderer.mm_processor = None
239
+ # _readonly_mm_processor is already None
240
+
241
+ renderer.start_mm_warmup_in_background()
242
+
243
+ assert renderer._mm_warmup_future is None
244
+
245
+ def test_start_is_run_at_most_once(self):
246
+ renderer = self._make_bg_renderer({"image": 1})
247
+ try:
248
+ with patch("vllm.multimodal.processing.TimingContext", autospec=True):
249
+ renderer.start_mm_warmup_in_background()
250
+ first = renderer._mm_warmup_future
251
+ renderer.start_mm_warmup_in_background() # must not spawn a second
252
+ assert renderer._mm_warmup_future is first
253
+ renderer._join_mm_warmup()
254
+ finally:
255
+ renderer._mm_executor.shutdown(wait=True)
256
+
257
+ def test_warmup_joins_background_and_does_not_rerun_mm(self):
258
+ # When a background MM warmup is in flight, warmup() must join it and
259
+ # run only the chat warmup — the MM warmup must not run twice.
260
+ renderer = self._make_bg_renderer({"image": 1})
261
+ try:
262
+ with patch("vllm.multimodal.processing.TimingContext", autospec=True):
263
+ renderer.start_mm_warmup_in_background()
264
+ BaseRenderer.warmup(renderer, ChatParams())
265
+
266
+ # MM apply called exactly once (by the background warmup task).
267
+ renderer.mm_processor.apply.assert_called_once()
268
+ # Chat warmup ran exactly once.
269
+ renderer.render_chat.assert_called_once()
270
+ # Background task has been joined and cleared.
271
+ assert renderer._mm_warmup_future is None
272
+ finally:
273
+ renderer._mm_executor.shutdown(wait=True)
274
+
275
+ def test_warmup_does_not_rerun_mm_after_reset_joins_background(self):
276
+ # Regression: reset_mm_cache joins the background warmup before
277
+ # warmup() runs (it clears _mm_warmup_future). warmup() must still not
278
+ # re-run the MM warmup — the _mm_warmup_done flag survives the join.
279
+ renderer = self._make_bg_renderer({"image": 1})
280
+ try:
281
+ with patch("vllm.multimodal.processing.TimingContext", autospec=True):
282
+ renderer.start_mm_warmup_in_background()
283
+ # Simulate reset_mm_cache joining the background warmup first
284
+ # (clears _mm_warmup_future, sets _mm_warmup_done via the join).
285
+ renderer._join_mm_warmup()
286
+ assert renderer._mm_warmup_future is None # joined & cleared
287
+ BaseRenderer.warmup(renderer, ChatParams())
288
+
289
+ # MM apply called exactly once (by the background warmup task);
290
+ # warmup() did not re-run warmup_mm despite the future being cleared.
291
+ renderer.mm_processor.apply.assert_called_once()
292
+ renderer.render_chat.assert_called_once()
293
+ finally:
294
+ renderer._mm_executor.shutdown(wait=True)
295
+
296
+ def test_warmup_mm_runs_at_most_once(self):
297
+ # Direct repeated calls to warmup_mm run the MM warmup only once.
298
+ renderer = _make_renderer_mock({"image": 1})
299
+
300
+ with patch("vllm.multimodal.processing.TimingContext", autospec=True):
301
+ renderer.warmup_mm()
302
+ renderer.warmup_mm() # second call must be a no-op
303
+
304
+ renderer.mm_processor.apply.assert_called_once()
305
+
306
+ def test_shutdown_joins_background_warmup(self):
307
+ # shutdown() must join the background warmup before closing caches,
308
+ # so the mm_processor_cache is never touched concurrently.
309
+ renderer = self._make_bg_renderer({"image": 1})
310
+
311
+ with patch("vllm.multimodal.processing.TimingContext", autospec=True):
312
+ renderer.start_mm_warmup_in_background()
313
+ future = renderer._mm_warmup_future
314
+ renderer.shutdown()
315
+
316
+ assert renderer._mm_warmup_future is None
317
+ # The background warmup was allowed to complete (apply ran) and the
318
+ # future is done after shutdown joined it.
319
+ assert future.done()
320
+ renderer.mm_processor.apply.assert_called_once()
321
+
322
+
323
+class TestEngineStartWarmupHook:
324
+ # The renderer is handed to the client, which starts the MM warmup
325
+ # (renderer.start_mm_warmup_in_background) only after engine-core
326
+ # processes have been forked — a live warmup thread at fork() time would
327
+ # deadlock the child (it inherits locks whose owning thread vanishes).
328
+ # These tests pin the renderer plumbing contract on EngineCoreClient.
329
+
330
+ def _mock_config(self):
331
+ from types import SimpleNamespace
332
+
333
+ from vllm.v1.engine.core_client import EngineCoreClient # noqa: F401
334
+
335
+ cfg = SimpleNamespace(
336
+ parallel_config=SimpleNamespace(
337
+ data_parallel_size=1,
338
+ data_parallel_external_lb=False,
339
+ ),
340
+ model_config=SimpleNamespace(multimodal_config=None),
341
+ )
342
+ return cfg
343
+
344
+ def _mock_renderer(self):
345
+ return MagicMock()
346
+
347
+ def test_inproc_client_rejects_renderer(self):
348
+ # InprocClient has no _start_mm_warmup (only MPClient does), so it
349
+ # takes no renderer at all: passing one is a TypeError at the call
350
+ # site rather than a silently-ignored kwarg.
351
+ from vllm.v1.engine import core_client as cc
352
+
353
+ with pytest.raises(TypeError, match="renderer"):
354
+ cc.InprocClient(
355
+ MagicMock(),
356
+ MagicMock(),
357
+ MagicMock(),
358
+ renderer=self._mock_renderer(),
359
+ )
360
+
361
+ def test_inproc_client_forwards_fail_callback_but_not_renderer(self):
362
+ # EngineCore args (incl. executor_fail_callback) pass through; the
363
+ # renderer kwarg is intercepted by InprocClient.__init__ (keyword-only
364
+ # after *), so it is never forwarded to EngineCore — which has no
365
+ # renderer parameter at all.
366
+ from vllm.v1.engine import core_client as cc
367
+
368
+ callback = MagicMock()
369
+ with patch.object(cc, "EngineCore") as mock_engine_core:
370
+ cc.InprocClient(
371
+ MagicMock(),
372
+ MagicMock(),
373
+ MagicMock(),
374
+ executor_fail_callback=callback,
375
+ )
376
+
377
+ mock_engine_core.assert_called_once()
378
+ args, kwargs = mock_engine_core.call_args
379
+ assert len(args) == 3 # vllm_config, executor_class, log_stats
380
+ assert "renderer" not in kwargs
381
+ assert kwargs.get("executor_fail_callback") is callback
382
+
383
+ def test_make_client_does_not_pass_renderer_to_inproc_client(self):
384
+ # In-process: the renderer must never reach InprocClient, so MM
385
+ # warmup stays a plain inline call inside renderer.warmup().
386
+ from vllm.v1.engine import core_client as cc
387
+
388
+ cfg = self._mock_config()
389
+ renderer = self._mock_renderer()
390
+
391
+ with (
392
+ patch.object(cc, "InprocClient") as mock_inproc,
393
+ patch.object(cc, "SyncMPClient") as mock_sync,
394
+ ):
395
+ client = cc.EngineCoreClient.make_client(
396
+ multiprocess_mode=False,
397
+ asyncio_mode=False,
398
+ vllm_config=cfg,
399
+ executor_class=MagicMock(),
400
+ log_stats=False,
401
+ renderer=renderer,
402
+ )
403
+
404
+ assert client is mock_inproc.return_value
405
+ mock_inproc.assert_called_once()
406
+ assert "renderer" not in mock_inproc.call_args.kwargs
407
+ mock_sync.assert_not_called()
408
+ renderer.start_mm_warmup_in_background.assert_not_called()
409
+
410
+ @pytest.mark.parametrize(
411
+ ("client_cls", "multiprocess_mode"),
412
+ [
413
+ # SyncMPClient forks engine-core: the renderer must be handed to
414
+ # it so the warmup fires after proc.start(), not before fork().
415
+ ("SyncMPClient", True),
416
+ ],
417
+ )
418
+ def test_make_client_passes_renderer(self, client_cls, multiprocess_mode):
419
+ from vllm.v1.engine import core_client as cc
420
+
421
+ cfg = self._mock_config()
422
+ renderer = self._mock_renderer()
423
+
424
+ with patch.object(cc, client_cls) as mock_client:
425
+ cc.EngineCoreClient.make_client(
426
+ multiprocess_mode=multiprocess_mode,
427
+ asyncio_mode=False,
428
+ vllm_config=cfg,
429
+ executor_class=MagicMock(),
430
+ log_stats=False,
431
+ renderer=renderer,
432
+ )
433
+
434
+ mock_client.assert_called_once()
435
+ assert mock_client.call_args.kwargs["renderer"] is renderer
436
+
437
+ @pytest.mark.parametrize(
438
+ ("client_cls", "dp_size", "external_lb"),
439
+ [
440
+ ("AsyncMPClient", 1, False),
441
+ ("DPAsyncMPClient", 2, True),
442
+ ],
443
+ )
444
+ def test_make_async_mp_client_passes_renderer(
445
+ self, client_cls, dp_size, external_lb
446
+ ):
447
+ from vllm.v1.engine import core_client as cc
448
+
449
+ cfg = self._mock_config()
450
+ cfg.parallel_config.data_parallel_size = dp_size
451
+ cfg.parallel_config.data_parallel_external_lb = external_lb
452
+ renderer = self._mock_renderer()
453
+
454
+ with patch.object(cc, client_cls) as mock_client:
455
+ cc.EngineCoreClient.make_async_mp_client(
456
+ cfg,
457
+ executor_class=MagicMock(),
458
+ log_stats=False,
459
+ renderer=renderer,
460
+ )
461
+
462
+ mock_client.assert_called_once()
463
+ assert mock_client.call_args.kwargs["renderer"] is renderer
464
+
465
+ def test_mp_client_starts_mm_warmup_only_with_renderer(self):
466
+ # MPClient must call renderer.start_mm_warmup_in_background when a
467
+ # renderer was given, and skip it (no crash) when not.
468
+ from vllm.v1.engine import core_client as cc
469
+
470
+ # No renderer: _start_mm_warmup is a no-op.
471
+ client = cc.MPClient.__new__(cc.MPClient)
472
+ client._renderer = None
473
+ client._start_mm_warmup()
474
+
475
+ # With a renderer: it starts the background MM warmup.
476
+ renderer = self._mock_renderer()
477
+ client2 = cc.MPClient.__new__(cc.MPClient)
478
+ client2._renderer = renderer
479
+ client2._start_mm_warmup()
480
+ renderer.start_mm_warmup_in_background.assert_called_once()
vllm/renderers/base.py
+117
-24
@@ -5,7 +5,7 @@ import time
5
import weakref
6
from abc import ABC, abstractmethod
7
from collections.abc import Mapping, Sequence
8
-from concurrent.futures import ThreadPoolExecutor
8
+from concurrent.futures import Future, ThreadPoolExecutor
9
from contextlib import ExitStack
10
from functools import cached_property
11
from typing import TYPE_CHECKING, Any, Generic, overload
@@ -85,6 +85,26 @@ class BaseRenderer(ABC, Generic[_T]):
85
86
self.tokenizer = tokenizer
87
88
+ # Background multimodal warmup task (a future returned by
89
+ # _mm_executor) so it overlaps engine-core initialization while
90
+ # staying serialized with the serving path through the same
91
+ # single-worker executor. None when no background warmup has been
92
+ # launched (or it has already been joined). Routing the warmup through
93
+ # _mm_executor (max_workers=1) guarantees it can never run
94
+ # concurrently with a serving request inside the numba workqueue
95
+ # parallel region, which would otherwise hit numba's
96
+ # "Concurrent access has been detected" fatal abort (see
97
+ # vllm/utils/jit_monitor.py, which forces the workqueue layer).
98
+ # Joined by the first of reset_mm_cache / warmup / shutdown so the
99
+ # mm_processor_cache is also never touched concurrently by the warmup
100
+ # and the serving path.
101
+ self._mm_warmup_future: Future[None] | None = None
102
+ # Whether warmup_mm has already run. Survives _join_mm_warmup (which
103
+ # clears the thread pointer), so warmup() can tell that the background
104
+ # warmup already ran even after reset_mm_cache joined it first, and
105
+ # avoid running warmup_mm a second time.
106
+ self._mm_warmup_done: bool = False
107
+
108
# Thread pool executor for blocking tokenizer operations. The
109
# multimodal processor receives a deep-copied tokenizer (see #36557)
110
# so it is safe to run tokenization and MM preprocessing concurrently.
@@ -243,12 +263,97 @@ class BaseRenderer(ABC, Generic[_T]):
263
elapsed = time.perf_counter() - start_time
264
logger.info("%s warmup completed in %.3fs", log_prefix, elapsed)
265
266
+ def warmup_mm(self) -> None:
267
+ # Run only the multimodal warmup (mm + readonly mm), serially.
268
+ # Idempotent: runs at most once. A background warmup
269
+ # (start_mm_warmup_in_background) may already have run it, and
270
+ # reset_mm_cache may have joined that background warmup task before
271
+ # warmup() reaches this point — the _mm_warmup_done flag survives the
272
+ # join (the future pointer does not), so warmup() can still tell it
273
+ # already ran.
274
+ if self._mm_warmup_done:
275
+ return
276
+ try:
277
+ # prevent MM processor hangs
278
+ with set_default_torch_num_threads(1):
279
+ if self.mm_processor:
280
+ try:
281
+ logger.debug("Warming up multi-modal processing...")
282
+ self._warmup_mm_processor(
283
+ self.mm_processor,
284
+ log_prefix="Multi-modal",
285
+ )
286
+ except Exception:
287
+ logger.warning("Multi-modal warmup failed")
288
+ finally:
289
+ self.clear_mm_cache()
290
+
291
+ if self._readonly_mm_processor is not None:
292
+ try:
293
+ logger.debug("Warming up readonly multi-modal processing...")
294
+ self._warmup_mm_processor(
295
+ self._readonly_mm_processor,
296
+ log_prefix="Readonly multi-modal",
297
+ )
298
+ except Exception:
299
+ logger.warning("Readonly multi-modal warmup failed")
300
+ finally:
301
+ self._clear_processor_cache(self._readonly_mm_processor)
302
+ finally:
303
+ self._mm_warmup_done = True
304
+
305
+ def start_mm_warmup_in_background(self) -> None:
306
+ # Launch the multimodal warmup as a task on the single-worker
307
+ # _mm_executor so it overlaps with engine-core initialization
308
+ # (model loading) while staying serialized with the serving path.
309
+ #
310
+ # Why the same executor as serving (not a separate Thread):
311
+ # _process_multimodal runs on _mm_executor (max_workers=1) and
312
+ # enters the numba workqueue parallel region (e.g. Kimi K2.5 vision
313
+ # preprocessing via kimi_k25_vision_fused). A separate warmup Thread
314
+ # would run concurrently with that single serving worker, so both
315
+ # threads would enter the numba workqueue parallel region at the same
316
+ # time and trip numba's "Concurrent access has been detected" ->
317
+ # "Fatal Python error: Aborted" (see vllm/utils/jit_monitor.py, which
318
+ # forces the non-threadsafe workqueue layer). Submitting the warmup to
319
+ # the same single-worker executor makes warmup and serving
320
+ # physically incapable of overlapping on the numba threading layer,
321
+ # while still overlapping at the process level with the (forked)
322
+ # engine-core init in the main thread.
323
+ #
324
+ # The warmup task is joined by the first of: reset_mm_cache, warmup, or
325
+ # shutdown. This also guarantees the frontend mm_processor_cache —
326
+ # which is not designed for concurrent access (its get_and_update must
327
+ # stay ordered with the engine-core cache) — is never touched
328
+ # concurrently by the warmup and the serving path.
329
+ if self.mm_processor is None and self._readonly_mm_processor is None:
330
+ return # text-only model: nothing to warm up
331
+ if self._mm_warmup_future is not None:
332
+ return # already launched
333
+ self._mm_warmup_future = self._mm_executor.submit(self.warmup_mm)
334
+
335
+ def _join_mm_warmup(self) -> None:
336
+ # Wait for the background MM warmup task to finish, if one was started.
337
+ # Called from the main / event-loop thread (reset_mm_cache, warmup,
338
+ # shutdown) — never from inside the _mm_executor worker — so
339
+ # waiting on the future cannot deadlock.
340
+ future = getattr(self, "_mm_warmup_future", None)
341
+ if future is not None:
342
+ future.result()
343
+ self._mm_warmup_future = None
344
+
345
def warmup(self, chat_params: ChatParams) -> None:
346
"""
347
Warm up this renderer to avoid first-request latency.
348
349
For chat requests:
350
- Jinja2 template compilation
351
+
352
+ If a background multimodal warmup was launched (to overlap
353
+ engine-core init) it is joined first so it is not still touching the
354
+ mm_processor_cache. warmup_mm is idempotent, so the call below is a
355
+ no-op if the background warmup — or a prior reset_mm_cache that joined
356
+ it — already ran it.
357
"""
358
from vllm.entrypoints.chat_utils import ChatTemplateResolutionError
359
@@ -267,29 +372,12 @@ class BaseRenderer(ABC, Generic[_T]):
372
except Exception:
373
logger.warning("Chat template warmup failed", exc_info=True)
374
270
- if self.mm_processor:
271
- try:
272
- logger.debug("Warming up multi-modal processing...")
273
- self._warmup_mm_processor(
274
- self.mm_processor,
275
- log_prefix="Multi-modal",
276
- )
277
- except Exception:
278
- logger.warning("Multi-modal warmup failed")
279
- finally:
280
- self.clear_mm_cache()
281
-
282
- if self._readonly_mm_processor is not None:
283
- try:
284
- logger.debug("Warming up readonly multi-modal processing...")
285
- self._warmup_mm_processor(
286
- self._readonly_mm_processor,
287
- log_prefix="Readonly multi-modal",
288
- )
289
- except Exception:
290
- logger.warning("Readonly multi-modal warmup failed")
291
- finally:
292
- self._clear_processor_cache(self._readonly_mm_processor)
375
+ if self._mm_warmup_future is not None:
376
+ # Join a background MM warmup launched to overlap engine-core init.
377
+ self._join_mm_warmup()
378
+ # Idempotent: no-op if the background warmup (or a prior reset_mm_cache)
379
+ # already ran the MM warmup.
380
+ self.warmup_mm()
381
382
async def clear_mm_cache_async(self) -> None:
383
"""Serialize clear_mm_cache through the multimodal executor to avoid
@@ -297,6 +385,11 @@ class BaseRenderer(ABC, Generic[_T]):
385
await self._clear_mm_cache_async()
386
387
def shutdown(self) -> None:
388
+ # Wait for any background MM warmup to finish first: it runs on the
389
+ # _mm_executor and touches the mm_processor_cache, so it must be
390
+ # quiescent before _resources.close() tears down the executors and
391
+ # the cache (both are registered as resources in __init__).
392
+ self._join_mm_warmup()
393
self._resources.close()
394
395
def get_bos_token_id(self) -> int | None:
vllm/v1/engine/async_llm.py
+8
@@ -153,6 +153,10 @@ class AsyncLLM(EngineClient):
153
)
154
155
# EngineCore (starts the engine in background process).
156
+ # Hand the renderer to the client so it can start the frontend MM
157
+ # warmup only after engine-core fork (the why is in
158
+ # BaseRenderer.start_mm_warmup_in_background). The warmup is joined
159
+ # by reset_mm_cache / warmup / shutdown.
160
self.engine_core = EngineCoreClient.make_async_mp_client(
161
vllm_config=vllm_config,
162
executor_class=executor_class,
@@ -160,6 +164,7 @@ class AsyncLLM(EngineClient):
164
client_addresses=client_addresses,
165
client_count=client_count,
166
client_index=client_index,
167
+ renderer=renderer,
168
)
169
170
# Loggers.
@@ -1023,6 +1028,9 @@ class AsyncLLM(EngineClient):
1028
await asyncio.gather(*coros)
1029
1030
async def reset_mm_cache(self) -> None:
1031
+ # Join the background MM warmup first: the mm_processor_cache is not
1032
+ # safe for concurrent access with its apply/clear.
1033
+ self.renderer._join_mm_warmup()
1034
await self.renderer.clear_mm_cache_async()
1035
await self.engine_core.reset_mm_cache_async()
1036
vllm/v1/engine/core_client.py
+82
-8
@@ -26,6 +26,7 @@ from vllm.config import VllmConfig
26
from vllm.envs import VLLM_ENGINE_READY_TIMEOUT_S
27
from vllm.logger import init_logger
28
from vllm.lora.request import LoRARequest
29
+from vllm.renderers import BaseRenderer
30
from vllm.tasks import SupportedTask
31
from vllm.tracing import instrument
32
from vllm.utils.async_utils import in_loop
@@ -93,7 +94,15 @@ class EngineCoreClient(ABC):
94
vllm_config: VllmConfig,
95
executor_class: type[Executor],
96
log_stats: bool,
97
+ renderer: BaseRenderer | None = None,
98
) -> "EngineCoreClient":
99
+ # renderer is passed through to the multiprocess clients, which start
100
+ # the frontend MM warmup (renderer.start_mm_warmup_in_background) once
101
+ # the engine-core processes have been forked, so warmup overlaps
102
+ # engine-core load without a live thread at fork() time. In-process
103
+ # clients do not take a renderer: the engine core is built in this
104
+ # process, so there is no other process to overlap with and the MM
105
+ # warmup runs synchronously inside renderer.warmup() instead.
106
# TODO: support this for debugging purposes.
107
if asyncio_mode and not multiprocess_mode:
108
raise NotImplementedError(
@@ -103,11 +112,19 @@ class EngineCoreClient(ABC):
112
113
if multiprocess_mode and asyncio_mode:
114
return EngineCoreClient.make_async_mp_client(
106
- vllm_config, executor_class, log_stats
115
+ vllm_config,
116
+ executor_class,
117
+ log_stats,
118
+ renderer=renderer,
119
)
120
121
if multiprocess_mode and not asyncio_mode:
110
- return SyncMPClient(vllm_config, executor_class, log_stats)
122
+ return SyncMPClient(
123
+ vllm_config,
124
+ executor_class,
125
+ log_stats,
126
+ renderer=renderer,
127
+ )
128
129
return InprocClient(vllm_config, executor_class, log_stats)
130
@@ -120,6 +137,7 @@ class EngineCoreClient(ABC):
137
client_addresses: dict[str, Any] | None = None,
138
client_count: int = 1,
139
client_index: int = 0,
140
+ renderer: BaseRenderer | None = None,
141
) -> "AsyncMPClient":
142
parallel_config = vllm_config.parallel_config
143
client_args = (
@@ -133,10 +151,19 @@ class EngineCoreClient(ABC):
151
if parallel_config.data_parallel_size > 1:
152
if parallel_config.data_parallel_external_lb:
153
# External load balancer - client per DP rank.
136
- return DPAsyncMPClient(*client_args)
154
+ return DPAsyncMPClient(
155
+ *client_args,
156
+ renderer=renderer,
157
+ )
158
# Internal load balancer - client balances to all DP ranks.
138
- return DPLBAsyncMPClient(*client_args)
139
- return AsyncMPClient(*client_args)
159
+ return DPLBAsyncMPClient(
160
+ *client_args,
161
+ renderer=renderer,
162
+ )
163
+ return AsyncMPClient(
164
+ *client_args,
165
+ renderer=renderer,
166
+ )
167
168
@abstractmethod
169
def shutdown(self, timeout: float | None = None) -> None: ...
@@ -313,8 +340,23 @@ class InprocClient(EngineCoreClient):
340
* pulls EngineCoreOutputs by stepping the EngineCore
341
"""
342
316
- def __init__(self, *args, **kwargs):
317
- self.engine_core = EngineCore(*args, **kwargs)
343
+ # Takes no renderer: this class has no _start_mm_warmup (only MPClient
344
+ # does), so a renderer would never be used here. EngineCore is built in
345
+ # this process and MM warmup runs synchronously inside renderer.warmup().
346
+ def __init__(
347
+ self,
348
+ vllm_config: VllmConfig,
349
+ executor_class: type[Executor],
350
+ log_stats: bool,
351
+ *,
352
+ executor_fail_callback: Callable | None = None,
353
+ ):
354
+ self.engine_core = EngineCore(
355
+ vllm_config,
356
+ executor_class,
357
+ log_stats,
358
+ executor_fail_callback=executor_fail_callback,
359
+ )
360
361
def get_output(self) -> EngineCoreOutputs:
362
outputs, model_executed = self.engine_core.step_fn()
@@ -520,8 +562,10 @@ class MPClient(EngineCoreClient):
562
executor_class: type[Executor],
563
log_stats: bool,
564
client_addresses: dict[str, Any] | None = None,
565
+ renderer: BaseRenderer | None = None,
566
):
567
self.vllm_config = vllm_config
568
+ self._renderer: BaseRenderer | None = renderer
569
570
# ZMQ setup.
571
sync_ctx = zmq.Context(io_threads=2)
@@ -583,6 +627,9 @@ class MPClient(EngineCoreClient):
627
)
628
finally:
629
actual_address_pipe.close()
630
+ # Engines are managed externally: this process does not fork
631
+ # them, so there is no fork race; start the MM warmup now.
632
+ self._start_mm_warmup()
633
else:
634
# Engines are managed by this client.
635
addresses = get_engine_zmq_addresses(vllm_config)
@@ -614,6 +661,14 @@ class MPClient(EngineCoreClient):
661
coordinator = engine_launch.coordinator
662
addresses = engine_launch.addresses
663
tensor_queue = engine_launch.tensor_queue
664
+ # Engine-core processes have now all been forked/started
665
+ # (CoreEngineProcManager.proc.start()). It is now safe to
666
+ # launch the frontend background MM warmup: it must not
667
+ # run while fork() is in flight (a live thread holding a
668
+ # lock would deadlock the forked child), but the
669
+ # engine-core model load (minutes) that follows is exactly
670
+ # what the warmup should overlap with.
671
+ self._start_mm_warmup()
672
673
self.stats_update_address = addresses.frontend_stats_publish_address
674
if coordinator is not None:
@@ -708,6 +763,14 @@ class MPClient(EngineCoreClient):
763
def dp_engines_running(self) -> bool:
764
return self.engines_running
765
766
+ def _start_mm_warmup(self) -> None:
767
+ # Called once the engine-core process(es) have been created (forked or
768
+ # externally managed). Overlap the frontend MM warmup with the
769
+ # engine-core model load. This is a no-op when no renderer was passed
770
+ # (e.g. text-only serving or tests).
771
+ if self._renderer is not None:
772
+ self._renderer.start_mm_warmup_in_background()
773
+
774
def start_engine_core_monitor(self):
775
"""Start a monitor thread for engine core processes."""
776
engine_manager = self.resources.engine_manager
@@ -808,13 +871,18 @@ class SyncMPClient(MPClient):
871
872
@instrument(span_name="SyncMPClient init")
873
def __init__(
811
- self, vllm_config: VllmConfig, executor_class: type[Executor], log_stats: bool
874
+ self,
875
+ vllm_config: VllmConfig,
876
+ executor_class: type[Executor],
877
+ log_stats: bool,
878
+ renderer: BaseRenderer | None = None,
879
):
880
super().__init__(
881
asyncio_mode=False,
882
vllm_config=vllm_config,
883
executor_class=executor_class,
884
log_stats=log_stats,
885
+ renderer=renderer,
886
)
887
888
self.is_dp = self.vllm_config.parallel_config.data_parallel_size > 1
@@ -987,6 +1055,7 @@ class AsyncMPClient(MPClient):
1055
client_addresses: dict[str, Any] | None = None,
1056
client_count: int = 1,
1057
client_index: int = 0,
1058
+ renderer: BaseRenderer | None = None,
1059
):
1060
super().__init__(
1061
asyncio_mode=True,
@@ -994,6 +1063,7 @@ class AsyncMPClient(MPClient):
1063
executor_class=executor_class,
1064
log_stats=log_stats,
1065
client_addresses=client_addresses,
1066
+ renderer=renderer,
1067
)
1068
1069
self.client_count = client_count
@@ -1262,6 +1332,7 @@ class DPAsyncMPClient(AsyncMPClient):
1332
client_addresses: dict[str, Any] | None = None,
1333
client_count: int = 1,
1334
client_index: int = 0,
1335
+ renderer: BaseRenderer | None = None,
1336
):
1337
self.current_wave = 0
1338
@@ -1272,6 +1343,7 @@ class DPAsyncMPClient(AsyncMPClient):
1343
client_addresses,
1344
client_count,
1345
client_index,
1346
+ renderer,
1347
)
1348
1349
# List of [waiting, running, kv_cache_usage] per engine.
@@ -1444,6 +1516,7 @@ class DPLBAsyncMPClient(DPAsyncMPClient):
1516
client_addresses: dict[str, Any] | None = None,
1517
client_count: int = 1,
1518
client_index: int = 0,
1519
+ renderer: BaseRenderer | None = None,
1520
):
1521
self.client_count = client_count
1522
@@ -1460,6 +1533,7 @@ class DPLBAsyncMPClient(DPAsyncMPClient):
1533
client_addresses,
1534
client_count,
1535
client_index,
1536
+ renderer,
1537
)
1538
1539
assert len(self.core_engines) > 1
vllm/v1/engine/llm_engine.py
+8
@@ -102,12 +102,17 @@ class LLMEngine:
102
)
103
104
# EngineCore (gets EngineCoreRequests and gives EngineCoreOutputs)
105
+ # Hand the renderer to the client. In multiprocess mode the client
106
+ # starts the MM warmup only after engine-core fork (the why is in
107
+ # BaseRenderer.start_mm_warmup_in_background); InprocClient takes no
108
+ # renderer, so MM warmup stays inside renderer.warmup() there.
109
self.engine_core = EngineCoreClient.make_client(
110
multiprocess_mode=multiprocess_mode,
111
asyncio_mode=False,
112
vllm_config=vllm_config,
113
executor_class=executor_class,
114
log_stats=self.log_stats,
115
+ renderer=renderer,
116
)
117
118
self.logger_manager: StatLoggerManager | None = None
@@ -343,6 +348,9 @@ class LLMEngine:
348
self.engine_core.profile(False)
349
350
def reset_mm_cache(self):
351
+ # Join the background MM warmup first: the mm_processor_cache is not
352
+ # safe for concurrent access with its apply/clear.
353
+ self.renderer._join_mm_warmup()
354
self.renderer.clear_mm_cache()
355
self.engine_core.reset_mm_cache()
356