main
py 1,946 lines 67.5 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import contextlib
4 import pathlib
5 from copy import deepcopy
6
7 from tblib import pickling_support
8
9 # ruff: noqa
10
11 # Install support for pickling exceptions so that we can nicely propagate
12 # failures from tests running in a subprocess.
13 # This should be run before any custom exception subclasses are defined.
14 pickling_support.install()
15
16 import http.server
17 import json
18 import math
19 import mimetypes
20 import os
21 import socket
22 import tempfile
23 import threading
24 from collections.abc import Generator
25 from contextlib import nullcontext
26 from enum import Enum
27 from typing import Any, Callable, TypedDict, TypeVar, cast, TYPE_CHECKING, Optional
28
29 import numpy as np
30 import pytest
31 import torch
32 import torch.nn as nn
33 import torch.nn.functional as F
34 from vllm.transformers_utils.repo_utils import hf_api
35 from PIL import Image
36 from transformers import (
37 AutoConfig,
38 AutoModelForCausalLM,
39 AutoTokenizer,
40 BatchEncoding,
41 BatchFeature,
42 )
43 from transformers.models.auto.auto_factory import _BaseAutoModelClass
44
45 from tests.models.utils import (
46 TokensTextLogprobs,
47 TokensTextLogprobsPromptLogprobs,
48 softmax,
49 )
50 from vllm import LLM, SamplingParams, envs
51 from vllm.assets.audio import AudioAsset
52 from vllm.assets.image import ImageAsset
53 from vllm.assets.video import VideoAsset
54 from vllm.config.cache import CacheConfig
55 from vllm.config.model import ConvertOption, RunnerOption, _get_and_verify_dtype
56 from vllm.connections import global_http_connection
57 from vllm.distributed import (
58 cleanup_dist_env_and_memory,
59 init_distributed_environment,
60 initialize_model_parallel,
61 )
62 from vllm.logger import init_logger
63 from vllm.logprobs import Logprob
64 from vllm.multimodal.media import MediaWithBytes
65 from vllm.multimodal.utils import fetch_image
66 from vllm.outputs import RequestOutput
67 from vllm.platforms import current_platform
68 from vllm.sampling_params import BeamSearchParams
69 from vllm.transformers_utils.repo_utils import with_retry
70 from vllm.transformers_utils.utils import maybe_model_redirect
71 from vllm.utils.collection_utils import is_list_of
72 from vllm.utils.torch_utils import set_default_torch_num_threads
73
74 from torch._inductor.utils import fresh_cache
75
76
77 if TYPE_CHECKING:
78 from transformers import PythonBackend, TokenizersBackend
79 from transformers.generation.utils import GenerateOutput
80
81
82 logger = init_logger(__name__)
83
84
85 @pytest.fixture
86 def sample_json_schema():
87 return {
88 "type": "object",
89 "properties": {
90 "name": {"type": "string"},
91 "age": {"type": "integer"},
92 "skills": {
93 "type": "array",
94 "items": {
95 "type": "string",
96 },
97 },
98 "grade": {
99 "type": "string",
100 "pattern": "^[A-D]$",
101 },
102 "email": {
103 "type": "string",
104 "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$",
105 },
106 "work_history": {
107 "type": "array",
108 "items": {
109 "type": "object",
110 "properties": {
111 "company": {"type": "string"},
112 "duration": {
113 "type": "number",
114 "minimum": 0.0,
115 "maximum": 100.0,
116 },
117 "position": {"type": "string"},
118 },
119 "required": ["company", "duration", "position"],
120 "additionalProperties": False,
121 },
122 "minItems": 0,
123 "maxItems": 3,
124 },
125 },
126 "required": ["name", "age", "skills", "grade", "email", "work_history"],
127 "additionalProperties": False,
128 "minProperties": 1,
129 "maxProperties": 10,
130 }
131
132
133 _TEST_DIR = os.path.dirname(__file__)
134 _TEST_PROMPTS = [os.path.join(_TEST_DIR, "prompts", "example.txt")]
135 _LONG_PROMPTS = [os.path.join(_TEST_DIR, "prompts", "summary.txt")]
136 _SYS_MSG = os.path.join(_TEST_DIR, "system_messages", "sonnet3.5_nov2024.txt")
137
138 _M = TypeVar("_M")
139
140 _PromptMultiModalInput = list[_M] | list[list[_M]]
141
142 PromptImageInput = _PromptMultiModalInput[Image.Image]
143 PromptAudioInput = _PromptMultiModalInput[tuple[np.ndarray, int]]
144 PromptVideoInput = _PromptMultiModalInput[np.ndarray]
145
146
147 def _read_prompts(filename: str) -> list[str]:
148 with open(filename) as f:
149 prompts = f.readlines()
150 return prompts
151
152
153 class ImageAssetPrompts(TypedDict):
154 stop_sign: str
155 cherry_blossom: str
156
157
158 class ImageTestAssets(list[ImageAsset]):
159 def __init__(self) -> None:
160 super().__init__(
161 [
162 ImageAsset("stop_sign"),
163 ImageAsset("cherry_blossom"),
164 ]
165 )
166
167 def prompts(self, prompts: ImageAssetPrompts) -> list[str]:
168 """
169 Convenience method to define the prompt for each test image.
170
171 The order of the returned prompts matches the order of the
172 assets when iterating through this object.
173 """
174 return [prompts["stop_sign"], prompts["cherry_blossom"]]
175
176
177 class VideoAssetPrompts(TypedDict):
178 baby_reading: str
179
180
181 class VideoTestAssets(list[VideoAsset]):
182 def __init__(self) -> None:
183 super().__init__(
184 [
185 VideoAsset("baby_reading"),
186 ]
187 )
188
189 def prompts(self, prompts: VideoAssetPrompts) -> list[str]:
190 return [prompts["baby_reading"]]
191
192
193 class AudioAssetPrompts(TypedDict):
194 mary_had_lamb: str
195 winning_call: str
196
197
198 class AudioTestAssets(list[AudioAsset]):
199 def __init__(self) -> None:
200 super().__init__(
201 [
202 AudioAsset("mary_had_lamb"),
203 AudioAsset("winning_call"),
204 ]
205 )
206
207 def prompts(self, prompts: AudioAssetPrompts) -> list[str]:
208 return [prompts["mary_had_lamb"], prompts["winning_call"]]
209
210
211 IMAGE_ASSETS = ImageTestAssets()
212 """Singleton instance of {class}`ImageTestAssets`."""
213 VIDEO_ASSETS = VideoTestAssets()
214 """Singleton instance of {class}`VideoTestAssets`."""
215 AUDIO_ASSETS = AudioTestAssets()
216 """Singleton instance of {class}`AudioTestAssets`."""
217
218
219 @pytest.fixture(autouse=True)
220 def init_test_http_connection():
221 # pytest_asyncio may use a different event loop per test
222 # so we need to make sure the async client is created anew
223 global_http_connection.reuse_client = False
224
225
226 @pytest.fixture
227 def dist_init():
228 from tests.utils import ensure_current_vllm_config
229
230 # Close the fd returned by mkstemp; FileStore opens the path itself.
231 # Leaving it open leaks one FD per test and eventually exhausts the
232 # ulimit, causing FileStore's destructor to throw c10::DistStoreError
233 # ("Too many open files") during gc and abort the process.
234 fd, temp_file = tempfile.mkstemp()
235 os.close(fd)
236
237 try:
238 with ensure_current_vllm_config():
239 init_distributed_environment(
240 world_size=1,
241 rank=0,
242 distributed_init_method=f"file://{temp_file}",
243 local_rank=0,
244 backend="gloo" if current_platform.is_cpu() else "nccl",
245 )
246 initialize_model_parallel(1, 1)
247 yield
248 cleanup_dist_env_and_memory()
249 finally:
250 with contextlib.suppress(OSError):
251 os.unlink(temp_file)
252
253
254 @pytest.fixture
255 def default_vllm_config():
256 """Set a default VllmConfig for tests that directly test CustomOps or pathways
257 that use get_current_vllm_config() outside of a full engine context.
258 """
259 from vllm.config import VllmConfig, set_current_vllm_config
260
261 config = VllmConfig()
262 with set_current_vllm_config(config):
263 yield config
264
265
266 @pytest.fixture()
267 def should_do_global_cleanup_after_test(request) -> bool:
268 """Allow subdirectories to skip global cleanup by overriding this fixture.
269 This can provide a ~10x speedup for non-GPU unit tests since they don't need
270 to initialize torch.
271 """
272
273 return not request.node.get_closest_marker("skip_global_cleanup")
274
275
276 @pytest.fixture(autouse=True)
277 def cleanup_fixture(should_do_global_cleanup_after_test: bool):
278 yield
279 if should_do_global_cleanup_after_test:
280 cleanup_dist_env_and_memory()
281
282
283 @pytest.fixture
284 def workspace_init():
285 """Initialize the workspace manager for tests that need it.
286
287 This fixture initializes the workspace manager with the current
288 platform's accelerator device if available, and resets it after the test
289 completes. Tests that create a full vLLM engine should NOT use this
290 fixture as the engine will initialize the workspace manager itself.
291 """
292 from vllm.v1.worker.workspace import (
293 init_workspace_manager,
294 reset_workspace_manager,
295 )
296
297 if torch.accelerator.is_available():
298 init_workspace_manager(torch.device(0))
299 yield
300 reset_workspace_manager()
301
302
303 @pytest.fixture(autouse=True)
304 def dynamo_reset():
305 yield
306 torch._dynamo.reset()
307
308
309 @pytest.fixture
310 def example_prompts() -> list[str]:
311 return [prompt for filename in _TEST_PROMPTS for prompt in _read_prompts(filename)]
312
313
314 @pytest.fixture
315 def example_system_message() -> str:
316 with open(_SYS_MSG) as f:
317 return f.read()
318
319
320 @pytest.fixture(scope="session")
321 def image_assets() -> ImageTestAssets:
322 return IMAGE_ASSETS
323
324
325 @pytest.fixture(scope="session")
326 def video_assets() -> VideoTestAssets:
327 return VIDEO_ASSETS
328
329
330 @pytest.fixture(scope="session")
331 def audio_assets() -> AudioTestAssets:
332 return AUDIO_ASSETS
333
334
335 _T = TypeVar("_T", nn.Module, torch.Tensor, BatchEncoding, BatchFeature, dict)
336 _R = TypeVar("_R")
337
338
339 def _fix_v4_tied_weights_keys(model_cls: type) -> None:
340 """Convert a v4 list-format _tied_weights_keys to the transformers v5 dict form."""
341 tied = getattr(model_cls, "_tied_weights_keys", None)
342 if not isinstance(tied, list) or not tied:
343 return
344 result = {
345 k: "model.embed_tokens.weight"
346 for k in tied
347 if "lm_head" in k and k.endswith(".weight")
348 }
349 if result:
350 setattr(model_cls, "_tied_weights_keys", result)
351
352
353 class HfRunner:
354 def get_default_device(self):
355 from vllm.platforms import current_platform
356
357 return "cpu" if current_platform.is_cpu() else current_platform.device_type
358
359 def wrap_device(self, x: _T, device: str | None = None) -> _T:
360 if x is None or isinstance(x, (bool,)):
361 return x
362
363 if device is None:
364 device = self.device
365
366 if isinstance(x, dict):
367 return {k: self.wrap_device(v, device) for k, v in x.items()}
368
369 if hasattr(x, "device") and x.device.type == device:
370 return x
371
372 return x.to(device)
373
374 def __init__(
375 self,
376 model_name: str,
377 dtype: str = "auto",
378 *,
379 revision: str | None = None,
380 model_kwargs: dict[str, Any] | None = None,
381 trust_remote_code: bool = True,
382 is_sentence_transformer: bool = False,
383 is_cross_encoder: bool = False,
384 skip_tokenizer_init: bool = False,
385 auto_cls: type[_BaseAutoModelClass] = AutoModelForCausalLM,
386 tokenizer_name: str | None = None,
387 processor: Any | None = None,
388 # Set this to avoid hanging issue
389 default_torch_num_threads: int | None = None,
390 ) -> None:
391 init_ctx = (
392 nullcontext()
393 if default_torch_num_threads is None
394 else set_default_torch_num_threads(default_torch_num_threads)
395 )
396
397 with init_ctx:
398 self._init(
399 model_name=model_name,
400 dtype=dtype,
401 revision=revision,
402 model_kwargs=model_kwargs,
403 trust_remote_code=trust_remote_code,
404 is_sentence_transformer=is_sentence_transformer,
405 is_cross_encoder=is_cross_encoder,
406 skip_tokenizer_init=skip_tokenizer_init,
407 auto_cls=auto_cls,
408 tokenizer_name=tokenizer_name,
409 processor=processor,
410 )
411
412 def _init(
413 self,
414 model_name: str,
415 dtype: str = "auto",
416 *,
417 revision: str | None = None,
418 model_kwargs: dict[str, Any] | None = None,
419 trust_remote_code: bool = True,
420 is_sentence_transformer: bool = False,
421 is_cross_encoder: bool = False,
422 skip_tokenizer_init: bool = False,
423 auto_cls: type[_BaseAutoModelClass] = AutoModelForCausalLM,
424 tokenizer_name: str | None = None,
425 processor: Any | None = None,
426 ) -> None:
427 model_name = maybe_model_redirect(model_name)
428 self.model_name = model_name
429
430 self.config = AutoConfig.from_pretrained(
431 model_name,
432 trust_remote_code=trust_remote_code,
433 )
434 # HF runner should use the HF config so that it's consistent with the HF model
435 if self.config.__module__.startswith("vllm.transformers_utils.configs"):
436 from transformers.models.auto.configuration_auto import CONFIG_MAPPING
437
438 del CONFIG_MAPPING._extra_content[self.config.model_type]
439 self.config = AutoConfig.from_pretrained(
440 model_name,
441 trust_remote_code=trust_remote_code,
442 )
443 self.device = self.get_default_device()
444 self.dtype = dtype = _get_and_verify_dtype(
445 self.model_name,
446 self.config,
447 dtype=dtype,
448 is_pooling_model=is_sentence_transformer or is_cross_encoder,
449 config_format="hf",
450 )
451
452 model_kwargs = model_kwargs if model_kwargs is not None else {}
453 model_kwargs.setdefault("dtype", dtype)
454
455 if is_sentence_transformer:
456 # Lazy init required for AMD CI
457 from sentence_transformers import SentenceTransformer
458
459 self.model = SentenceTransformer(
460 model_name,
461 revision=revision,
462 device=self.device,
463 model_kwargs=model_kwargs,
464 trust_remote_code=trust_remote_code,
465 )
466 elif is_cross_encoder:
467 # Lazy init required for AMD CI
468 from sentence_transformers import CrossEncoder
469
470 self.model = CrossEncoder(
471 model_name,
472 revision=revision,
473 device=self.device,
474 automodel_args=model_kwargs,
475 trust_remote_code=trust_remote_code,
476 )
477 else:
478 if trust_remote_code and hasattr(self.config, "auto_map"):
479 cls_ref = self.config.auto_map.get(auto_cls.__name__)
480 if cls_ref is not None:
481 from vllm.transformers_utils.dynamic_module import (
482 try_get_class_from_dynamic_module,
483 )
484
485 model_cls = try_get_class_from_dynamic_module(
486 cls_ref,
487 model_name,
488 trust_remote_code=trust_remote_code,
489 warn_on_fail=False,
490 )
491 if model_cls is not None:
492 _fix_v4_tied_weights_keys(model_cls)
493
494 from transformers.integrations import is_deepspeed_zero3_enabled
495
496 # On ROCm, avoid a converted CPU copy when downcasting FP32 references.
497 # Same-dtype CPU loads can stay lazily mapped and use less host RAM.
498 # Leave customized, remote, and quantized loading unchanged.
499 if (
500 current_platform.is_rocm()
501 and self.device == "cuda"
502 and auto_cls is AutoModelForCausalLM
503 and revision is None
504 and model_kwargs.keys() == {"dtype"}
505 and self.config.dtype == torch.float32
506 and model_kwargs["dtype"] in (torch.float16, torch.bfloat16)
507 and not (trust_remote_code and hasattr(self.config, "auto_map"))
508 and getattr(self.config, "quantization_config", None) is None
509 and getattr(
510 self.config.get_text_config(decoder=True),
511 "quantization_config",
512 None,
513 )
514 is None
515 and not is_deepspeed_zero3_enabled()
516 ):
517 model_kwargs = {
518 **model_kwargs,
519 "device_map": torch.device(
520 "cuda", torch.accelerator.current_device_index()
521 ),
522 }
523
524 model = cast(
525 nn.Module,
526 auto_cls.from_pretrained(
527 model_name,
528 revision=revision,
529 trust_remote_code=trust_remote_code,
530 **model_kwargs,
531 ),
532 )
533
534 # in case some unquantized custom models are not in same dtype
535 if getattr(model, "quantization_method", None) is None and any(
536 p.dtype != self.dtype for p in model.parameters()
537 ):
538 model = model.to(dtype=self.dtype)
539
540 if len({p.device for p in model.parameters()}) < 2:
541 model = model.to(device=self.device)
542
543 self.model = model
544
545 if not skip_tokenizer_init:
546 self.tokenizer: "PythonBackend | TokenizersBackend" = (
547 AutoTokenizer.from_pretrained(
548 tokenizer_name or model_name,
549 trust_remote_code=trust_remote_code,
550 )
551 )
552
553 if processor is not None:
554 self.processor = processor
555 else:
556 # don't put this import at the top level
557 # it will call torch.accelerator.device_count()
558 from transformers import AutoProcessor
559
560 # A concurrent refresh of the shared HF cache can briefly hide
561 # processor configuration files. Retry just as model config loading
562 # does in vllm.transformers_utils.config.
563 self.processor = with_retry(
564 lambda: AutoProcessor.from_pretrained(
565 model_name,
566 trust_remote_code=trust_remote_code,
567 ),
568 f"Error loading processor for {model_name}",
569 )
570 if skip_tokenizer_init:
571 if self.processor is None:
572 raise ValueError(
573 "skip_tokenizer_init=True requires processor initialization."
574 )
575 self.tokenizer = self.processor.tokenizer
576
577 def get_inputs(
578 self,
579 prompts: list[str] | list[list[int]],
580 images: PromptImageInput | None = None,
581 videos: PromptVideoInput | None = None,
582 audios: PromptAudioInput | None = None,
583 tokenization_kwargs: dict[str, Any] | None = None,
584 ) -> list[BatchFeature | BatchEncoding | dict[str, torch.Tensor]]:
585 if images is not None:
586 assert len(prompts) == len(images)
587
588 if videos is not None:
589 assert len(prompts) == len(videos)
590
591 if audios is not None:
592 assert len(prompts) == len(audios)
593
594 all_inputs: list[BatchFeature | BatchEncoding | dict[str, torch.Tensor]] = []
595 for i, prompt in enumerate(prompts):
596 if isinstance(prompt, str):
597 if self.processor is None:
598 raise RuntimeError(
599 "HfRunner.processor is not initialized. "
600 "Pass processor=... to HfRunner or set "
601 "hf_model.processor before generation."
602 )
603 # Create a copy to avoid modifying the original dict
604 processor_kwargs = (
605 tokenization_kwargs.copy()
606 if tokenization_kwargs is not None
607 else {}
608 )
609 processor_kwargs.update(
610 {
611 "text": prompt,
612 "return_tensors": "pt",
613 }
614 )
615 if images is not None and (image := images[i]) is not None:
616 processor_kwargs["images"] = image
617 if videos is not None and (video := videos[i]) is not None:
618 processor_kwargs["videos"] = video
619 if audios is not None and (audio_inputs := audios[i]) is not None:
620 # HACK - not all processors take sampling_rate; we should
621 # clean this up in the future.
622 if len(audio_inputs) == 2:
623 audio, sr = audio_inputs
624 processor_kwargs["audio"] = audio
625 processor_kwargs["sampling_rate"] = sr
626 else:
627 processor_kwargs["audio"] = audio_inputs
628
629 inputs = self.processor(**processor_kwargs)
630 if isinstance(inputs, BatchFeature):
631 inputs = inputs.to(dtype=self.dtype)
632 all_inputs.append(inputs)
633 else:
634 # check that prompt is (batched) list of integers (token ids)
635 if not is_list_of(prompt, typ=int, check="all"):
636 raise ValueError(
637 "Prompt must be a list of ints corresponding to the prompt token ids."
638 )
639 # check that no multimodal input is provided
640 if images or videos or audios:
641 raise ValueError(
642 "When providing prompt token ids multimodal inputs are not supported."
643 )
644 input_dict = {
645 "input_ids": torch.tensor(prompt, dtype=torch.long).unsqueeze(0),
646 }
647 all_inputs.append(input_dict)
648
649 return all_inputs
650
651 def get_prompt_embeddings(self, prompts: list[str]) -> list[torch.Tensor]:
652 all_inputs = self.get_inputs(prompts)
653 embeddings = []
654 for inputs in all_inputs:
655 input_ids = self.wrap_device(inputs)["input_ids"]
656 embedding = self.model.get_input_embeddings()(input_ids).squeeze(0)
657 embeddings.append(embedding)
658 return embeddings
659
660 def classify(self, prompts: list[str]) -> list[list[float]]:
661 # output is final logits
662 all_inputs = self.get_inputs(prompts)
663 outputs: list[list[float]] = []
664 problem_type = getattr(self.config, "problem_type", "")
665
666 for inputs in all_inputs:
667 output = self.model(**self.wrap_device(inputs))
668
669 assert isinstance(output.logits, torch.Tensor)
670
671 if problem_type == "regression":
672 logits = output.logits[0].tolist()
673 elif problem_type == "multi_label_classification":
674 logits = output.logits.sigmoid()[0].tolist()
675 else:
676 logits = softmax(output.logits)[0].tolist()
677 outputs.append(logits)
678
679 return outputs
680
681 def generate(
682 self,
683 prompts: list[str] | list[list[int]],
684 images: PromptImageInput | None = None,
685 videos: PromptVideoInput | None = None,
686 audios: PromptAudioInput | None = None,
687 **kwargs: Any,
688 ) -> list[tuple[list[list[int]], list[str]]]:
689 all_inputs = self.get_inputs(
690 prompts, images=images, videos=videos, audios=audios
691 )
692
693 outputs: list[tuple[list[list[int]], list[str]]] = []
694 for inputs in all_inputs:
695 generate_kwargs = dict(kwargs)
696 generate_kwargs.setdefault("tokenizer", self.tokenizer)
697 output_ids: torch.Tensor = self.model.generate(
698 **self.wrap_device(inputs),
699 use_cache=True,
700 **generate_kwargs,
701 )
702 if self.processor is None:
703 raise RuntimeError(
704 "HfRunner.processor is not initialized; cannot decode output."
705 )
706 output_str = self.processor.batch_decode(
707 output_ids,
708 skip_special_tokens=True,
709 clean_up_tokenization_spaces=False,
710 )
711 outputs.append((output_ids.cpu().tolist(), output_str))
712 return outputs
713
714 def generate_greedy(
715 self,
716 prompts: list[str] | list[list[int]],
717 max_tokens: int,
718 images: PromptImageInput | None = None,
719 videos: PromptVideoInput | None = None,
720 audios: PromptAudioInput | None = None,
721 **kwargs: Any,
722 ) -> list[tuple[list[int], str]]:
723 outputs = self.generate(
724 prompts,
725 do_sample=False,
726 max_new_tokens=max_tokens,
727 images=images,
728 videos=videos,
729 audios=audios,
730 **kwargs,
731 )
732
733 return [(output_ids[0], output_str[0]) for output_ids, output_str in outputs]
734
735 def generate_beam_search(
736 self,
737 prompts: list[str],
738 beam_width: int,
739 max_tokens: int,
740 images: PromptImageInput | None = None,
741 videos: PromptVideoInput | None = None,
742 audios: PromptAudioInput | None = None,
743 ) -> list[tuple[list[list[int]], list[str]]]:
744 outputs = self.generate(
745 prompts,
746 do_sample=False,
747 max_new_tokens=max_tokens,
748 num_beams=beam_width,
749 num_return_sequences=beam_width,
750 images=images,
751 videos=videos,
752 audios=audios,
753 )
754
755 for i in range(len(outputs)):
756 output_ids, output_str = outputs[i]
757 for j in range(len(output_ids)):
758 output_ids[j] = [
759 x for x in output_ids[j] if x != self.tokenizer.pad_token_id
760 ]
761 outputs[i] = (output_ids, output_str)
762 return outputs
763
764 def generate_greedy_logprobs(
765 self,
766 prompts: list[str],
767 max_tokens: int,
768 images: PromptImageInput | None = None,
769 videos: PromptVideoInput | None = None,
770 audios: PromptAudioInput | None = None,
771 **kwargs: Any,
772 ) -> list[list[torch.Tensor]]:
773 all_inputs = self.get_inputs(
774 prompts, images=images, videos=videos, audios=audios
775 )
776
777 all_logprobs: list[list[torch.Tensor]] = []
778 for inputs in all_inputs:
779 generate_kwargs = dict(kwargs)
780 generate_kwargs.setdefault("tokenizer", self.tokenizer)
781 output: "GenerateOutput" = self.model.generate(
782 **self.wrap_device(inputs),
783 use_cache=True,
784 do_sample=False,
785 max_new_tokens=max_tokens,
786 output_hidden_states=True,
787 return_dict_in_generate=True,
788 **generate_kwargs,
789 )
790 seq_logprobs = self._hidden_states_to_seq_logprobs(output.hidden_states)
791 all_logprobs.append(seq_logprobs)
792 return all_logprobs
793
794 def _hidden_states_to_seq_logprobs(
795 self,
796 hidden_states: tuple[tuple[torch.Tensor, ...], ...],
797 ) -> list[torch.Tensor]:
798 output_embeddings = self.model.get_output_embeddings()
799
800 seq_logprobs: list[torch.Tensor] = []
801 for _, hidden_state in enumerate(hidden_states):
802 last_hidden_states = hidden_state[-1][0]
803 logits = torch.matmul(
804 last_hidden_states.to(
805 device=output_embeddings.weight.device,
806 dtype=output_embeddings.weight.dtype,
807 ),
808 output_embeddings.weight.t(),
809 )
810 if getattr(output_embeddings, "bias", None) is not None:
811 logits += output_embeddings.bias.unsqueeze(0)
812 logprobs = F.log_softmax(logits, dim=-1, dtype=torch.float32)
813 seq_logprobs.append(logprobs)
814
815 return seq_logprobs
816
817 def _hidden_states_to_logprobs(
818 self,
819 hidden_states: tuple[tuple[torch.Tensor, ...], ...],
820 num_logprobs: int | None,
821 ) -> tuple[list[dict[int, float]], int]:
822 seq_logprobs = self._hidden_states_to_seq_logprobs(hidden_states)
823 output_len = len(hidden_states)
824
825 # convert to dict
826 seq_logprobs_lst: list[dict[int, float]] = []
827 for tok_idx, tok_logprobs in enumerate(seq_logprobs):
828 # drop prompt logprobs
829 if tok_idx == 0:
830 tok_logprobs = tok_logprobs[-1, :].reshape(1, -1)
831 topk = tok_logprobs.topk(num_logprobs)
832
833 tok_logprobs_dct = {}
834 for token_id, logprob in zip(topk.indices[0], topk.values[0]):
835 tok_logprobs_dct[token_id.item()] = logprob.item()
836
837 seq_logprobs_lst.append(tok_logprobs_dct)
838
839 return (
840 seq_logprobs_lst,
841 output_len,
842 )
843
844 def generate_greedy_logprobs_limit(
845 self,
846 prompts: list[str],
847 max_tokens: int,
848 num_logprobs: int | None,
849 images: PromptImageInput | None = None,
850 audios: PromptAudioInput | None = None,
851 videos: PromptVideoInput | None = None,
852 use_cache: bool = True,
853 tokenization_kwargs: dict[str, Any] | None = None,
854 **kwargs: Any,
855 ) -> list[TokensTextLogprobs]:
856 all_inputs = self.get_inputs(
857 prompts,
858 images=images,
859 videos=videos,
860 audios=audios,
861 tokenization_kwargs=tokenization_kwargs,
862 )
863
864 all_logprobs: list[list[dict[int, float]]] = []
865 all_output_ids: list[list[int]] = []
866 all_output_strs: list[str] = []
867
868 for inputs in all_inputs:
869 generate_kwargs = dict(kwargs)
870 generate_kwargs.setdefault("tokenizer", self.tokenizer)
871 output: "GenerateOutput" = self.model.generate(
872 **self.wrap_device(inputs),
873 use_cache=use_cache,
874 do_sample=False,
875 max_new_tokens=max_tokens,
876 output_hidden_states=True,
877 return_dict_in_generate=True,
878 **generate_kwargs,
879 )
880
881 # Encoder-decoder models return decoder_hidden_states instead of
882 # hidden_states
883 hidden_states = (
884 getattr(output, "hidden_states", None) or output.decoder_hidden_states
885 )
886
887 (
888 seq_logprobs_lst,
889 output_len,
890 ) = self._hidden_states_to_logprobs(hidden_states, num_logprobs)
891
892 all_logprobs.append(seq_logprobs_lst)
893 seq_ids = output.sequences[0]
894 output_len = len(seq_logprobs_lst)
895 output_ids = seq_ids[-output_len:]
896 all_output_ids.append(output_ids.tolist())
897 all_output_strs.append(self.tokenizer.decode(output_ids))
898
899 outputs = zip(all_output_ids, all_output_strs, all_logprobs)
900 return [
901 (output_ids, output_str, output_logprobs)
902 for output_ids, output_str, output_logprobs in outputs
903 ]
904
905 def encode(self, prompts: list[str], *args, **kwargs) -> list[list[torch.Tensor]]:
906 return self.model.encode(prompts, *args, **kwargs)
907
908 def predict(self, prompts: list[list[str]], *args, **kwargs) -> torch.Tensor:
909 return self.model.predict(prompts, *args, convert_to_tensor=True, **kwargs)
910
911 def __enter__(self):
912 if current_platform.is_rocm() or current_platform.is_xpu():
913 # Record starting memory usage stats on ROCm/XPU so that we can
914 # wait for memory to roughly settle back below these levels on
915 # shutdown. This is helpful in cases where the HfRunner is
916 # initialized after significant GPU memory is already occupied,
917 # e.g. in
918 # tests/basic_correctness/models/test_basic_correctness.py::test_models_distributed
919 # where vllm worker processes are still alive and holding GPU
920 # memory when hf_runner.__exit__ is called.
921 from tests.utils import record_gpu_memory_usage_stats
922
923 if (device_count := current_platform.device_count()) > 0:
924 devices = list(range(device_count))
925 mem_usage_stats = record_gpu_memory_usage_stats(devices=devices)
926 self.threshold_ratios = {
927 device: 0.05 + mem_used / mem_tot
928 for device, (mem_used, mem_tot) in mem_usage_stats.items()
929 }
930 return self
931
932 def __exit__(self, exc_type, exc_value, traceback):
933 from tests.utils import wait_for_memory_to_settle
934
935 del self.model
936 cleanup_dist_env_and_memory()
937 # ROCm/XPU free VRAM lazily; wait so a runner started right after this
938 # HF model exits does not OOM on its startup memory guard.
939 wait_for_memory_to_settle(
940 threshold_ratio=getattr(self, "threshold_ratios", None)
941 )
942 if hasattr(self, "threshold_ratios"):
943 del self.threshold_ratios
944
945
946 @pytest.fixture(scope="session")
947 def hf_runner():
948 return HfRunner
949
950
951 def _default_block_size() -> int:
952 if current_platform.is_xpu():
953 return 64
954 if current_platform.is_cpu():
955 return 128
956 return 16
957
958
959 class VllmRunner:
960 """
961 The default value of some arguments have been modified from
962 {class}`~vllm.LLM` as follows:
963
964 - `trust_remote_code`: Set to `True` instead of `False` for convenience.
965 - `seed`: Set to `0` instead of `None` for test reproducibility.
966 - `max_model_len`: Set to `1024` instead of `None` to reduce memory usage.
967 - `block_size`: To reduce memory usage, set default to `64` if on XPU
968 devices, otherwise default to `16`.
969 - `enable_chunked_prefill`: Set to `False` instead of `None` for
970 test reproducibility.
971 - `enforce_eager`: Set to `False` to test CUDA graph.
972 - `kernel_config.enable_jit_warmup`: Set to `False` to reduce test startup
973 time.
974 """
975
976 def __init__(
977 self,
978 model_name: str,
979 runner: RunnerOption = "auto",
980 convert: ConvertOption = "auto",
981 tokenizer_name: str | None = None,
982 tokenizer_mode: str = "auto",
983 trust_remote_code: bool = True,
984 seed: int = 0,
985 max_model_len: int | None = 1024,
986 dtype: str = "auto",
987 disable_log_stats: bool = True,
988 tensor_parallel_size: int = 1,
989 block_size: int = _default_block_size(),
990 enable_chunked_prefill: bool | None = False,
991 enforce_eager: bool | None = False,
992 # Set this to avoid hanging issue
993 default_torch_num_threads: int | None = None,
994 **kwargs,
995 ) -> None:
996 init_ctx = (
997 nullcontext()
998 if default_torch_num_threads is None
999 else set_default_torch_num_threads(default_torch_num_threads)
1000 )
1001
1002 kwargs.setdefault("kernel_config", {"enable_jit_warmup": False})
1003
1004 if not kwargs.get("compilation_config", None):
1005 # Note(@tdoublep): This is set to 4 because some tests (e.g., hybrid
1006 # model tests) may set max_num_seqs=4. If min cudagraph_capture_size is
1007 # set to larger than max_num_seqs, then it will lead to *no* graphs
1008 # being captured which can trigger edge cases that we don't handle yet.
1009 kwargs["compilation_config"] = {"cudagraph_capture_sizes": [4]}
1010
1011 # Make sure we have atleast one cudagraph large enough for a single decode.
1012 if (speculative_config := kwargs.get("speculative_config")) and (
1013 num_speculative_tokens := speculative_config["num_speculative_tokens"]
1014 ):
1015 kwargs["compilation_config"]["cudagraph_capture_sizes"].append(
1016 num_speculative_tokens + 1
1017 )
1018
1019 from vllm.platforms import current_platform
1020
1021 if current_platform.is_rocm():
1022 gpu_memory_utilization = kwargs.get(
1023 "gpu_memory_utilization",
1024 CacheConfig.gpu_memory_utilization,
1025 )
1026 # V1 startup requires free_memory >= total * gpu_memory_utilization.
1027 # ROCm CI can hand a test a device that is still lazily releasing
1028 # VRAM from a previous process, so wait before constructing LLM.
1029 from tests.utils import wait_for_memory_to_settle
1030
1031 wait_for_memory_to_settle(threshold_ratio=1.0 - gpu_memory_utilization)
1032 elif current_platform.is_xpu():
1033 # The XPU/oneAPI runtime keeps ~1 GiB of context resident in the
1034 # parent pytest process for its whole lifetime (grown by in-process
1035 # HfRunner models), and distributed tests additionally allocate a
1036 # CCL context in the engine subprocess. The default utilization of
1037 # 0.92 leaves too little headroom for both, so lower it on XPU when
1038 # the caller did not request an explicit value.
1039 if "gpu_memory_utilization" not in kwargs:
1040 kwargs["gpu_memory_utilization"] = 0.9
1041 gpu_memory_utilization = kwargs["gpu_memory_utilization"]
1042 # XPU (Level Zero) can also release device memory lazily after a
1043 # previous engine shuts down, so wait before constructing LLM.
1044 from tests.utils import wait_for_memory_to_settle
1045
1046 wait_for_memory_to_settle(threshold_ratio=1.0 - gpu_memory_utilization)
1047
1048 with init_ctx:
1049 self.llm = LLM(
1050 model=model_name,
1051 runner=runner,
1052 convert=convert,
1053 tokenizer=tokenizer_name,
1054 tokenizer_mode=tokenizer_mode,
1055 trust_remote_code=trust_remote_code,
1056 dtype=dtype,
1057 seed=seed,
1058 enforce_eager=enforce_eager,
1059 disable_log_stats=disable_log_stats,
1060 tensor_parallel_size=tensor_parallel_size,
1061 max_model_len=max_model_len,
1062 block_size=block_size,
1063 enable_chunked_prefill=enable_chunked_prefill,
1064 **kwargs,
1065 )
1066
1067 def get_inputs(
1068 self,
1069 prompts: list[str]
1070 | list[torch.Tensor]
1071 | list[list[int]]
1072 | list[dict[str, Any]],
1073 images: PromptImageInput | None = None,
1074 videos: PromptVideoInput | None = None,
1075 audios: PromptAudioInput | None = None,
1076 ) -> list[dict[str, Any]]:
1077 if any(
1078 x is not None and len(x) != len(prompts) for x in [images, videos, audios]
1079 ):
1080 raise ValueError(
1081 "All non-None multimodal inputs must have the same length as prompts"
1082 )
1083
1084 inputs = list[dict[str, Any]]()
1085 for i, prompt in enumerate(prompts):
1086 # If we're passing an encoder/decoder prompt, we assume it
1087 # already contains the multimodal data in the prompt
1088 if isinstance(prompt, dict):
1089 assert images is None and audios is None and videos is None
1090 inputs.append(prompt.copy())
1091 else:
1092 prompt_dict = dict[str, Any]()
1093 if isinstance(prompt, str):
1094 prompt_dict["prompt"] = prompt
1095 elif isinstance(prompt, list):
1096 prompt_dict["prompt_token_ids"] = prompt
1097 else:
1098 prompt_dict["prompt_embeds"] = prompt
1099
1100 multi_modal_data = dict[str, Any]()
1101 if images is not None and (image := images[i]) is not None:
1102 multi_modal_data["image"] = image
1103 if videos is not None and (video := videos[i]) is not None:
1104 multi_modal_data["video"] = video
1105 if audios is not None and (audio := audios[i]) is not None:
1106 multi_modal_data["audio"] = audio
1107
1108 if multi_modal_data:
1109 prompt_dict["multi_modal_data"] = multi_modal_data
1110
1111 inputs.append(prompt_dict)
1112
1113 return inputs
1114
1115 def generate(
1116 self,
1117 prompts: list[str] | list[torch.Tensor] | list[list[int]],
1118 sampling_params: SamplingParams,
1119 images: PromptImageInput | None = None,
1120 videos: PromptVideoInput | None = None,
1121 audios: PromptAudioInput | None = None,
1122 return_logprobs: bool = False,
1123 **kwargs: Any,
1124 ) -> list[tuple[list[list[int]], list[str]]] | tuple[list, list]:
1125 inputs = self.get_inputs(prompts, images=images, videos=videos, audios=audios)
1126
1127 req_outputs = self.llm.generate(
1128 inputs, sampling_params=sampling_params, **kwargs
1129 )
1130
1131 outputs: list[tuple[list[list[int]], list[str]]] = []
1132 logprobs = []
1133 for req_output in req_outputs:
1134 prompt_str = req_output.prompt
1135 prompt_ids = req_output.prompt_token_ids
1136 req_sample_output_ids: list[list[int]] = []
1137 req_sample_output_strs: list[str] = []
1138 req_logprobs = []
1139 if req_output.prompt_logprobs:
1140 req_logprobs.extend(req_output.prompt_logprobs)
1141 for sample in req_output.outputs:
1142 output_str = sample.text
1143 output_ids = list(sample.token_ids)
1144 req_sample_output_ids.append(prompt_ids + output_ids)
1145 req_sample_output_strs.append((prompt_str or "") + output_str)
1146 if sample.logprobs:
1147 req_logprobs.extend(sample.logprobs)
1148 outputs.append((req_sample_output_ids, req_sample_output_strs))
1149 logprobs.append(req_logprobs)
1150 return outputs if not return_logprobs else (outputs, logprobs)
1151
1152 @staticmethod
1153 def _final_steps_generate_w_logprobs(
1154 req_outputs: list[RequestOutput],
1155 include_prompt_token_ids: bool = False,
1156 ) -> list[TokensTextLogprobsPromptLogprobs]:
1157 outputs: list[TokensTextLogprobsPromptLogprobs] = []
1158 for req_output in req_outputs:
1159 assert len(req_output.outputs) > 0
1160 for sample in req_output.outputs:
1161 output_str = sample.text
1162 output_ids = list(sample.token_ids)
1163 output_logprobs = sample.logprobs
1164 if include_prompt_token_ids:
1165 outputs.append(
1166 (
1167 output_ids,
1168 output_str,
1169 output_logprobs,
1170 req_output.prompt_token_ids,
1171 req_output.prompt_logprobs,
1172 )
1173 )
1174 else:
1175 outputs.append(
1176 (
1177 output_ids,
1178 output_str,
1179 output_logprobs,
1180 req_output.prompt_logprobs,
1181 )
1182 )
1183
1184 return outputs
1185
1186 def generate_w_logprobs(
1187 self,
1188 prompts: list[str],
1189 sampling_params: SamplingParams,
1190 images: PromptImageInput | None = None,
1191 audios: PromptAudioInput | None = None,
1192 videos: PromptVideoInput | None = None,
1193 include_prompt_token_ids: bool = False,
1194 **kwargs: Any,
1195 ) -> list[TokensTextLogprobs] | list[TokensTextLogprobsPromptLogprobs]:
1196 inputs = self.get_inputs(prompts, images=images, videos=videos, audios=audios)
1197
1198 req_outputs = self.llm.generate(
1199 inputs, sampling_params=sampling_params, **kwargs
1200 )
1201
1202 toks_str_logsprobs_prompt_logprobs = self._final_steps_generate_w_logprobs(
1203 req_outputs, include_prompt_token_ids
1204 )
1205 # Omit prompt logprobs if not required by sampling params
1206 return (
1207 [x[0:-1] for x in toks_str_logsprobs_prompt_logprobs]
1208 if sampling_params.prompt_logprobs is None
1209 else toks_str_logsprobs_prompt_logprobs
1210 )
1211
1212 def generate_greedy(
1213 self,
1214 prompts: list[str] | list[torch.Tensor] | list[list[int]],
1215 max_tokens: int,
1216 images: PromptImageInput | None = None,
1217 videos: PromptVideoInput | None = None,
1218 audios: PromptAudioInput | None = None,
1219 **kwargs: Any,
1220 ) -> list[tuple[list[int], str]]:
1221 greedy_params = SamplingParams(temperature=0.0, max_tokens=max_tokens)
1222 outputs = self.generate(
1223 prompts,
1224 greedy_params,
1225 images=images,
1226 videos=videos,
1227 audios=audios,
1228 **kwargs,
1229 )
1230 return [(output_ids[0], output_str[0]) for output_ids, output_str in outputs]
1231
1232 def generate_greedy_logprobs(
1233 self,
1234 prompts: list[str],
1235 max_tokens: int,
1236 num_logprobs: int | None,
1237 num_prompt_logprobs: int | None = None,
1238 images: PromptImageInput | None = None,
1239 audios: PromptAudioInput | None = None,
1240 videos: PromptVideoInput | None = None,
1241 stop_token_ids: list[int] | None = None,
1242 stop: list[str] | None = None,
1243 **kwargs: Any,
1244 ) -> list[TokensTextLogprobs] | list[TokensTextLogprobsPromptLogprobs]:
1245 greedy_logprobs_params = SamplingParams(
1246 temperature=0.0,
1247 max_tokens=max_tokens,
1248 logprobs=num_logprobs,
1249 prompt_logprobs=num_prompt_logprobs,
1250 stop_token_ids=stop_token_ids,
1251 stop=stop,
1252 )
1253
1254 return self.generate_w_logprobs(
1255 prompts,
1256 greedy_logprobs_params,
1257 images=images,
1258 audios=audios,
1259 videos=videos,
1260 **kwargs,
1261 )
1262
1263 def generate_prompt_perplexity(
1264 self, prompts: list[str], mask: Optional[list[str]] = None
1265 ) -> list[float]:
1266 """
1267 Return the perplexity score associated with generating the prompts
1268
1269 :param prompts: list of prompts to score
1270 :return: perplexity score of each prompt
1271 """
1272 outputs = self.generate_greedy_logprobs(
1273 prompts, max_tokens=1, num_logprobs=None, num_prompt_logprobs=0
1274 )
1275
1276 mask_prefix_lens = (
1277 [len(self.llm.get_tokenizer()(prefix)["input_ids"]) for prefix in mask]
1278 if mask is not None
1279 else [0 for _ in range(len(prompts))]
1280 )
1281
1282 perplexities = []
1283 for output, mask_prefix_len in zip(outputs, mask_prefix_lens):
1284 output = cast(TokensTextLogprobsPromptLogprobs, output)
1285 token_datas = cast(list[dict[int, Logprob] | None], output[3])
1286 assert token_datas[0] is None
1287
1288 token_log_probs = []
1289 for token_data in token_datas[mask_prefix_len + 1 :]:
1290 assert token_data is not None
1291 assert len(token_data) == 1
1292 token_log_prob = list(token_data.values())[0].logprob
1293 token_log_probs.append(token_log_prob)
1294
1295 perplexity = math.exp(-sum(token_log_probs) / len(token_log_probs))
1296 perplexities.append(perplexity)
1297
1298 return perplexities
1299
1300 def generate_beam_search(
1301 self,
1302 prompts: list[str],
1303 beam_width: int,
1304 max_tokens: int,
1305 images: PromptImageInput | None = None,
1306 videos: PromptVideoInput | None = None,
1307 audios: PromptAudioInput | None = None,
1308 concurrency_limit: int | None = None,
1309 ) -> list[tuple[list[list[int]], list[str]]]:
1310 inputs = self.get_inputs(prompts, images=images, videos=videos, audios=audios)
1311
1312 outputs = self.llm.beam_search(
1313 inputs,
1314 BeamSearchParams(beam_width=beam_width, max_tokens=max_tokens),
1315 concurrency_limit=concurrency_limit,
1316 )
1317 returned_outputs = []
1318 for output in outputs:
1319 token_ids = [x.tokens for x in output.sequences]
1320 texts = [x.text for x in output.sequences]
1321 returned_outputs.append((token_ids, texts))
1322 return returned_outputs
1323
1324 def classify(self, prompts: list[str]) -> list[list[float]]:
1325 req_outputs = self.llm.classify(prompts)
1326 return [req_output.outputs.probs for req_output in req_outputs]
1327
1328 def embed(
1329 self,
1330 prompts: list[str],
1331 images: PromptImageInput | None = None,
1332 videos: PromptVideoInput | None = None,
1333 audios: PromptAudioInput | None = None,
1334 *args,
1335 **kwargs,
1336 ) -> list[list[float]]:
1337 inputs = self.get_inputs(prompts, images=images, videos=videos, audios=audios)
1338
1339 req_outputs = self.llm.embed(inputs, *args, **kwargs)
1340 return [req_output.outputs.embedding for req_output in req_outputs]
1341
1342 def token_embed(self, prompts: list[str]) -> list[list[float]]:
1343 req_outputs = self.llm.encode(prompts, pooling_task="token_embed")
1344 return [req_output.outputs.data for req_output in req_outputs]
1345
1346 def token_classify(self, prompts: list[str]) -> list[list[float]]:
1347 req_outputs = self.llm.encode(prompts, pooling_task="token_classify")
1348 return [req_output.outputs.data for req_output in req_outputs]
1349
1350 def score(
1351 self,
1352 text_1: list[str] | str,
1353 text_2: list[str] | str,
1354 *args,
1355 **kwargs,
1356 ) -> list[float]:
1357 req_outputs = self.llm.score(text_1, text_2, *args, **kwargs)
1358 return [req_output.outputs.score for req_output in req_outputs]
1359
1360 def apply_model(self, func: Callable[[nn.Module], _R]) -> list[_R]:
1361 return self.llm.apply_model(func)
1362
1363 def get_llm(self) -> LLM:
1364 return self.llm
1365
1366 def collective_rpc(self, *args, **kwargs):
1367 return self.llm.collective_rpc(*args, **kwargs)
1368
1369 def __enter__(self):
1370 return self
1371
1372 def _wait_for_memory_release(self, gpu_memory_utilization: float) -> None:
1373 from tests.utils import wait_for_memory_to_settle
1374
1375 # V1 startup requires free_memory >= total * gpu_memory_utilization.
1376 # Wait for the complementary used-memory ratio so the next runner does
1377 # not fail the startup guard immediately after this runner exits. The
1378 # wait is bounded so cleanup failures fail this test instead of hanging.
1379 wait_for_memory_to_settle(threshold_ratio=1.0 - gpu_memory_utilization)
1380
1381 def __exit__(self, exc_type, exc_value, traceback):
1382 # Explicitly shutdown the engine core to release GPU resources
1383 # This is needed because when executing consecutive tests, the GC
1384 # might not be fast enough in shutting down the llm engine. This can lead to OOMs
1385 # because when the next test starts some GPU memory is still in use.
1386 gpu_memory_utilization = (
1387 self.llm.llm_engine.vllm_config.cache_config.gpu_memory_utilization
1388 )
1389 from vllm.platforms import current_platform
1390
1391 try:
1392 # Give the engine core time to run its own graceful shutdown
1393 # (model_executor teardown + empty_cache + process-group destroy)
1394 # before the process manager SIGKILLs it at the default 5s. On ROCm
1395 # a hard kill leaves the whole allocation for the driver's slow async
1396 # VRAM reclamation, which starves the next test's startup.
1397 shutdown_timeout = 60.0 if current_platform.is_rocm() else None
1398 self.llm.llm_engine.engine_core.shutdown(timeout=shutdown_timeout)
1399 except Exception:
1400 # Don't fail the test on shutdown errors since cleanup will still
1401 # proceed, but don't hide them either: a failure here usually
1402 # means the engine's GPU memory was never released.
1403 logger.exception("Engine core shutdown raised; GPU memory may leak")
1404 del self.llm
1405 torch._dynamo.reset()
1406 cleanup_dist_env_and_memory()
1407 self._wait_for_memory_release(gpu_memory_utilization)
1408
1409
1410 @pytest.fixture(scope="session")
1411 def vllm_runner():
1412 return VllmRunner
1413
1414
1415 @pytest.fixture()
1416 def temporary_enable_log_propagate():
1417 import logging
1418
1419 logger = logging.getLogger("vllm")
1420 previous_propagate = logger.propagate
1421 logger.propagate = True
1422 yield
1423 logger.propagate = previous_propagate
1424
1425
1426 @pytest.fixture()
1427 def caplog_vllm(temporary_enable_log_propagate, caplog):
1428 # caplog depends on logs propagated to the root logger. The vLLM logger
1429 # inherits this INFO level until runtime initialization configures it.
1430 import logging
1431
1432 with caplog.at_level(logging.INFO):
1433 yield caplog
1434
1435
1436 @pytest.fixture()
1437 def caplog_mp_fork():
1438 """
1439 This fixture enables capturing logs from a forked MP subprocess.
1440 It should be used in conjunction with caplog_vllm.
1441
1442 By default, subprocess logs do not go through the parent process.
1443 We instead create a queue listener in the parent process which
1444 forwards logs to the logger's other handlers, and add a QueueHandler
1445 to the root logger. Forked subprocesses will inherit the root logger
1446 and pass their messages to the queue, which the listener will forward
1447 to the root logger, which can be captured by caplog.
1448
1449 Note that this workaround only works for fork; with spawn, the subprocess
1450 reinitializes logging and does not automatically inherit the queue.
1451 We'd have to manually pass the queue to the subprocess at the spawn point.
1452 See caplog_mp_spawn below.
1453 """
1454
1455 @contextlib.contextmanager
1456 def ctx():
1457 import logging.handlers
1458 import multiprocessing as mp
1459
1460 logger_queue: mp.Queue[logging.LogRecord] = mp.Queue()
1461 logger = logging.getLogger()
1462 handlers = logger.handlers
1463
1464 # The listener works on a background thread, not inherited by the child.
1465 queue_listener = logging.handlers.QueueListener(logger_queue, *handlers)
1466 queue_listener.start()
1467
1468 # Add queue handler after creating the listener to avoid cycle
1469 logger.addHandler(logging.handlers.QueueHandler(logger_queue))
1470 yield
1471 queue_listener.stop()
1472
1473 return ctx
1474
1475
1476 class LogHolder:
1477 def __init__(self):
1478 self.text = None
1479
1480
1481 @pytest.fixture()
1482 def caplog_mp_spawn(tmp_path, monkeypatch):
1483 """
1484 This fixture enables capturing logs from a forked MP subprocess.
1485 It does not require caplog_vllm (but it only contains logs from the child).
1486
1487 By default, subprocess logs do not go through the parent process.
1488 We instead add a FileHandler to the config so the spawned child process
1489 writes its logs to a temp file.
1490 In the parent, we read the file and return the contents.
1491
1492 Note: this method could be extended to fork by either reconfiguring logging
1493 in the parent or using a SocketHandler:
1494 https://docs.python.org/3/howto/logging-cookbook.html#sending-and-receiving-logging-events-across-a-network # noqa: E501
1495 """
1496
1497 @contextlib.contextmanager
1498 def ctx(level: int | str):
1499 from vllm.logger import DEFAULT_LOGGING_CONFIG
1500
1501 config_path = tmp_path / "vllm_logging_config.json"
1502 log_path = tmp_path / "vllm.log"
1503 log_holder = LogHolder()
1504
1505 config = deepcopy(DEFAULT_LOGGING_CONFIG)
1506 if envs.VLLM_LOGGING_CONFIG_PATH:
1507 path = pathlib.Path(envs.VLLM_LOGGING_CONFIG_PATH)
1508 assert path.exists()
1509 config = json.loads(path.read_text())
1510
1511 config["loggers"]["vllm"]["handlers"] += ["vllm_file"]
1512 config["handlers"]["vllm_file"] = {
1513 "class": "logging.FileHandler",
1514 "formatter": "vllm",
1515 "level": level,
1516 "filename": log_path.as_posix(),
1517 }
1518 config["loggers"]["vllm"]["level"] = level
1519
1520 config_path.write_text(json.dumps(config))
1521
1522 with monkeypatch.context() as monkeypatch_ctx:
1523 monkeypatch_ctx.setenv("VLLM_LOGGING_CONFIG_PATH", config_path.as_posix())
1524 monkeypatch_ctx.setenv("VLLM_CONFIGURE_LOGGING", "1")
1525 yield log_holder
1526
1527 log_holder.text = log_path.read_text()
1528
1529 return ctx
1530
1531
1532 @pytest.fixture(scope="session")
1533 def num_gpus_available():
1534 """Get number of GPUs without initializing the CUDA context
1535 in current process."""
1536
1537 from vllm.platforms import current_platform
1538
1539 return current_platform.device_count()
1540
1541
1542 temp_dir = tempfile.gettempdir()
1543 _dummy_opt_path = os.path.join(temp_dir, "dummy_opt")
1544 _dummy_llava_path = os.path.join(temp_dir, "dummy_llava")
1545 _dummy_gemma2_embedding_path = os.path.join(temp_dir, "dummy_gemma2_embedding")
1546
1547
1548 @pytest.fixture
1549 def dummy_opt_path():
1550 json_path = os.path.join(_dummy_opt_path, "config.json")
1551 if not os.path.exists(_dummy_opt_path):
1552 hf_api().snapshot_download(
1553 repo_id="facebook/opt-125m",
1554 local_dir=_dummy_opt_path,
1555 ignore_patterns=["*.bin", "*.bin.index.json", "*.pt", "*.h5", "*.msgpack"],
1556 )
1557 assert os.path.exists(json_path)
1558 with open(json_path) as f:
1559 config = json.load(f)
1560 config["architectures"] = ["MyOPTForCausalLM"]
1561 with open(json_path, "w") as f:
1562 json.dump(config, f)
1563 return _dummy_opt_path
1564
1565
1566 @pytest.fixture
1567 def dummy_llava_path():
1568 json_path = os.path.join(_dummy_llava_path, "config.json")
1569 if not os.path.exists(_dummy_llava_path):
1570 hf_api().snapshot_download(
1571 repo_id="llava-hf/llava-1.5-7b-hf",
1572 local_dir=_dummy_llava_path,
1573 ignore_patterns=[
1574 "*.bin",
1575 "*.bin.index.json",
1576 "*.pt",
1577 "*.h5",
1578 "*.msgpack",
1579 "*.safetensors",
1580 ],
1581 )
1582 assert os.path.exists(json_path)
1583 with open(json_path) as f:
1584 config = json.load(f)
1585 config["architectures"] = ["MyLlava"]
1586 with open(json_path, "w") as f:
1587 json.dump(config, f)
1588 return _dummy_llava_path
1589
1590
1591 @pytest.fixture
1592 def dummy_gemma2_embedding_path():
1593 json_path = os.path.join(_dummy_gemma2_embedding_path, "config.json")
1594 if not os.path.exists(_dummy_gemma2_embedding_path):
1595 hf_api().snapshot_download(
1596 repo_id="BAAI/bge-multilingual-gemma2",
1597 local_dir=_dummy_gemma2_embedding_path,
1598 ignore_patterns=[
1599 "*.bin",
1600 "*.bin.index.json",
1601 "*.pt",
1602 "*.h5",
1603 "*.msgpack",
1604 "*.safetensors",
1605 ],
1606 )
1607 assert os.path.exists(json_path)
1608 with open(json_path) as f:
1609 config = json.load(f)
1610 config["architectures"] = ["MyGemma2Embedding"]
1611 with open(json_path, "w") as f:
1612 json.dump(config, f)
1613 return _dummy_gemma2_embedding_path
1614
1615
1616 # Add the flag `--optional` to allow run tests
1617 # that are marked with @pytest.mark.optional
1618 def pytest_addoption(parser):
1619 parser.addoption(
1620 "--optional", action="store_true", default=False, help="run optional test"
1621 )
1622
1623
1624 def pytest_collection_modifyitems(config, items):
1625 if config.getoption("--optional"):
1626 # --optional given in cli: do not skip optional tests
1627 return
1628 skip_optional = pytest.mark.skip(reason="need --optional option to run")
1629 for item in items:
1630 if "optional" in item.keywords:
1631 item.add_marker(skip_optional)
1632
1633
1634 @pytest.fixture(scope="session")
1635 def cli_config_file():
1636 """Return the path to the CLI config file."""
1637 return os.path.join(_TEST_DIR, "config", "test_config.yaml")
1638
1639
1640 @pytest.fixture(scope="session")
1641 def cli_config_file_with_model():
1642 """Return the path to the CLI config file with model."""
1643 return os.path.join(_TEST_DIR, "config", "test_config_with_model.yaml")
1644
1645
1646 class AssetHandler(http.server.BaseHTTPRequestHandler):
1647 # _IMAGE_CACHE : Dict[str, bytes] = {}
1648
1649 def log_message(self, *args, **kwargs):
1650 pass
1651
1652 def do_GET(self):
1653 # Accepts paths like: /1280px-Venn_diagram_rgb.jpg
1654 filename = self.path.lstrip("/")
1655 if not filename or "." not in filename:
1656 self.send_error(404, "Missing filename (expected /<name>.<ext>)")
1657 return
1658
1659 base, ext = filename.rsplit(".", 1)
1660 ext = ext.lower()
1661
1662 if ext not in ["jpg", "png"]:
1663 self.send_error(404, f"Unsupported extension: .{ext}")
1664 return
1665
1666 try:
1667 data = ImageAsset(base).read_bytes(ext=ext)
1668 except Exception as e:
1669 self.send_error(500, f"Failed to load asset: {ext} {base} {e} ")
1670 return
1671
1672 ctype, _ = mimetypes.guess_type(filename)
1673 if ctype is None:
1674 ctype = {"jpg": "image/jpg", "png": "image/png"}[ext]
1675 self.send_response(200)
1676 self.send_header("Content-Type", ctype)
1677 self.send_header("Content-Length", str(len(data)))
1678 self.end_headers()
1679 try:
1680 self.wfile.write(data)
1681 except (BrokenPipeError, ConnectionResetError) as e:
1682 logger.debug(
1683 "Client disconnected while serving test asset %s: %r", filename, e
1684 )
1685 self.close_connection = True
1686
1687
1688 def _find_free_port() -> int:
1689 with socket.socket() as s:
1690 s.bind(("127.0.0.1", 0))
1691 return s.getsockname()[1]
1692
1693
1694 class LocalAssetServer:
1695 address: str
1696 port: int
1697 server: http.server.ThreadingHTTPServer | None
1698 thread: threading.Thread | None
1699
1700 def __init__(self, address: str = "127.0.0.1") -> None:
1701 self.address = address
1702 self.port = -1
1703 self.server = None
1704 self.thread = None
1705
1706 def __enter__(self):
1707 self.port = _find_free_port()
1708 self.server = http.server.ThreadingHTTPServer(
1709 (self.address, self.port), AssetHandler
1710 )
1711 self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
1712 self.thread.start()
1713 return self
1714
1715 def __exit__(self, exc_type, exc_value, traceback):
1716 if self.server:
1717 self.server.shutdown()
1718 del self.server
1719
1720 if self.thread:
1721 self.thread.join()
1722 del self.thread
1723
1724 if exc_type is None:
1725 return None
1726
1727 return False
1728
1729 @property
1730 def base_url(self) -> str:
1731 assert self.port is not None
1732 return f"http://{self.address}:{self.port}"
1733
1734 def url_for(self, name: str) -> str:
1735 """e.g., name='RGBA_comp.png' -> 'http://127.0.0.1:PORT/RGBA_comp.png'"""
1736 return f"{self.base_url}/{name}"
1737
1738 def get_image_asset(self, name: str) -> Image.Image:
1739 image = fetch_image(self.url_for(name))
1740 # Unwrap MediaWithBytes if present
1741 if isinstance(image, MediaWithBytes):
1742 image = image.media
1743 return image
1744
1745
1746 @pytest.fixture(scope="session")
1747 def local_asset_server() -> Generator[LocalAssetServer, None, None]:
1748 """
1749 Starts a thread based HTTP server bound to 127.0.0.1 on a random free port.
1750 The server currently servers images at:
1751 http://127.0.0.1:<port>/<name>.<ext>
1752 """
1753 with LocalAssetServer() as srv:
1754 yield srv
1755
1756
1757 @pytest.fixture
1758 def image_url(request, local_asset_server) -> str:
1759 # request.param is one of the IMAGE_ASSETS filenames
1760 name = request.param
1761 return local_asset_server.url_for(name)
1762
1763
1764 @pytest.fixture
1765 def image_urls(request, local_asset_server) -> list[str]:
1766 """Indirect fixture: takes a list of names, returns list of full URLs."""
1767 names: list[str] = request.param
1768 return [local_asset_server.url_for(name) for name in names]
1769
1770
1771 @pytest.fixture
1772 def disable_deepgemm_ue8m0(monkeypatch):
1773 from vllm.utils.deep_gemm import is_deep_gemm_e8m0_used
1774
1775 with monkeypatch.context() as monkeypatch_ctx:
1776 monkeypatch_ctx.setenv("VLLM_USE_DEEP_GEMM_E8M0", "0")
1777 is_deep_gemm_e8m0_used.cache_clear()
1778 yield
1779 # Clear cache so the next time it is used it is processed with the
1780 # default VLLM_USE_DEEP_GEMM_E8M0 setting.
1781 is_deep_gemm_e8m0_used.cache_clear()
1782
1783
1784 @pytest.fixture
1785 def gpu_memory_cleared():
1786 """Wait for prior tests to release GPU memory on gfx950."""
1787 if not current_platform.is_rocm():
1788 return
1789
1790 from tests.utils import wait_for_gpu_memory_to_clear
1791 from vllm.platforms.rocm import on_gfx950
1792
1793 if on_gfx950():
1794 wait_for_gpu_memory_to_clear(
1795 devices=[0],
1796 threshold_ratio=0.08,
1797 timeout_s=30,
1798 stable_duration_s=1,
1799 )
1800
1801
1802 def _should_clean_gpu_memory_between_tests() -> bool:
1803 # This must stay opt-in: a function-scoped fixture cannot distinguish
1804 # stale VRAM from allocations owned by longer-lived module/session fixtures.
1805 return os.getenv("VLLM_TEST_CLEAN_GPU_MEMORY", "0") == "1"
1806
1807
1808 @pytest.fixture(autouse=True)
1809 def clean_gpu_memory_between_tests():
1810 if not _should_clean_gpu_memory_between_tests():
1811 yield
1812 return
1813
1814 import gc
1815
1816 from tests.utils import wait_for_gpu_memory_to_clear, wait_for_memory_to_settle
1817
1818 num_gpus = torch.accelerator.device_count()
1819
1820 def _wait_for_settled_gpu_memory() -> None:
1821 if num_gpus <= 0:
1822 return
1823 try:
1824 if current_platform.is_rocm():
1825 wait_for_memory_to_settle()
1826 else:
1827 wait_for_gpu_memory_to_clear(
1828 devices=list(range(num_gpus)),
1829 threshold_ratio=0.1,
1830 )
1831 except ValueError as e:
1832 logger.info("Failed to clean GPU memory: %s", e)
1833
1834 _wait_for_settled_gpu_memory()
1835
1836 yield
1837
1838 if torch.cuda.is_available():
1839 torch.accelerator.empty_cache()
1840 gc.collect()
1841 _wait_for_settled_gpu_memory()
1842
1843
1844 @pytest.fixture
1845 def use_fresh_inductor_cache():
1846 """
1847 Use a fresh inductor cache for the test.
1848 This is useful to ensure that the test is not affected by the
1849 previous test calls.
1850 """
1851 with fresh_cache():
1852 yield
1853
1854
1855 @pytest.fixture
1856 def disable_vllm_compile_cache(monkeypatch, use_fresh_inductor_cache):
1857 """
1858 Use a fresh inductor cache AND disable vLLM's on-disk torch.compile cache.
1859
1860 This forces compilation (and any custom compile passes) to actually run
1861 instead of being served from a warm cache left behind by previous runs
1862 (e.g. on persistent CI agents). Use this for tests that inspect what
1863 happens during compilation; use ``use_fresh_inductor_cache`` (or
1864 ``fresh_vllm_cache``) instead when the vLLM compile cache must stay
1865 enabled (e.g. cache save/load tests).
1866 """
1867 monkeypatch.setenv("VLLM_DISABLE_COMPILE_CACHE", "1")
1868 yield
1869
1870
1871 @pytest.fixture
1872 def fresh_vllm_cache(monkeypatch, use_fresh_inductor_cache):
1873 """Temporary VLLM_CACHE_ROOT combined with a fresh inductor cache."""
1874 with tempfile.TemporaryDirectory() as tmp_dir:
1875 monkeypatch.setenv("VLLM_CACHE_ROOT", tmp_dir)
1876 yield tmp_dir
1877
1878
1879 @pytest.fixture(scope="function")
1880 def enable_pickle(monkeypatch):
1881 """`LLM.apply_model` requires pickling a function."""
1882 monkeypatch.setenv("VLLM_ALLOW_INSECURE_SERIALIZATION", "1")
1883
1884
1885 @pytest.fixture(scope="function")
1886 def disable_log_dedup(monkeypatch):
1887 """
1888 Disable log deduplication such that warning_once and info_once always print.
1889 """
1890
1891 # Patch logger._print_warning_once to remove the lru_cache decorator
1892 from vllm import logger
1893
1894 original_print_warning_once = logger._print_warning_once
1895 original_print_info_once = logger._print_info_once
1896 original_print_debug_once = logger._print_debug_once
1897
1898 logger._print_warning_once = original_print_warning_once.__wrapped__
1899 logger._print_info_once = original_print_info_once.__wrapped__
1900 logger._print_debug_once = original_print_debug_once.__wrapped__
1901
1902 yield
1903 logger._print_warning_once = original_print_warning_once
1904 logger._print_info_once = original_print_info_once
1905 logger._print_debug_once = original_print_debug_once
1906
1907
1908 @pytest.fixture(scope="function")
1909 def fake_vllm_ir(monkeypatch):
1910 """
1911 Pytest fixture to allow isolated IR op registration in tests.
1912
1913 Replaces IrOp.registry with an empty dict and swaps ``vllm_ir_torch_lib`` for a
1914 fresh ``Library`` with a unique namespace per test (see ``Library.ns``).
1915
1916 Torch keeps registrations for the process lifetime; reusing the fragment
1917 name ``vllm_ir`` and defining the same op string again can segfault. A
1918 random library name keeps each fixture run on a disjoint namespace.
1919
1920 The test Library is kept alive until after monkeypatch teardown so PyTorch's
1921 C++ state is not freed while references may still exist.
1922
1923 Usage:
1924 def test_my_ir_op(fake_vllm_ir):
1925 @vllm.ir.register_op
1926 def my_test_op(x: torch.Tensor) -> torch.Tensor:
1927 return x * 2
1928
1929 result = my_test_op(torch.tensor([1, 2, 3]))
1930 # Registry and library cleaned up automatically after the test
1931 """
1932 import secrets
1933
1934 from torch.library import Library
1935 from vllm.ir.op import IrOp
1936
1937 monkeypatch.setattr(IrOp, "registry", {})
1938
1939 # Keep a local reference so the Library is not GC'd before monkeypatch
1940 # teardown restores the original reference.
1941 test_lib = Library(f"vllm_ir_{secrets.token_hex(8)}", "FRAGMENT")
1942 monkeypatch.setattr("vllm.ir.op.vllm_ir_torch_lib", test_lib)
1943
1944 yield
1945
1946 del test_lib