main
py 2,304 lines 78.5 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import atexit
5 import contextlib
6 import copy
7 import functools
8 import importlib
9 import itertools
10 import json
11 import os
12 import random
13 import signal
14 import subprocess
15 import sys
16 import tempfile
17 import threading
18 import time
19 import warnings
20 from collections.abc import Callable, Iterable, MutableMapping, Sequence
21 from contextlib import ExitStack, contextmanager
22 from multiprocessing import Process, get_context
23 from pathlib import Path
24 from typing import TYPE_CHECKING, Any, Literal, cast
25
26 import anthropic
27 import cloudpickle
28 import httpx
29 import openai
30 import pytest
31 import requests
32 import torch
33 import torch.nn.functional as F
34 from typing_extensions import ParamSpec
35
36 import vllm.envs as envs
37 from vllm.distributed import (
38 ensure_model_parallel_initialized,
39 init_distributed_environment,
40 )
41 from vllm.engine.arg_utils import AsyncEngineArgs
42 from vllm.entrypoints.cli.serve import ServeSubcommand
43 from vllm.logger import init_logger
44 from vllm.model_executor.layers.quantization.utils.quant_utils import (
45 QuantKey,
46 )
47 from vllm.model_executor.model_loader import get_model_loader
48 from vllm.platforms import current_platform
49 from vllm.tokenizers import get_tokenizer
50 from vllm.utils.argparse_utils import FlexibleArgumentParser
51 from vllm.utils.mem_constants import GB_bytes
52 from vllm.utils.network_utils import get_open_port
53 from vllm.utils.torch_utils import (
54 set_random_seed, # noqa: F401 - re-exported for use in test files
55 )
56 from vllm.v1.engine.utils import get_engine_process_shutdown_timeout
57
58 if TYPE_CHECKING:
59 from vllm.model_executor.kernels.linear import _KernelT
60
61 logger = init_logger(__name__)
62
63 FP8_DTYPE = current_platform.fp8_dtype()
64
65
66 if current_platform.is_cuda():
67 from vllm.third_party.pynvml import (
68 nvmlDeviceGetHandleByIndex,
69 nvmlDeviceGetHandleByUUID,
70 nvmlDeviceGetMemoryInfo,
71 nvmlInit,
72 nvmlShutdown,
73 )
74
75 @contextmanager
76 def _nvml():
77 try:
78 nvmlInit()
79 yield
80 finally:
81 nvmlShutdown()
82 else:
83
84 @contextmanager
85 def _nvml():
86 yield
87
88
89 VLLM_PATH = Path(__file__).parent.parent
90 """Path to root of the vLLM repository."""
91
92 # ROCm: disable prefix caching and eliminate batch variance to reduce
93 # test flakiness.
94 ROCM_EXTRA_ARGS = (
95 ["--no-enable-prefix-caching", "--max-num-seqs", "1"]
96 if current_platform.is_rocm()
97 else []
98 )
99 # Python-API equivalent of ROCM_EXTRA_ARGS for use with EngineArgs kwargs.
100 ROCM_ENGINE_KWARGS: dict = (
101 {"enable_prefix_caching": False, "max_num_seqs": 1}
102 if current_platform.is_rocm()
103 else {}
104 )
105 _TILELANG_TVM_PYTHONPATH_FRAGMENT = os.path.join(
106 "tilelang", "3rdparty", "tvm", "python"
107 )
108 _SENSITIVE_CLI_ARG_NARGS = {"--api-key": "+", "--hf-token": "?"}
109
110
111 def _redact_sensitive_cli_args(args: Sequence[str]) -> list[str]:
112 redacted_args = list(args)
113 index = 0
114 while index < len(args):
115 name, separator, _ = args[index].partition("=")
116 nargs = _SENSITIVE_CLI_ARG_NARGS.get(name.replace("_", "-"))
117 if nargs is None:
118 index += 1
119 continue
120 if separator:
121 redacted_args[index] = f"{name}=***"
122 index += 1
123 if not separator or nargs == "+":
124 while index < len(args) and not args[index].startswith("-"):
125 redacted_args[index] = "***"
126 index += 1
127 if nargs == "?":
128 break
129 return redacted_args
130
131
132 def _sanitize_pythonpath_value(pythonpath: str | None) -> str:
133 if not pythonpath:
134 return ""
135 entries = []
136 for entry in pythonpath.split(os.pathsep):
137 normalized = entry.replace(os.sep, "/")
138 if _TILELANG_TVM_PYTHONPATH_FRAGMENT.replace(os.sep, "/") in normalized:
139 continue
140 entries.append(entry)
141 return os.pathsep.join(entries)
142
143
144 def _sanitize_pythonpath_env(env: MutableMapping[str, str]) -> None:
145 cleaned = _sanitize_pythonpath_value(env.get("PYTHONPATH"))
146 if cleaned:
147 env["PYTHONPATH"] = cleaned
148 else:
149 env.pop("PYTHONPATH", None)
150
151
152 def _sanitize_current_pythonpath_env() -> None:
153 _sanitize_pythonpath_env(os.environ)
154
155
156 @contextmanager
157 def _temporarily_sanitized_pythonpath_env():
158 original = os.environ.get("PYTHONPATH")
159 _sanitize_current_pythonpath_env()
160 try:
161 yield
162 finally:
163 if original is None:
164 os.environ.pop("PYTHONPATH", None)
165 else:
166 os.environ["PYTHONPATH"] = original
167
168
169 def requires_spawn_multiprocessing() -> bool:
170 """Whether this platform requires spawn instead of fork for test processes."""
171 return current_platform.is_rocm() or current_platform.is_xpu()
172
173
174 def _run_in_new_process_group(
175 child_process_fxn: Callable[[dict[str, str] | None, str, list[str]], None],
176 env_dict: dict[str, str] | None,
177 model: str,
178 vllm_serve_args: list[str],
179 ) -> None:
180 os.setsid()
181 child_process_fxn(env_dict, model, vllm_serve_args)
182
183
184 class RemoteVLLMServer:
185 """Base class for launching vLLM server subprocesses for testing.
186
187 Subclasses must override ``_create_cli_subcommand`` and
188 ``_start_server``.
189 """
190
191 DUMMY_API_KEY = "token-abc123" # vLLM's OpenAI server does not need API key
192 _active_servers: set["RemoteVLLMServer"] = set()
193 _active_servers_lock = threading.RLock()
194 _cleanup_hooks_registered = False
195 _signal_hooks_registered = False
196 _previous_signal_handlers: dict[int, Any] = {}
197 proc: subprocess.Popen
198
199 def _create_cli_subcommand(self):
200 """Return a CLISubcommand instance used to parse CLI args."""
201 raise NotImplementedError
202
203 def _start_server(
204 self, model: str, vllm_serve_args: list[str], env_dict: dict[str, str] | None
205 ) -> None:
206 """Subclasses override this method to customize server process launch."""
207 raise NotImplementedError
208
209 def _pre_download_model(self, model: str, args) -> None:
210 """Download model weights before starting the server to avoid timeout."""
211 is_local = os.path.isdir(model)
212 if not is_local:
213 engine_args = AsyncEngineArgs.from_cli_args(args)
214 model_config = engine_args.create_model_config()
215 load_config = engine_args.create_load_config()
216
217 model_loader = get_model_loader(load_config)
218 model_loader.download_model(model_config)
219
220 def _get_process_termination_timeout(self) -> float:
221 return 15.0
222
223 def __init__(
224 self,
225 model: str,
226 vllm_serve_args: list[str],
227 *,
228 env_dict: dict[str, str] | None = None,
229 seed: int = 0,
230 auto_port: bool = True,
231 max_wait_seconds: float | None = None,
232 override_hf_configs: dict[str, Any] | None = None,
233 ) -> None:
234 if auto_port:
235 if "-p" in vllm_serve_args or "--port" in vllm_serve_args:
236 raise ValueError(
237 "You have manually specified the port when `auto_port=True`."
238 )
239
240 # No need for a port if using unix sockets
241 if "--uds" not in vllm_serve_args:
242 # Don't mutate the input args
243 vllm_serve_args = vllm_serve_args + ["--port", str(get_open_port())]
244 if seed is not None:
245 if "--seed" in vllm_serve_args:
246 raise ValueError(
247 f"You have manually specified the seed when `seed={seed}`."
248 )
249
250 vllm_serve_args = vllm_serve_args + ["--seed", str(seed)]
251
252 if override_hf_configs is not None:
253 vllm_serve_args = vllm_serve_args + [
254 "--hf-overrides",
255 json.dumps(override_hf_configs),
256 ]
257
258 parser = FlexibleArgumentParser(description="vLLM's remote server.")
259 subparsers = parser.add_subparsers(required=False, dest="subparser")
260 parser = self._create_cli_subcommand().subparser_init(subparsers)
261 args = parser.parse_args(["--model", model, *vllm_serve_args])
262 self.uds = args.uds
263 if args.uds:
264 self.host = None
265 self.port = None
266 else:
267 self.host = str(args.host or "127.0.0.1")
268 self.port = int(args.port)
269
270 self.show_hidden_metrics = (
271 getattr(args, "show_hidden_metrics_for_version", None) is not None
272 )
273 self._request_shutdown_timeout = float(args.shutdown_timeout)
274
275 with _temporarily_sanitized_pythonpath_env():
276 self._pre_download_model(model, args)
277 self._shutdown_complete = False
278
279 # Record GPU memory before server start so we know what
280 # "released" looks like.
281 self._pre_server_gpu_memory = self._get_gpu_memory_used()
282 if self._pre_server_gpu_memory is not None:
283 pre_gb = self._pre_server_gpu_memory / 1e9
284 print(
285 f"[{type(self).__name__}] GPU memory before server start: "
286 f"{pre_gb:.2f} GB"
287 )
288
289 self._start_server(model, vllm_serve_args, env_dict)
290 self._register_active_server()
291 max_wait_seconds = max_wait_seconds or 480
292 try:
293 self._wait_for_server(url=self.url_for("health"), timeout=max_wait_seconds)
294 except Exception:
295 # If the server never became healthy, we must still clean up
296 # the subprocess tree. Without this, a timeout in __init__
297 # leaks the server + EngineCore processes (and their GPU
298 # memory), because __exit__ is never called when __init__
299 # raises inside a ``with`` statement.
300 self._shutdown()
301 raise
302
303 def __enter__(self):
304 return self
305
306 def __exit__(self, exc_type, exc_value, traceback):
307 self._shutdown()
308
309 def _shutdown(self) -> None:
310 """Kill the server process tree and wait for GPU memory release.
311
312 Called from both ``__exit__`` (normal path) and ``__init__``
313 (when the server fails to start). Must be safe to call even if
314 the process is already dead.
315 """
316 if self._shutdown_complete:
317 return
318
319 self._shutdown_complete = True
320 try:
321 self._terminate_process_tree()
322 self._wait_for_gpu_memory_release()
323 finally:
324 self._unregister_active_server()
325
326 @classmethod
327 def _ensure_cleanup_hooks_registered(cls) -> None:
328 """Register process-exit cleanup for detached server subprocesses."""
329 root_cls = RemoteVLLMServer
330 with root_cls._active_servers_lock:
331 if not root_cls._cleanup_hooks_registered:
332 atexit.register(root_cls._shutdown_active_servers)
333 root_cls._cleanup_hooks_registered = True
334
335 if (
336 threading.current_thread() is threading.main_thread()
337 and not root_cls._signal_hooks_registered
338 ):
339 for signum in (signal.SIGTERM, signal.SIGINT):
340 root_cls._previous_signal_handlers[signum] = signal.getsignal(
341 signum
342 )
343 signal.signal(signum, root_cls._handle_parent_signal)
344 root_cls._signal_hooks_registered = True
345
346 def _register_active_server(self) -> None:
347 """Track this server so parent-process exits still clean it up."""
348 RemoteVLLMServer._ensure_cleanup_hooks_registered()
349 with RemoteVLLMServer._active_servers_lock:
350 RemoteVLLMServer._active_servers.add(self)
351
352 def _unregister_active_server(self) -> None:
353 with RemoteVLLMServer._active_servers_lock:
354 RemoteVLLMServer._active_servers.discard(self)
355
356 @classmethod
357 def _shutdown_active_servers(cls) -> None:
358 """Best-effort shutdown for all live RemoteVLLMServer instances."""
359 with cls._active_servers_lock:
360 servers = list(cls._active_servers)
361
362 for server in servers:
363 with contextlib.suppress(Exception):
364 server._shutdown()
365
366 @classmethod
367 def _handle_parent_signal(cls, signum, frame) -> None:
368 """Clean up detached servers before letting the signal terminate pytest."""
369 cls._shutdown_active_servers()
370
371 previous_handler = cls._previous_signal_handlers.get(signum, signal.SIG_DFL)
372 if callable(previous_handler):
373 previous_handler(signum, frame)
374 elif previous_handler == signal.SIG_IGN:
375 return
376 elif signum == signal.SIGINT:
377 raise KeyboardInterrupt
378 else:
379 raise SystemExit(128 + signum)
380
381 def _terminate_process_tree(self) -> None:
382 """Kill the server process tree without waiting for GPU memory release.
383
384 Split out from ``_shutdown`` so that ``shutdown_many`` can run this
385 phase in parallel for sibling servers and then wait for GPU memory
386 release once at the end.
387 """
388 pid = self.proc.pid
389
390 # Get the process group ID. Because we used
391 # start_new_session=True the pgid equals the server's pid.
392 try:
393 pgid = os.getpgid(pid)
394 except (ProcessLookupError, OSError):
395 pgid = None
396
397 # Phase 1: graceful SIGTERM to the root process
398 with contextlib.suppress(ProcessLookupError, OSError):
399 self.proc.terminate()
400 print(f"[RemoteOpenAIServer] Sent SIGTERM to process {pid}")
401
402 try:
403 self.proc.wait(timeout=self._get_process_termination_timeout())
404 print(f"[RemoteOpenAIServer] Server {pid} terminated gracefully")
405 except subprocess.TimeoutExpired:
406 # Phase 2: SIGKILL the entire process group
407 print(
408 f"[RemoteOpenAIServer] Server {pid} did not respond "
409 "to SIGTERM, sending SIGKILL to process group"
410 )
411 if pgid is not None:
412 with contextlib.suppress(ProcessLookupError, OSError):
413 os.killpg(pgid, signal.SIGKILL)
414 else:
415 self.proc.kill()
416
417 try:
418 self.proc.wait(timeout=10)
419 print(f"[RemoteOpenAIServer] Server {pid} killed")
420 except subprocess.TimeoutExpired:
421 pass
422
423 # After killing the root process, ensure all children in the
424 # process group (e.g. EngineCore workers) are also dead.
425 # On ROCm especially, surviving children hold GPU contexts and
426 # prevent VRAM from being reclaimed by the driver.
427 self._kill_process_group_survivors(pgid)
428
429 @classmethod
430 def shutdown_many(cls, servers: Sequence["RemoteVLLMServer"]) -> None:
431 """Shut down multiple sibling servers and wait for GPU memory once.
432
433 Test fixtures that hold several ``RemoteVLLMServer`` instances at
434 once must NOT shut them down by calling each server's ``__exit__``
435 sequentially: every server measures total GPU memory across all
436 visible devices in ``_wait_for_gpu_memory_release``, so the first
437 server's wait blocks the full timeout because later sibling
438 servers are still holding GPU memory.
439
440 Instead, this method terminates every server's process tree in
441 parallel, then runs the GPU-memory-release wait once against the
442 earliest recorded baseline (memory before any server started).
443 """
444 if not servers:
445 return
446
447 for server in servers:
448 server._shutdown_complete = True
449
450 threads = [
451 threading.Thread(
452 target=s._terminate_process_tree,
453 name=f"shutdown-{s.proc.pid}",
454 daemon=True,
455 )
456 for s in servers
457 ]
458 for t in threads:
459 t.start()
460 for t in threads:
461 t.join()
462
463 # Use the smallest pre-server baseline so the wait targets memory
464 # usage before *any* of these sibling servers started, not after
465 # earlier siblings had already allocated.
466 earliest = min(
467 servers,
468 key=lambda s: (
469 float("inf")
470 if s._pre_server_gpu_memory is None
471 else s._pre_server_gpu_memory
472 ),
473 )
474 try:
475 earliest._wait_for_gpu_memory_release()
476 finally:
477 for server in servers:
478 server._unregister_active_server()
479
480 def _kill_process_group_survivors(
481 self, pgid: int | None, timeout: float = 15.0
482 ) -> None:
483 """SIGKILL any processes still in the server's process group
484 and wait for them to exit.
485
486 Because the server is launched with ``start_new_session=True``,
487 all its children (EngineCore, workers, etc.) share the same
488 pgid. After the root process is killed, stragglers -- especially
489 on ROCm where GPU contexts linger until the *process* exits --
490 must be reaped explicitly.
491
492 Uses ``/proc`` to scan for pgid members so this works even after
493 the parent has been reaped (unlike ``psutil.Process.children``).
494 """
495 if pgid is None:
496 return
497
498 # Send SIGKILL to the entire process group one more time.
499 # This is cheap and harmless if everyone is already dead.
500 with contextlib.suppress(ProcessLookupError, OSError):
501 os.killpg(pgid, signal.SIGKILL)
502
503 # Collect surviving PIDs by scanning /proc for matching pgid.
504 # This works on Linux even after the parent has been waited on
505 # and is more reliable than psutil.Process(parent).children().
506 survivor_pids = self._find_pgid_members(pgid)
507
508 if not survivor_pids:
509 return
510
511 print(
512 f"[RemoteOpenAIServer] {len(survivor_pids)} process(es) still "
513 f"in pgid {pgid} after SIGKILL: {survivor_pids}"
514 )
515
516 # Wait for each survivor to actually exit so the GPU driver
517 # releases its VRAM.
518 deadline = time.time() + timeout
519 while survivor_pids and time.time() < deadline:
520 still_alive = []
521 for spid in survivor_pids:
522 try:
523 os.kill(spid, 0) # Check if still alive
524 still_alive.append(spid)
525 except (ProcessLookupError, OSError):
526 pass
527 survivor_pids = still_alive
528 if survivor_pids:
529 time.sleep(0.5)
530
531 if survivor_pids:
532 print(
533 f"[RemoteOpenAIServer] WARNING: processes {survivor_pids} "
534 f"in pgid {pgid} could not be killed within {timeout}s"
535 )
536
537 @staticmethod
538 def _find_pgid_members(pgid: int) -> list[int]:
539 """Return PIDs of all living processes whose pgid matches."""
540 members: list[int] = []
541 proc_path = Path("/proc")
542 if not proc_path.is_dir():
543 return members
544 for entry in proc_path.iterdir():
545 if not entry.name.isdigit():
546 continue
547 pid = int(entry.name)
548 try:
549 if os.getpgid(pid) == pgid:
550 members.append(pid)
551 except OSError:
552 continue
553 return members
554
555 def _get_gpu_memory_used(self) -> float | None:
556 """Get device-wide usage across visible devices in bytes.
557
558 On ROCm this initializes HIP in the caller; subsequent multiprocessing
559 GPU workers must use spawn, as the ROCm server helpers already do.
560 """
561 try:
562 if current_platform.is_rocm():
563 # Use HIP logical devices. On MI355 DPX/NPS2, the primary
564 # AMD SMI device can expose whole-card memory counters.
565 total_used = 0
566 for i in range(current_platform.device_count()):
567 free, total = torch.accelerator.get_memory_info(i)
568 total_used += total - free
569 return float(total_used)
570 elif current_platform.is_cuda():
571 with _nvml():
572 total_used = 0
573 device_count = current_platform.device_count()
574 for i in range(device_count):
575 handle = nvmlDeviceGetHandleByIndex(i)
576 mem_info = nvmlDeviceGetMemoryInfo(handle)
577 total_used += mem_info.used
578 return total_used
579 elif current_platform.is_xpu():
580 total_used = 0
581 device_count = current_platform.device_count()
582 for i in range(device_count):
583 free, total = torch.xpu.mem_get_info(i)
584 total_used += total - free
585 return total_used
586 except Exception as e:
587 print(f"[RemoteOpenAIServer] Could not query GPU memory: {e}")
588 return None
589 return None
590
591 def _wait_for_gpu_memory_release(
592 self, timeout: float = 120.0, log_interval: float = 10.0
593 ):
594 """Wait for GPU memory to drop back toward pre-server levels.
595
596 Waits the full timeout for memory to return close to the
597 pre-server baseline. Does NOT fall back to a "stabilization"
598 heuristic -- if memory is still held when the timeout expires,
599 the test fails so the problem is surfaced immediately rather
600 than causing cascading OOM failures in every subsequent test.
601 """
602 baseline = self._pre_server_gpu_memory
603 if baseline is None:
604 # Can't query GPU memory - nothing to do
605 return
606
607 # Allow aggregate driver/context growth above the baseline.
608 headroom_bytes = (
609 4 * 1024 * 1024 * 1024
610 if current_platform.is_rocm()
611 else 2 * 1024 * 1024 * 1024
612 )
613 target = baseline + headroom_bytes
614
615 start = time.time()
616 next_log_time = start + log_interval
617
618 while time.time() - start < timeout:
619 used = self._get_gpu_memory_used()
620
621 if used is None:
622 return # Can't query, assume ok
623
624 used_gb = used / 1e9
625 target_gb = target / 1e9
626 elapsed = time.time() - start
627
628 if used <= target:
629 print(
630 f"[RemoteOpenAIServer] GPU memory released to "
631 f"{used_gb:.2f} GB (target: {target_gb:.2f} GB) "
632 f"in {elapsed:.1f}s"
633 )
634 return
635
636 now = time.time()
637 if now >= next_log_time:
638 print(
639 f"[RemoteOpenAIServer] Waiting for GPU memory release: "
640 f"{used_gb:.2f} GB (target: {target_gb:.2f} GB) "
641 f"[{elapsed:.0f}s/{timeout:.0f}s]"
642 )
643 next_log_time = now + log_interval
644
645 time.sleep(1.0)
646
647 # Timeout -- raise so the current test fails with a clear
648 # message instead of silently poisoning subsequent tests.
649 final_used = self._get_gpu_memory_used()
650 final_gb = final_used / 1e9 if final_used else 0.0
651 raise RuntimeError(
652 f"[RemoteOpenAIServer] GPU memory did not release within "
653 f"{timeout}s. Current: {final_gb:.2f} GB, "
654 f"target: {target / 1e9:.2f} GB, "
655 f"baseline: {baseline / 1e9:.2f} GB. "
656 f"Child processes may still be holding GPU memory."
657 )
658
659 def _poll(self) -> int | None:
660 """Subclasses override this method to customize process polling."""
661 return self.proc.poll()
662
663 def _wait_for_server(self, *, url: str, timeout: float):
664 # run health check
665 start = time.time()
666 client = (
667 httpx.Client(transport=httpx.HTTPTransport(uds=self.uds))
668 if self.uds
669 else requests
670 )
671 while True:
672 try:
673 if client.get(url).status_code == 200:
674 break
675 except Exception:
676 # this exception can only be raised by requests.get,
677 # which means the server is not ready yet.
678 # the stack trace is not useful, so we suppress it
679 # by using `raise from None`.
680 result = self._poll()
681 if result is not None and result != 0:
682 raise RuntimeError("Server exited unexpectedly.") from None
683
684 time.sleep(0.5)
685 if time.time() - start > timeout:
686 raise RuntimeError("Server failed to start in time.") from None
687
688 @property
689 def url_root(self) -> str:
690 return (
691 f"http://{self.uds.split('/')[-1]}"
692 if self.uds
693 else f"http://{self.host}:{self.port}"
694 )
695
696 def url_for(self, *parts: str) -> str:
697 path = "/".join(part.strip("/") for part in parts if part)
698 return f"{self.url_root}/{path}"
699
700 def get_client(self, **kwargs):
701 if "timeout" not in kwargs:
702 kwargs["timeout"] = 600
703 return openai.OpenAI(
704 base_url=self.url_for("v1"),
705 api_key=self.DUMMY_API_KEY,
706 max_retries=0,
707 **kwargs,
708 )
709
710 def get_async_client(self, **kwargs):
711 if "timeout" not in kwargs:
712 kwargs["timeout"] = 600
713 return openai.AsyncOpenAI(
714 base_url=self.url_for("v1"),
715 api_key=self.DUMMY_API_KEY,
716 max_retries=0,
717 **kwargs,
718 )
719
720 def get_client_anthropic(self, **kwargs):
721 if "timeout" not in kwargs:
722 kwargs["timeout"] = 600
723 return anthropic.Anthropic(
724 base_url=self.url_for(),
725 api_key=self.DUMMY_API_KEY,
726 max_retries=0,
727 **kwargs,
728 )
729
730 def get_async_client_anthropic(self, **kwargs):
731 if "timeout" not in kwargs:
732 kwargs["timeout"] = 600
733 return anthropic.AsyncAnthropic(
734 base_url=self.url_for(), api_key=self.DUMMY_API_KEY, max_retries=0, **kwargs
735 )
736
737
738 class RemoteOpenAIServer(RemoteVLLMServer):
739 """Launches ``vllm serve`` for testing OpenAI-compatible endpoints."""
740
741 def _get_process_termination_timeout(self) -> float:
742 engine_timeout = get_engine_process_shutdown_timeout(
743 self._request_shutdown_timeout,
744 self._request_shutdown_timeout,
745 )
746 assert engine_timeout is not None
747 return engine_timeout + super()._get_process_termination_timeout()
748
749 def _create_cli_subcommand(self):
750 return ServeSubcommand()
751
752 def _start_server(
753 self, model: str, vllm_serve_args: list[str], env_dict: dict[str, str] | None
754 ) -> None:
755 env = os.environ.copy()
756 # the current process might initialize cuda,
757 # to be safe, we should use spawn method
758 env["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
759 if env_dict is not None:
760 env.update(env_dict)
761 _sanitize_pythonpath_env(env)
762 serve_cmd = ["vllm", "serve", model, *vllm_serve_args]
763 redacted_serve_cmd = _redact_sensitive_cli_args(serve_cmd)
764 print(f"Launching RemoteOpenAIServer with: {' '.join(redacted_serve_cmd)}")
765 self.proc: subprocess.Popen = subprocess.Popen(
766 serve_cmd,
767 env=env,
768 stdout=sys.stdout,
769 stderr=sys.stderr,
770 # Create a dedicated process group so we can kill
771 # the entire tree (parent + EngineCore + workers) at once.
772 start_new_session=True,
773 )
774
775
776 class RemoteLaunchRenderServer(RemoteVLLMServer):
777 """Launches ``vllm launch render`` for GPU-less serving tests."""
778
779 def _create_cli_subcommand(self):
780 return ServeSubcommand()
781
782 def _start_server(
783 self, model: str, vllm_serve_args: list[str], env_dict: dict[str, str] | None
784 ) -> None:
785 env = os.environ.copy()
786 env["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
787 if env_dict is not None:
788 env.update(env_dict)
789 _sanitize_pythonpath_env(env)
790 serve_cmd = ["vllm", "launch", "render", model, *vllm_serve_args]
791 redacted_serve_cmd = _redact_sensitive_cli_args(serve_cmd)
792 print(
793 f"Launching RemoteLaunchRenderServer with: {' '.join(redacted_serve_cmd)}"
794 )
795 self.proc: subprocess.Popen = subprocess.Popen(
796 serve_cmd,
797 env=env,
798 stdout=sys.stdout,
799 stderr=sys.stderr,
800 start_new_session=True,
801 )
802
803 def _pre_download_model(self, model: str, args) -> None:
804 """Download only the tokenizer files (no model weights needed)."""
805 is_local = os.path.isdir(model)
806 if not is_local:
807 engine_args = AsyncEngineArgs.from_cli_args(args)
808 model_config = engine_args.create_model_config()
809 get_tokenizer(
810 model_config.tokenizer,
811 tokenizer_mode=model_config.tokenizer_mode,
812 trust_remote_code=model_config.trust_remote_code,
813 revision=model_config.tokenizer_revision,
814 )
815
816 def _wait_for_gpu_memory_release(
817 self, timeout: float = 30.0, log_interval: float = 10.0
818 ):
819 pass # No GPU used
820
821
822 class RemoteOpenAIServerCustom(RemoteOpenAIServer):
823 """Launch test server with custom child process."""
824
825 def _start_server(
826 self, model: str, vllm_serve_args: list[str], env_dict: dict[str, str] | None
827 ) -> None:
828 method = "spawn" if requires_spawn_multiprocessing() else "fork"
829 ctx = get_context(method)
830 self.proc: Process = cast(Any, ctx).Process(
831 target=_run_in_new_process_group,
832 args=(self.child_process_fxn, env_dict, model, vllm_serve_args),
833 )
834 with _temporarily_sanitized_pythonpath_env():
835 self.proc.start()
836
837 def __init__(
838 self,
839 model: str,
840 vllm_serve_args: list[str],
841 child_process_fxn: Callable[[dict[str, str] | None, str, list[str]], None],
842 *,
843 env_dict: dict[str, str] | None = None,
844 seed: int = 0,
845 auto_port: bool = True,
846 max_wait_seconds: float | None = None,
847 ) -> None:
848 """Store custom child process function then invoke superclass
849 constructor which will indirectly launch it."""
850 self.child_process_fxn = child_process_fxn
851 super().__init__(
852 model=model,
853 vllm_serve_args=vllm_serve_args,
854 env_dict=env_dict,
855 seed=seed,
856 auto_port=auto_port,
857 max_wait_seconds=max_wait_seconds,
858 )
859
860 def _poll(self) -> int | None:
861 return self.proc.exitcode
862
863 def _terminate_process_tree(self) -> None:
864 pid = self.proc.pid
865 if pid is None:
866 return
867
868 pgid: int | None
869 try:
870 pgid = os.getpgid(pid)
871 # _run_in_new_process_group should make the child the group
872 # leader. Avoid signaling pytest's process group if startup failed
873 # before os.setsid() ran.
874 if pgid != pid:
875 pgid = None
876 except (ProcessLookupError, OSError):
877 pgid = None
878
879 with contextlib.suppress(ProcessLookupError, OSError):
880 self.proc.terminate()
881 print(f"[RemoteOpenAIServerCustom] Sent SIGTERM to process {pid}")
882
883 self.proc.join(self._get_process_termination_timeout())
884 if self.proc.is_alive():
885 print(
886 f"[RemoteOpenAIServerCustom] Server {pid} did not respond "
887 "to SIGTERM, sending SIGKILL to process group"
888 )
889 if pgid is not None:
890 with contextlib.suppress(ProcessLookupError, OSError):
891 os.killpg(pgid, signal.SIGKILL)
892 else:
893 self.proc.kill()
894 self.proc.join(10)
895
896 self._kill_process_group_survivors(pgid)
897
898
899 def _test_completion(
900 client: openai.OpenAI,
901 model: str,
902 prompt: str,
903 token_ids: list[int],
904 include_seeded_sampling: bool = True,
905 ):
906 results = []
907
908 # test with text prompt
909 completion = client.completions.create(
910 model=model, prompt=prompt, max_tokens=5, temperature=0.0
911 )
912
913 results.append(
914 {
915 "test": "single_completion",
916 "text": completion.choices[0].text,
917 "finish_reason": completion.choices[0].finish_reason,
918 "usage": completion.usage,
919 }
920 )
921
922 # test using token IDs
923 completion = client.completions.create(
924 model=model,
925 prompt=token_ids,
926 max_tokens=5,
927 temperature=0.0,
928 )
929
930 results.append(
931 {
932 "test": "token_ids",
933 "text": completion.choices[0].text,
934 "finish_reason": completion.choices[0].finish_reason,
935 "usage": completion.usage,
936 }
937 )
938
939 if include_seeded_sampling:
940 # test seeded random sampling
941 completion = client.completions.create(
942 model=model, prompt=prompt, max_tokens=5, seed=33, temperature=1.0
943 )
944
945 results.append(
946 {
947 "test": "seeded_sampling",
948 "text": completion.choices[0].text,
949 "finish_reason": completion.choices[0].finish_reason,
950 "usage": completion.usage,
951 }
952 )
953
954 # test seeded random sampling with multiple prompts
955 completion = client.completions.create(
956 model=model,
957 prompt=[prompt, prompt],
958 max_tokens=5,
959 seed=33,
960 temperature=1.0,
961 )
962
963 results.append(
964 {
965 "test": "seeded_sampling",
966 "text": [choice.text for choice in completion.choices],
967 "finish_reason": [
968 choice.finish_reason for choice in completion.choices
969 ],
970 "usage": completion.usage,
971 }
972 )
973
974 # test simple list
975 batch = client.completions.create(
976 model=model,
977 prompt=[prompt, prompt],
978 max_tokens=5,
979 temperature=0.0,
980 )
981
982 results.append(
983 {
984 "test": "simple_list",
985 "text0": batch.choices[0].text,
986 "text1": batch.choices[1].text,
987 }
988 )
989
990 # test streaming
991 batch = client.completions.create(
992 model=model,
993 prompt=[prompt, prompt],
994 max_tokens=5,
995 temperature=0.0,
996 stream=True,
997 )
998
999 texts = [""] * 2
1000 for chunk in batch:
1001 assert len(chunk.choices) == 1
1002 choice = chunk.choices[0]
1003 texts[choice.index] += choice.text
1004
1005 results.append(
1006 {
1007 "test": "streaming",
1008 "texts": texts,
1009 }
1010 )
1011
1012 return results
1013
1014
1015 def _test_completion_close(
1016 client: openai.OpenAI,
1017 model: str,
1018 prompt: str,
1019 ):
1020 results = []
1021
1022 # test with text prompt
1023 completion = client.completions.create(
1024 model=model, prompt=prompt, max_tokens=1, logprobs=5, temperature=0.0
1025 )
1026
1027 logprobs = completion.choices[0].logprobs.top_logprobs[0]
1028 logprobs = {k: round(v, 2) for k, v in logprobs.items()}
1029
1030 results.append(
1031 {
1032 "test": "completion_close",
1033 "logprobs": logprobs,
1034 }
1035 )
1036
1037 return results
1038
1039
1040 def _test_chat(
1041 client: openai.OpenAI,
1042 model: str,
1043 prompt: str,
1044 ):
1045 results = []
1046
1047 messages = [{"role": "user", "content": [{"type": "text", "text": prompt}]}]
1048
1049 # test with text prompt
1050 chat_response = client.chat.completions.create(
1051 model=model, messages=messages, max_tokens=5, temperature=0.0
1052 )
1053
1054 results.append(
1055 {
1056 "test": "completion_close",
1057 "text": chat_response.choices[0].message.content,
1058 "finish_reason": chat_response.choices[0].finish_reason,
1059 "usage": chat_response.usage,
1060 }
1061 )
1062
1063 return results
1064
1065
1066 def _test_embeddings(
1067 client: openai.OpenAI,
1068 model: str,
1069 text: str,
1070 ):
1071 results = []
1072
1073 # test with text input
1074 embeddings = client.embeddings.create(
1075 model=model,
1076 input=text,
1077 encoding_format="float",
1078 )
1079
1080 results.append(
1081 {
1082 "test": "single_embedding",
1083 "embedding": embeddings.data[0].embedding,
1084 "usage": embeddings.usage,
1085 }
1086 )
1087
1088 return results
1089
1090
1091 def _test_image_text(
1092 client: openai.OpenAI,
1093 model_name: str,
1094 image_url: str,
1095 ):
1096 results = []
1097
1098 # test pure text input
1099 messages = [
1100 {
1101 "role": "user",
1102 "content": [
1103 {"type": "text", "text": "How do you feel today?"},
1104 ],
1105 }
1106 ]
1107
1108 chat_completion = client.chat.completions.create(
1109 model=model_name,
1110 messages=messages,
1111 temperature=0.0,
1112 max_tokens=1,
1113 logprobs=True,
1114 top_logprobs=5,
1115 )
1116 top_logprobs = chat_completion.choices[0].logprobs.content[0].top_logprobs
1117
1118 for x in top_logprobs:
1119 x.logprob = round(x.logprob, 2)
1120
1121 results.append(
1122 {
1123 "test": "pure_text",
1124 "logprobs": top_logprobs,
1125 }
1126 )
1127
1128 messages = [
1129 {
1130 "role": "user",
1131 "content": [
1132 {"type": "image_url", "image_url": {"url": image_url}},
1133 {"type": "text", "text": "What's in this image?"},
1134 ],
1135 }
1136 ]
1137
1138 chat_completion = client.chat.completions.create(
1139 model=model_name,
1140 messages=messages,
1141 temperature=0.0,
1142 max_tokens=1,
1143 logprobs=True,
1144 top_logprobs=5,
1145 )
1146 top_logprobs = chat_completion.choices[0].logprobs.content[0].top_logprobs
1147
1148 results.append(
1149 {
1150 "test": "text_image",
1151 "logprobs": top_logprobs,
1152 }
1153 )
1154
1155 return results
1156
1157
1158 def compare_two_settings(
1159 model: str,
1160 arg1: list[str],
1161 arg2: list[str],
1162 env1: dict[str, str] | None = None,
1163 env2: dict[str, str] | None = None,
1164 *,
1165 method: str = "generate",
1166 max_wait_seconds: float | None = None,
1167 include_seeded_sampling: bool = True,
1168 force_v1_runner: bool = False,
1169 ) -> None:
1170 """Launch API server with two different sets of arguments/environments
1171 and compare the results of the API calls.
1172
1173 Args:
1174 model: The model to test.
1175 arg1: The first set of arguments to pass to the API server.
1176 arg2: The second set of arguments to pass to the API server.
1177 env1: The first set of environment variables to pass to the API server.
1178 env2: The second set of environment variables to pass to the API server.
1179 include_seeded_sampling: Whether to include temperature=1.0 seeded
1180 sampling checks in the default generate comparison.
1181 force_v1_runner: Whether to pin all compared settings to the v1 model
1182 runner to avoid mixing model runner differences into correctness
1183 tests.
1184
1185 """
1186 compare_all_settings(
1187 model,
1188 [arg1, arg2],
1189 [env1, env2],
1190 method=method,
1191 max_wait_seconds=max_wait_seconds,
1192 include_seeded_sampling=include_seeded_sampling,
1193 force_v1_runner=force_v1_runner,
1194 )
1195
1196
1197 def compare_all_settings(
1198 model: str,
1199 all_args: list[list[str]],
1200 all_envs: list[dict[str, str] | None],
1201 *,
1202 method: str = "generate",
1203 max_wait_seconds: float | None = None,
1204 include_seeded_sampling: bool = True,
1205 force_v1_runner: bool = False,
1206 ) -> None:
1207 """Launch API server with several different sets of arguments/environments
1208 and compare the results of the API calls with the first set of arguments.
1209
1210 Args:
1211 model: The model to test.
1212 all_args: A list of argument lists to pass to the API server.
1213 all_envs: A list of environment dictionaries to pass to the API server.
1214 include_seeded_sampling: Whether to include temperature=1.0 seeded
1215 sampling checks in the default generate comparison.
1216 force_v1_runner: Whether to pin all compared settings to the v1 model
1217 runner to avoid mixing model runner differences into correctness
1218 tests.
1219
1220 """
1221 if force_v1_runner:
1222 all_envs = [
1223 {"VLLM_USE_V2_MODEL_RUNNER": "0", **(env or {})} for env in all_envs
1224 ]
1225
1226 trust_remote_code = False
1227 for args in all_args:
1228 if "--trust-remote-code" in args:
1229 trust_remote_code = True
1230 break
1231
1232 tokenizer_mode = "auto"
1233 for args in all_args:
1234 if "--tokenizer-mode" in args:
1235 tokenizer_mode = args[args.index("--tokenizer-mode") + 1]
1236 break
1237
1238 tokenizer = get_tokenizer(
1239 model,
1240 trust_remote_code=trust_remote_code,
1241 tokenizer_mode=tokenizer_mode,
1242 )
1243
1244 can_force_load_format = True
1245
1246 for args in all_args:
1247 if "--load-format" in args:
1248 can_force_load_format = False
1249 break
1250
1251 prompt = "Hello, my name is"
1252 token_ids = tokenizer(prompt).input_ids
1253 ref_results: list = []
1254 for i, (args, env) in enumerate(zip(all_args, all_envs)):
1255 if can_force_load_format:
1256 # we are comparing the results and
1257 # usually we don't need real weights.
1258 # we force to use dummy weights by default,
1259 # and it should work for most of the cases.
1260 # if not, we can use VLLM_TEST_FORCE_LOAD_FORMAT
1261 # environment variable to force the load format,
1262 # e.g. in quantization tests.
1263 args = args + ["--load-format", envs.VLLM_TEST_FORCE_LOAD_FORMAT]
1264 compare_results: list = []
1265 results = ref_results if i == 0 else compare_results
1266 with RemoteOpenAIServer(
1267 model, args, env_dict=env, max_wait_seconds=max_wait_seconds
1268 ) as server:
1269 client = server.get_client()
1270
1271 # test models list
1272 models = client.models.list()
1273 models = models.data
1274 served_model = models[0]
1275 results.append(
1276 {
1277 "test": "models_list",
1278 "id": served_model.id,
1279 "root": served_model.root,
1280 }
1281 )
1282
1283 if method == "generate":
1284 results += _test_completion(
1285 client,
1286 model,
1287 prompt,
1288 token_ids,
1289 include_seeded_sampling=include_seeded_sampling,
1290 )
1291 elif method == "generate_close":
1292 results += _test_completion_close(client, model, prompt)
1293 elif method == "generate_chat":
1294 results += _test_chat(client, model, prompt)
1295 elif method == "generate_with_image":
1296 results += _test_image_text(
1297 client,
1298 model,
1299 "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/RGBA_comp.png",
1300 )
1301 elif method == "encode":
1302 results += _test_embeddings(client, model, prompt)
1303 else:
1304 raise ValueError(f"Unknown method: {method}")
1305
1306 if i > 0:
1307 # if any setting fails, raise an error early
1308 ref_args = all_args[0]
1309 ref_envs = all_envs[0]
1310 compare_args = all_args[i]
1311 compare_envs = all_envs[i]
1312 for ref_result, compare_result in zip(ref_results, compare_results):
1313 ref_result = copy.deepcopy(ref_result)
1314 compare_result = copy.deepcopy(compare_result)
1315 if "embedding" in ref_result and method == "encode":
1316 sim = F.cosine_similarity(
1317 torch.tensor(ref_result["embedding"]),
1318 torch.tensor(compare_result["embedding"]),
1319 dim=0,
1320 )
1321 assert sim >= 0.999, (
1322 f"Embedding for {model=} are not the same.\n"
1323 f"cosine_similarity={sim}\n"
1324 )
1325 del ref_result["embedding"]
1326 del compare_result["embedding"]
1327 assert ref_result == compare_result, (
1328 f"Results for {model=} are not the same.\n"
1329 f"{ref_args=} {ref_envs=}\n"
1330 f"{compare_args=} {compare_envs=}\n"
1331 f"{ref_result=}\n"
1332 f"{compare_result=}\n"
1333 )
1334
1335
1336 @contextmanager
1337 def ensure_current_vllm_config():
1338 """Ensures a vllm config is set for the duration of the context.
1339
1340 If a config is already set, this is a no-op. Otherwise, it creates a default
1341 VllmConfig and sets it for the duration of the context.
1342
1343 Used for tests that call functions which require a vllm config but don't
1344 need a specific config.
1345
1346 Example:
1347 with ensure_current_vllm_config():
1348 init_distributed_environment(...)
1349 ensure_model_parallel_initialized(...)
1350
1351 """
1352 from vllm.config import (
1353 VllmConfig,
1354 get_current_vllm_config_or_none,
1355 set_current_vllm_config,
1356 )
1357
1358 if get_current_vllm_config_or_none() is not None:
1359 # Config already set, just yield
1360 yield
1361 else:
1362 # No config set, create a default one for the duration
1363 with set_current_vllm_config(VllmConfig()):
1364 yield
1365
1366
1367 def init_test_distributed_environment(
1368 tp_size: int,
1369 pp_size: int,
1370 rank: int,
1371 distributed_init_port: str,
1372 local_rank: int = -1,
1373 data_parallel_size: int = 1,
1374 data_parallel_master_port: int | None = None,
1375 ) -> None:
1376 # Note: This function is often called from Ray worker processes, so we
1377 # can't rely on pytest fixtures to set the config. We check if the config
1378 # is already set and only create a default one if needed.
1379 from vllm.config import (
1380 VllmConfig,
1381 get_current_vllm_config_or_none,
1382 set_current_vllm_config,
1383 )
1384 from vllm.platforms import current_platform
1385
1386 distributed_init_method = f"tcp://localhost:{distributed_init_port}"
1387 backend = current_platform.dist_backend
1388
1389 if data_parallel_size > 1:
1390 # For DP we need to set a common DP master port
1391 from vllm.config.parallel import ParallelConfig
1392
1393 assert data_parallel_master_port is not None, (
1394 "data_parallel_master_port is required when data_parallel_size > 1"
1395 )
1396 tp_pp_world = tp_size * pp_size
1397 parallel_config = ParallelConfig(
1398 data_parallel_size=data_parallel_size,
1399 data_parallel_rank=rank // tp_pp_world,
1400 _data_parallel_master_port_list=[int(data_parallel_master_port)],
1401 )
1402 with set_current_vllm_config(VllmConfig(parallel_config=parallel_config)):
1403 init_distributed_environment(
1404 world_size=tp_pp_world,
1405 rank=rank % tp_pp_world,
1406 distributed_init_method=distributed_init_method,
1407 local_rank=local_rank if local_rank >= 0 else rank,
1408 backend=backend,
1409 )
1410 ensure_model_parallel_initialized(tp_size, pp_size)
1411 return
1412
1413 if get_current_vllm_config_or_none() is not None:
1414 # Config already set, use it directly
1415 init_distributed_environment(
1416 world_size=pp_size * tp_size,
1417 rank=rank,
1418 distributed_init_method=distributed_init_method,
1419 local_rank=local_rank,
1420 backend=backend,
1421 )
1422 ensure_model_parallel_initialized(tp_size, pp_size)
1423 else:
1424 # No config set, create a default one for the test
1425 with set_current_vllm_config(VllmConfig()):
1426 init_distributed_environment(
1427 world_size=pp_size * tp_size,
1428 rank=rank,
1429 distributed_init_method=distributed_init_method,
1430 local_rank=local_rank,
1431 backend=backend,
1432 )
1433 ensure_model_parallel_initialized(tp_size, pp_size)
1434
1435
1436 def multi_process_parallel(
1437 monkeypatch: pytest.MonkeyPatch,
1438 tp_size: int,
1439 pp_size: int,
1440 test_target: Any,
1441 data_parallel_size: int = 1,
1442 ) -> None:
1443 import ray
1444
1445 # Using ray helps debugging the error when it failed as compared to
1446 # multiprocessing. For local Ray workers, putting the repo root on
1447 # PYTHONPATH is enough and avoids uploading the full source tree, which
1448 # exceeds Ray's working_dir package size limit on CI.
1449 env_vars = {
1450 "PYTHONPATH": os.pathsep.join(
1451 filter(None, [str(VLLM_PATH), os.environ.get("PYTHONPATH")])
1452 ),
1453 **{env_var: "1" for env_var in current_platform.ray_noset_device_env_vars},
1454 }
1455 ray.init(
1456 runtime_env={
1457 "env_vars": env_vars,
1458 }
1459 )
1460
1461 distributed_init_port = get_open_port()
1462 # Separate port for the DP master group; only used when data_parallel_size > 1.
1463 data_parallel_master_port = get_open_port() if data_parallel_size > 1 else None
1464 world_size = data_parallel_size * tp_size * pp_size
1465 try:
1466 refs = []
1467 for rank in range(world_size):
1468 if data_parallel_size > 1:
1469 refs.append(
1470 test_target.remote(
1471 monkeypatch,
1472 tp_size,
1473 pp_size,
1474 rank,
1475 distributed_init_port,
1476 data_parallel_size,
1477 data_parallel_master_port,
1478 ),
1479 )
1480 else:
1481 refs.append(
1482 test_target.remote(
1483 monkeypatch,
1484 tp_size,
1485 pp_size,
1486 rank,
1487 distributed_init_port,
1488 ),
1489 )
1490 ray.get(refs)
1491 finally:
1492 ray.shutdown()
1493
1494
1495 def assert_rocm_custom_allreduce_backend_state(
1496 use_aiter_custom_ar: bool,
1497 quick_reduce_quantization: str,
1498 ) -> None:
1499 from vllm.distributed.parallel_state import get_tp_group
1500
1501 device_communicator = get_tp_group().device_communicator
1502 aiter_ar_comm = device_communicator.aiter_ar_comm
1503 if use_aiter_custom_ar:
1504 assert aiter_ar_comm is not None, "AITER CustomAllreduce was not initialized."
1505 assert not aiter_ar_comm.disabled, "AITER CustomAllreduce is disabled."
1506 assert device_communicator.ca_comm is None, (
1507 "vLLM CustomAllreduce should not be initialized when AITER CA is used."
1508 )
1509 else:
1510 assert aiter_ar_comm is None, (
1511 "AITER CustomAllreduce should not be initialized when disabled."
1512 )
1513 assert device_communicator.ca_comm is not None, (
1514 "vLLM CustomAllreduce should be initialized when AITER CA is disabled."
1515 )
1516
1517 qr_comm = device_communicator.qr_comm
1518 assert qr_comm is not None, "QuickReduce communicator was not initialized."
1519 if quick_reduce_quantization == "NONE":
1520 assert qr_comm.disabled, "QuickReduce should be disabled."
1521 else:
1522 assert not qr_comm.disabled, "QuickReduce should be enabled."
1523
1524
1525 def assert_rocm_custom_allreduce_backend_state_on_worker(
1526 _worker,
1527 use_aiter_custom_ar: bool,
1528 quick_reduce_quantization: str,
1529 ) -> None:
1530 assert_rocm_custom_allreduce_backend_state(
1531 use_aiter_custom_ar, quick_reduce_quantization
1532 )
1533
1534
1535 def get_physical_device_indices(devices: list[int]):
1536 visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES")
1537 if visible_devices is None:
1538 return devices
1539
1540 visible_indices = [int(x) for x in visible_devices.split(",")]
1541 index_mapping = {i: physical for i, physical in enumerate(visible_indices)}
1542 return [index_mapping[i] for i in devices if i in index_mapping]
1543
1544
1545 def get_nvml_device_handle(device: int):
1546 visible_devices = os.environ.get("NVIDIA_VISIBLE_DEVICES")
1547 if visible_devices is not None:
1548 identifiers = visible_devices.split(",")
1549 if device < len(identifiers):
1550 identifier = identifiers[device]
1551 if identifier.startswith(("GPU-", "MIG-")):
1552 return nvmlDeviceGetHandleByUUID(identifier)
1553
1554 return nvmlDeviceGetHandleByIndex(device)
1555
1556
1557 @_nvml()
1558 def record_gpu_memory_usage_stats(
1559 *,
1560 devices: list[int],
1561 ) -> dict[int, tuple[float, float]]:
1562 """Return device-wide used/total GiB; ROCm IDs are HIP logical devices.
1563
1564 ROCm queries initialize HIP in the caller and include other processes'
1565 allocations on the same memory partition.
1566 """
1567 output: dict[int, tuple[float, float]] = {}
1568 for device in devices:
1569 if current_platform.is_rocm():
1570 free, total = torch.accelerator.get_memory_info(device)
1571 gb_used = (total - free) / 2**30
1572 gb_total = total / 2**30
1573 elif current_platform.is_xpu():
1574 # nvml/amdsmi are unavailable on XPU. Query device memory through
1575 # torch.accelerator.get_memory_info, which the XPU platform patches
1576 # to return (free, total) bytes via Level Zero.
1577 free_b, total_b = torch.accelerator.get_memory_info(device)
1578 gb_used = (total_b - free_b) / 2**30
1579 gb_total = total_b / 2**30
1580 else:
1581 dev_handle = get_nvml_device_handle(device)
1582 mem_info = nvmlDeviceGetMemoryInfo(dev_handle)
1583 gb_used = mem_info.used / 2**30
1584 gb_total = mem_info.total / 2**30
1585 output[device] = (gb_used, gb_total)
1586 return output
1587
1588
1589 def wait_for_gpu_memory_to_clear(
1590 *,
1591 devices: list[int],
1592 threshold_bytes: int | dict[int, int] | None = None,
1593 threshold_ratio: float | dict[int, float] | None = None,
1594 timeout_s: float = 120,
1595 stable_duration_s: float = 0,
1596 stable_tolerance_bytes: int = 512 * 1024**2,
1597 poll_interval_s: float = 5,
1598 ) -> None:
1599 assert threshold_bytes is not None or threshold_ratio is not None
1600 # HIP already applies device visibility; keep logical IDs and threshold keys.
1601 if not current_platform.is_rocm():
1602 devices = get_physical_device_indices(devices)
1603 if isinstance(threshold_bytes, int):
1604 threshold_bytes = {device: threshold_bytes for device in devices}
1605 elif isinstance(threshold_bytes, dict):
1606 assert threshold_bytes.keys() == set(devices)
1607 if isinstance(threshold_ratio, float):
1608 threshold_ratio = {device: threshold_ratio for device in devices}
1609 elif isinstance(threshold_ratio, dict):
1610 assert threshold_ratio.keys() == set(devices)
1611 if current_platform.is_rocm() and threshold_ratio is not None:
1612 # ROCm can keep a small runtime/driver footprint resident even after
1613 # all model allocations are gone. On MI300 this has been observed
1614 # around 2.5 GiB, which is above a strict 1% idle threshold but nowhere
1615 # near the amount of free memory needed by the next vLLM runner.
1616 min_threshold_b = 4 * 1024**3
1617 if threshold_bytes is None:
1618 threshold_bytes = {}
1619 for device, ratio in threshold_ratio.items():
1620 threshold_bytes[device] = max(
1621 threshold_bytes.get(device, 0), min_threshold_b if ratio < 0.05 else 0
1622 )
1623
1624 start_time = time.time()
1625 stable_since: float | None = None
1626 stable_used_bytes: dict[int, int] | None = None
1627 while True:
1628 output_raw = record_gpu_memory_usage_stats(devices=devices)
1629 used_bytes_by_device = {
1630 device: int(gb_used * 2**30) for device, (gb_used, _) in output_raw.items()
1631 }
1632 output = {
1633 device: f"{gb_used:.02f}/{gb_total:.02f}"
1634 for device, (gb_used, gb_total) in output_raw.items()
1635 }
1636 print("gpu memory used/total (GiB): ", end="")
1637 for k, v in output.items():
1638 print(f"{k}={v}; ", end="")
1639 print("")
1640
1641 if threshold_bytes is not None and threshold_ratio is not None:
1642 threshold_gib = {
1643 device: threshold_b / 2**30
1644 for device, threshold_b in threshold_bytes.items()
1645 }
1646 threshold = "; ".join(
1647 f"{device=}: max({threshold_gib[device]:.2f} GiB, "
1648 f"{threshold_ratio[device]:.3f})"
1649 for device in devices
1650 )
1651 all_free = all(
1652 used <= max(threshold_gib[device], total * threshold_ratio[device])
1653 for device, (used, total) in output_raw.items()
1654 )
1655 elif threshold_bytes is not None:
1656 threshold_gib = {
1657 device: threshold_b / 2**30
1658 for device, threshold_b in threshold_bytes.items()
1659 }
1660 threshold = "; ".join(
1661 f"{device=}: {threshold_gib[device]:.2f} GiB" for device in devices
1662 )
1663 all_free = all(
1664 used <= threshold_gib[device]
1665 for device, (used, _) in output_raw.items()
1666 )
1667 else:
1668 assert threshold_ratio is not None
1669 threshold = "; ".join(
1670 f"{device=}: {threshold_ratio[device]:.3f}" for device in devices
1671 )
1672 all_free = all(
1673 used / total <= threshold_ratio[device]
1674 for device, (used, total) in output_raw.items()
1675 )
1676
1677 dur_s = time.time() - start_time
1678 if all_free:
1679 if stable_duration_s <= 0:
1680 print(
1681 f"Done waiting for free GPU memory on devices {devices=} "
1682 f"({threshold=}) {dur_s=:.02f}"
1683 )
1684 break
1685
1686 now = time.time()
1687 if stable_used_bytes is None:
1688 stable_since = now
1689 stable_used_bytes = used_bytes_by_device
1690 else:
1691 memory_changed = any(
1692 abs(used_bytes_by_device[device] - stable_used_bytes[device])
1693 > stable_tolerance_bytes
1694 for device in devices
1695 )
1696 if memory_changed:
1697 stable_since = now
1698 stable_used_bytes = used_bytes_by_device
1699 elif (
1700 stable_since is not None and now - stable_since >= stable_duration_s
1701 ):
1702 print(
1703 f"Done waiting for stable free GPU memory on devices "
1704 f"{devices=} ({threshold=}) {dur_s=:.02f}"
1705 )
1706 break
1707 else:
1708 stable_since = None
1709 stable_used_bytes = None
1710
1711 if dur_s >= timeout_s:
1712 raise ValueError(
1713 f"Memory of devices {devices=} not free after "
1714 f"{dur_s=:.02f} ({threshold=})"
1715 )
1716
1717 time.sleep(poll_interval_s)
1718
1719
1720 def wait_for_memory_to_settle(
1721 *,
1722 threshold_ratio: float | dict[int, float] | None = 0.1,
1723 timeout_s: float = 240,
1724 ) -> None:
1725 """Block until ROCm or XPU device VRAM usage drops below ``threshold_ratio``.
1726
1727 ROCm and XPU reclaims GPU memory more lazily than CUDA, so back-to-back model
1728 loads in a single test process can OOM the *next* engine/model startup
1729 even after ``cleanup_dist_env_and_memory``. This gives the driver time to
1730 actually release VRAM before the next allocation. No-op off ROCm.
1731 """
1732 if not current_platform.is_rocm() and not current_platform.is_xpu():
1733 return
1734
1735 num_gpus = current_platform.device_count()
1736 if num_gpus == 0:
1737 return
1738 if threshold_ratio is None:
1739 threshold_ratio = 0.1
1740
1741 wait_for_gpu_memory_to_clear(
1742 devices=list(range(num_gpus)),
1743 threshold_ratio=threshold_ratio,
1744 timeout_s=timeout_s,
1745 stable_duration_s=2.0,
1746 poll_interval_s=1.0,
1747 )
1748
1749
1750 _P = ParamSpec("_P")
1751
1752
1753 def fork_new_process_for_each_test(func: Callable[_P, None]) -> Callable[_P, None]:
1754 """Decorator to fork a new process for each test function.
1755 See https://github.com/vllm-project/vllm/issues/7053 for more details.
1756 """
1757
1758 @functools.wraps(func)
1759 def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None:
1760 from _pytest.outcomes import Skipped
1761
1762 # Create a unique temporary file to store exception info from child
1763 # process. Use test function name and process ID to avoid collisions.
1764 with (
1765 tempfile.NamedTemporaryFile(
1766 delete=False,
1767 mode="w+b",
1768 prefix=f"vllm_test_{func.__name__}_{os.getpid()}_",
1769 suffix=".exc",
1770 ) as exc_file,
1771 ExitStack() as delete_after,
1772 ):
1773 exc_file_path = exc_file.name
1774 delete_after.callback(os.remove, exc_file_path)
1775
1776 pid = os.fork()
1777 print(f"Fork a new process to run a test {pid}")
1778 if pid == 0:
1779 # Make the child process the leader of its own process group
1780 # to avoid sending SIGTERM to the parent process
1781 os.setpgrp()
1782 # Parent process responsible for deleting, don't delete
1783 # in child.
1784 delete_after.pop_all()
1785 try:
1786 func(*args, **kwargs)
1787 except Skipped as e:
1788 # convert Skipped to exit code 0
1789 print(str(e))
1790 os._exit(0)
1791 except Exception as e:
1792 import traceback
1793
1794 tb_string = traceback.format_exc()
1795
1796 # Try to serialize the exception object first
1797 exc_to_serialize: dict[str, Any]
1798 try:
1799 # First, try to pickle the actual exception with
1800 # its traceback.
1801 exc_to_serialize = {"pickled_exception": e}
1802 # Test if it can be pickled
1803 cloudpickle.dumps(exc_to_serialize)
1804 except (Exception, KeyboardInterrupt):
1805 # Fall back to string-based approach.
1806 exc_to_serialize = {
1807 "exception_type": type(e).__name__,
1808 "exception_msg": str(e),
1809 "traceback": tb_string,
1810 }
1811 try:
1812 with open(exc_file_path, "wb") as f:
1813 cloudpickle.dump(exc_to_serialize, f)
1814 except Exception:
1815 # Fallback: just print the traceback.
1816 print(tb_string)
1817 os._exit(1)
1818 else:
1819 os._exit(0)
1820 else:
1821 # After setpgrp(), the child's pgid equals its pid
1822 pgid = pid
1823 _pid, _exitcode = os.waitpid(pid, 0)
1824 # kill all child processes - but they may already have exited cleanly
1825 with contextlib.suppress(ProcessLookupError):
1826 os.killpg(pgid, signal.SIGTERM)
1827 if _exitcode != 0:
1828 # Try to read the exception from the child process
1829 exc_info = {}
1830 if os.path.exists(exc_file_path):
1831 with (
1832 contextlib.suppress(Exception),
1833 open(exc_file_path, "rb") as f,
1834 ):
1835 exc_info = cloudpickle.load(f)
1836
1837 if (
1838 original_exception := exc_info.get("pickled_exception")
1839 ) is not None:
1840 # Re-raise the actual exception object if it was
1841 # successfully pickled.
1842 assert isinstance(original_exception, Exception)
1843 raise original_exception
1844
1845 if (original_tb := exc_info.get("traceback")) is not None:
1846 # Use string-based traceback for fallback case
1847 raise AssertionError(
1848 f"Test {func.__name__} failed when called with"
1849 f" args {args} and kwargs {kwargs}"
1850 f" (exit code: {_exitcode}):\n{original_tb}"
1851 ) from None
1852
1853 # Fallback to the original generic error
1854 raise AssertionError(
1855 f"function {func.__name__} failed when called with"
1856 f" args {args} and kwargs {kwargs}"
1857 f" (exit code: {_exitcode})"
1858 ) from None
1859
1860 return wrapper
1861
1862
1863 def _format_subprocess_exit(returncode: int) -> str:
1864 """Render a subprocess exit code, naming the signal for negative codes."""
1865 if returncode >= 0:
1866 return f"exit code {returncode}"
1867 try:
1868 return f"killed by {signal.Signals(-returncode).name} ({returncode})"
1869 except ValueError:
1870 return f"exit code {returncode}"
1871
1872
1873 # Set on the spawn-child interpreter so the wrapper short-circuits when the
1874 # child resolves `module.qualname` back to its own decorated form, instead of
1875 # launching another subprocess.
1876 _SPAWN_CHILD_ENV = "VLLM_TEST_SPAWN_CHILD"
1877
1878
1879 def spawn_new_process_for_each_test(f: Callable[_P, None]) -> Callable[_P, None]:
1880 """Decorator to spawn a new process for each test function.
1881
1882 Uses subprocess to run each test in a fresh interpreter and propagates
1883 exceptions back to the parent, so test failures are never silently
1884 swallowed (fixes https://github.com/vllm-project/vllm/issues/41415).
1885
1886 The child resolves the test function by importing its module and looking
1887 it up by qualified name, rather than reconstructing it from a cloudpickle
1888 blob. Pickling the function by value would also pickle its ``__globals__``
1889 by value — turning module-level singletons (e.g.
1890 ``vllm.compilation.counter.compilation_counter``) into stale clones in
1891 the child, so increments performed by the production code in the child
1892 would never be observable to the test.
1893
1894 The child inherits the parent's stdout/stderr so its output (engine
1895 cores, NCCL, CUDA, ...) reaches the test runner live; the Python-level
1896 traceback or skip reason is serialized to ``tb_file`` for propagation. A
1897 native crash leaves ``tb_file`` empty — the diagnostic is then only in
1898 the inherited subprocess output.
1899 """
1900
1901 @functools.wraps(f)
1902 def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None:
1903 if os.environ.get(_SPAWN_CHILD_ENV) == "1":
1904 return f(*args, **kwargs)
1905
1906 with tempfile.NamedTemporaryFile(delete=False, suffix=".tb", mode="wb") as tmp:
1907 tb_file = tmp.name
1908
1909 try:
1910 payload = cloudpickle.dumps(
1911 {
1912 "module": f.__module__,
1913 "qualname": f.__qualname__,
1914 "args": args,
1915 "kwargs": kwargs,
1916 "tb_file": tb_file,
1917 }
1918 )
1919
1920 child_script = (
1921 "import sys, importlib, cloudpickle, traceback, json\n"
1922 "try:\n"
1923 " from _pytest.outcomes import Skipped\n"
1924 "except ImportError:\n"
1925 " class Skipped(BaseException): pass\n"
1926 "data = cloudpickle.loads(sys.stdin.buffer.read())\n"
1927 "mod = importlib.import_module(data['module'])\n"
1928 "target = mod\n"
1929 "for name in data['qualname'].split('.'):\n"
1930 " target = getattr(target, name)\n"
1931 "try:\n"
1932 " target(*data['args'], **data['kwargs'])\n"
1933 "except Skipped as exc:\n"
1934 " with open(data['tb_file'], 'w') as fp:\n"
1935 " json.dump(str(exc), fp)\n"
1936 " sys.exit(0)\n"
1937 "except BaseException:\n"
1938 " with open(data['tb_file'], 'w') as fp:\n"
1939 " fp.write(traceback.format_exc())\n"
1940 " sys.exit(1)\n"
1941 )
1942
1943 repo_root = str(VLLM_PATH.resolve())
1944 env = os.environ.copy()
1945 env["PYTHONPATH"] = repo_root + os.pathsep + env.get("PYTHONPATH", "")
1946 env[_SPAWN_CHILD_ENV] = "1"
1947
1948 result = subprocess.run(
1949 [sys.executable, "-c", child_script],
1950 input=payload,
1951 env=env,
1952 )
1953
1954 try:
1955 with open(tb_file) as fp:
1956 tb = fp.read()
1957 except OSError:
1958 tb = ""
1959 if result.returncode != 0:
1960 if not tb:
1961 tb = "<no Python traceback; see subprocess output above>"
1962 raise RuntimeError(
1963 f"Test subprocess '{f.__name__}' failed "
1964 f"({_format_subprocess_exit(result.returncode)}):\n{tb}"
1965 )
1966 if tb:
1967 pytest.skip(json.loads(tb))
1968 finally:
1969 with contextlib.suppress(OSError):
1970 os.remove(tb_file)
1971
1972 return wrapper
1973
1974
1975 def create_new_process_for_each_test(
1976 method: Literal["spawn", "fork"] | None = None,
1977 ) -> Callable[[Callable[_P, None]], Callable[_P, None]]:
1978 """Creates a decorator that runs each test function in a new process.
1979
1980 Args:
1981 method: The process creation method. Can be either "spawn" or "fork".
1982 If not specified, it defaults to "spawn" on ROCm and XPU
1983 platforms and "fork" otherwise.
1984
1985 Returns:
1986 A decorator to run test functions in separate processes.
1987
1988 """
1989 if method is None:
1990 method = "spawn" if requires_spawn_multiprocessing() else "fork"
1991
1992 assert method in ["spawn", "fork"], "Method must be either 'spawn' or 'fork'"
1993
1994 if method == "fork":
1995 return fork_new_process_for_each_test
1996
1997 return spawn_new_process_for_each_test
1998
1999
2000 def large_gpu_mark(min_gb: int) -> pytest.MarkDecorator:
2001 """Get a pytest mark, which skips the test if the GPU doesn't meet
2002 a minimum memory requirement in GB.
2003
2004 This can be leveraged via `@large_gpu_test` to skip tests in environments
2005 without enough resources, or called when filtering tests to run directly.
2006 """
2007 try:
2008 if current_platform.is_cpu():
2009 memory_gb = 0
2010 else:
2011 memory_gb = current_platform.get_device_total_memory() / GB_bytes
2012 except Exception as e:
2013 warnings.warn(
2014 f"An error occurred when finding the available memory: {e}",
2015 stacklevel=2,
2016 )
2017 memory_gb = 0
2018
2019 return pytest.mark.skipif(
2020 memory_gb < min_gb,
2021 reason=(
2022 f"Need at least {min_gb}GB GPU memory to run the test "
2023 f"(found {memory_gb}GB)."
2024 ),
2025 )
2026
2027
2028 requires_fp8 = pytest.mark.skipif(
2029 not current_platform.supports_fp8(),
2030 reason="FP8 is not supported on this GPU (requires Hopper or "
2031 "Ada architecture, compute capability 8.9+)",
2032 )
2033
2034
2035 def large_gpu_test(*, min_gb: int):
2036 """Decorate a test to be skipped if no GPU is available or it does not have
2037 sufficient memory.
2038
2039 Currently, the CI machine uses L4 GPU which has 24 GB VRAM.
2040 """
2041 mark = large_gpu_mark(min_gb)
2042
2043 def wrapper(f: Callable[_P, None]) -> Callable[_P, None]:
2044 return mark(f)
2045
2046 return wrapper
2047
2048
2049 def multi_gpu_marks(*, num_gpus: int):
2050 """Get a collection of pytest marks to apply for `@multi_gpu_test`."""
2051 test_selector = pytest.mark.distributed(num_gpus=num_gpus)
2052 test_skipif = pytest.mark.skipif(
2053 current_platform.device_count() < num_gpus,
2054 reason=f"Need at least {num_gpus} GPUs to run the test.",
2055 )
2056
2057 return [test_selector, test_skipif]
2058
2059
2060 def multi_gpu_test(*, num_gpus: int):
2061 """Decorate a test to be run only when multiple GPUs are available."""
2062 marks = multi_gpu_marks(num_gpus=num_gpus)
2063
2064 def wrapper(f: Callable[_P, None]) -> Callable[_P, None]:
2065 func = create_new_process_for_each_test()(f)
2066 for mark in reversed(marks):
2067 func = mark(func)
2068
2069 return func
2070
2071 return wrapper
2072
2073
2074 def gpu_tier_mark(*, min_gpus: int = 1, max_gpus: int | None = None):
2075 """Mark a test to only run when the GPU count falls within [min_gpus, max_gpus].
2076
2077 Examples:
2078 @gpu_tier_mark(min_gpus=2) # only on multi-GPU
2079 @gpu_tier_mark(max_gpus=1) # only on single-GPU
2080 @gpu_tier_mark(min_gpus=2, max_gpus=4) # 2-4 GPUs only
2081
2082 """
2083 gpu_count = current_platform.device_count()
2084 marks = []
2085
2086 if min_gpus > 1:
2087 marks.append(pytest.mark.distributed(num_gpus=min_gpus))
2088
2089 reasons = []
2090 if gpu_count < min_gpus:
2091 reasons.append(f"Need at least {min_gpus} GPUs (have {gpu_count})")
2092 if max_gpus is not None and gpu_count > max_gpus:
2093 reasons.append(f"Need at most {max_gpus} GPUs (have {gpu_count})")
2094
2095 if reasons:
2096 marks.append(pytest.mark.skipif(True, reason="; ".join(reasons)))
2097
2098 return marks
2099
2100
2101 def single_gpu_only(f=None):
2102 """Skip this test when running in a multi-GPU environment."""
2103 marks = gpu_tier_mark(max_gpus=1)
2104
2105 def wrapper(func):
2106 for mark in reversed(marks):
2107 func = mark(func)
2108 return func
2109
2110 return wrapper(f) if f is not None else wrapper
2111
2112
2113 def multi_gpu_only(*, num_gpus: int = 2):
2114 """Skip this test when running on fewer than num_gpus GPUs."""
2115 marks = gpu_tier_mark(min_gpus=num_gpus)
2116
2117 def wrapper(f):
2118 for mark in reversed(marks):
2119 f = mark(f)
2120 return f
2121
2122 return wrapper
2123
2124
2125 def has_module_attribute(module_name, attribute_name):
2126 """Helper function to check if a module has a specific attribute."""
2127 try:
2128 module = importlib.import_module(module_name)
2129 return hasattr(module, attribute_name)
2130 except ImportError:
2131 return False
2132
2133
2134 def get_attn_backend_list_based_on_platform() -> list[str]:
2135 if current_platform.is_cuda():
2136 return ["FLASH_ATTN", "TRITON_ATTN"]
2137 elif current_platform.is_rocm():
2138 attn_backend_list = ["TRITON_ATTN"]
2139 try:
2140 import aiter # noqa: F401
2141
2142 attn_backend_list.append("ROCM_AITER_FA")
2143 except Exception:
2144 print("Skip ROCM_AITER_FA on ROCm as aiter is not installed")
2145
2146 return attn_backend_list
2147 elif current_platform.is_xpu():
2148 return ["FLASH_ATTN", "TRITON_ATTN"]
2149 else:
2150 raise ValueError("Unsupported platform")
2151
2152
2153 def prep_prompts(batch_size: int, ln_range: tuple[int, int] = (800, 1100)):
2154 """Generate prompts which a bunch of assignments,
2155 then asking for the value of one of them.
2156 The prompt is just under 10k tokens; sliding window is 4k
2157 so the answer is outside sliding window, but should still be correct.
2158
2159 Args:
2160 batch_size: number of prompts to generate
2161 ln_range: an argument to control the length of the prompt
2162
2163 """
2164 prompts: list[str] = []
2165 answer: list[int] = []
2166 indices: list[int] = []
2167 random.seed(1)
2168 for _ in range(batch_size):
2169 idx = random.randint(30, 90)
2170 indices.append(idx)
2171 prompt = (
2172 "```python\n# We set a number of variables, "
2173 f"x{idx} will be important later\n"
2174 )
2175 ln = random.randint(*ln_range)
2176 for k in range(30, ln):
2177 v = random.randint(10, 99)
2178 if k == idx:
2179 answer.append(v)
2180 prompt += f"x{k} = {v}\n"
2181 prompt += f"# Now, we check the value of x{idx}:\n"
2182 prompt += f"assert x{idx} == "
2183 prompts.append(prompt)
2184 return prompts, answer, indices
2185
2186
2187 def check_answers(
2188 indices: list[int], answer: list[int], outputs: list[str], accept_rate: float = 0.7
2189 ):
2190 answer2 = [int(text[0:2].strip()) for text in outputs]
2191 print(list(zip(indices, zip(answer, answer2))))
2192 numok = 0
2193 for a1, a2 in zip(answer, answer2):
2194 if a1 == a2:
2195 numok += 1
2196 frac_ok = numok / len(answer)
2197 print(f"Num OK: {numok}/{len(answer)} {frac_ok}")
2198 assert frac_ok >= accept_rate
2199
2200
2201 def flat_product(*iterables: Iterable[Any]):
2202 """Flatten lists of tuples of the cartesian product.
2203 Useful when we want to avoid nested tuples to allow
2204 test params to be unpacked directly from the decorator.
2205
2206 Example:
2207 flat_product([(1, 2), (3, 4)], ["a", "b"]) ->
2208 [
2209 (1, 2, "a"),
2210 (1, 2, "b"),
2211 (3, 4, "a"),
2212 (3, 4, "b"),
2213 ]
2214
2215 """
2216 for element in itertools.product(*iterables):
2217 normalized = (e if isinstance(e, tuple) else (e,) for e in element)
2218 yield tuple(itertools.chain(*normalized))
2219
2220
2221 class TestFP8Layer(torch.nn.Module):
2222 """Test helper for FP8 linear operations. Creates random weights and scales
2223 based on quantization configuration.
2224
2225 Args:
2226 weight_shape: Shape of the weight tensor (out_features, in_features).
2227 activation_quant_key: Activation quantization configuration.
2228 weight_quant_key: Weight quantization configuration.
2229 out_dtype: Output dtype. Defaults to current default dtype.
2230 force_kernel: Optional kernel to force use of specific implementation.
2231
2232 """
2233
2234 def __init__(
2235 self,
2236 weight_shape: tuple[int, int],
2237 activation_quant_key: QuantKey,
2238 weight_quant_key: QuantKey,
2239 input_dtype: torch.dtype,
2240 out_dtype: torch.dtype | None = None,
2241 transpose_weights: bool = False,
2242 device: torch.device | None = None,
2243 force_kernel: "type[_KernelT] | None" = None,
2244 ):
2245 super().__init__()
2246 from vllm.model_executor.kernels.linear import init_fp8_linear_kernel
2247
2248 self.input_size_per_partition = weight_shape[1]
2249 self.output_size_per_partition = weight_shape[0]
2250 self.logical_widths = [self.output_size_per_partition]
2251 self.orig_dtype = input_dtype
2252 act_scale_desc = activation_quant_key.scale
2253 weight_scale_desc = weight_quant_key.scale
2254 is_block_wise = act_scale_desc.group_shape.is_per_group()
2255 if is_block_wise:
2256 block_size = weight_scale_desc.group_shape.col
2257 weight_scale_shape = weight_shape[0] // block_size
2258 self.weight_scale_inv = torch.rand(
2259 (weight_scale_shape, weight_scale_shape),
2260 dtype=torch.float32,
2261 device=device,
2262 )
2263 self.weight = torch.rand(weight_shape, device=device).to(dtype=FP8_DTYPE)
2264 self.input_scale = None
2265 self.weight_block_size = [block_size, block_size]
2266 if transpose_weights:
2267 self.weight = self.weight.t()
2268 else:
2269 per_tensor_weights = weight_scale_desc.group_shape.is_per_tensor()
2270 is_static_activation_scale = act_scale_desc.static
2271 weight_scale_shape = (1,) if per_tensor_weights else (weight_shape[0], 1)
2272 self.weight_scale_inv = None
2273 self.weight_scale = torch.rand(
2274 weight_scale_shape, dtype=torch.float32, device=device
2275 )
2276 self.input_scale = (
2277 torch.rand(1, dtype=torch.float32, device=device)
2278 if is_static_activation_scale
2279 else None
2280 )
2281 self.weight = (
2282 torch.rand(weight_shape, device=device).to(dtype=FP8_DTYPE).t()
2283 )
2284 self.input_scale_ub = None
2285
2286 out_dtype = torch.get_default_dtype() if out_dtype is None else out_dtype
2287
2288 self.kernel = init_fp8_linear_kernel(
2289 activation_quant_key=activation_quant_key,
2290 weight_quant_key=weight_quant_key,
2291 weight_shape=weight_shape,
2292 input_dtype=input_dtype,
2293 out_dtype=out_dtype,
2294 force_kernel=force_kernel,
2295 )
2296 self.kernel.process_weights_after_loading(self)
2297
2298 def is_quant_fp8_enabled(self) -> bool:
2299 return self.kernel.quant_fp8.enabled()
2300
2301 def forward(
2302 self, y: torch.Tensor, bias: torch.Tensor | None = None
2303 ) -> torch.Tensor:
2304 return self.kernel.apply_weights(self, y, bias)