| 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() |