Files
vllm-omni/tests/dfx/reliability/helpers.py
T
zhumingjue138 a150194b11 [Test] L5 reliability test for wan2.2 and qwen3_omni model (#2972)
Signed-off-by: zhumingjue <zhumingjue@huawei.com>
Signed-off-by: [Your Name] <[Your Email]>
Signed-off-by: zhumingjue138 <zhumingjue@huawei.com>
2026-04-27 12:12:52 +08:00

568 lines
19 KiB
Python

"""Shared reliability fault-injection helpers.
This module keeps fault injection callable from tests directly:
- GPU OOM (CUDA sidecar memory hog)
- process kill by pattern and signal
- post-ready hooks via ``fault_injector`` / ``omni_server_after_fault`` fixtures
"""
from __future__ import annotations
import http.client
import json
import logging
import os
import select
import shlex
import signal
import subprocess
import sys
import time
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import Any
import psutil
import pytest
logger = logging.getLogger(__name__)
@dataclass
class OomHandle:
"""Handle for a started CUDA memory hog subprocess."""
proc: subprocess.Popen | None
device: int
target_mem_ratio: float
start_ts: float
def post_chat_completions_raw(
host: str,
port: int,
body: bytes | str,
*,
content_type: str = "application/json",
timeout_sec: int = 120,
) -> tuple[int, bytes]:
"""POST /v1/chat/completions with raw bytes; returns (status, response_body)."""
conn = http.client.HTTPConnection(host, port, timeout=timeout_sec)
try:
headers = {"Content-Type": content_type}
payload = body.encode("utf-8") if isinstance(body, str) else body
conn.request("POST", "/v1/chat/completions", body=payload, headers=headers)
resp = conn.getresponse()
data = resp.read()
return resp.status, data
finally:
conn.close()
def get_health_raw(host: str, port: int, *, timeout_sec: int = 20) -> tuple[int, bytes]:
"""GET /health with stdlib HTTP client; returns (status, body)."""
conn = http.client.HTTPConnection(host, port, timeout=timeout_sec)
try:
conn.request("GET", "/health")
resp = conn.getresponse()
return resp.status, resp.read()
finally:
conn.close()
def post_json_raw_http_client(
host: str,
port: int,
path: str,
payload: dict[str, Any],
*,
timeout_sec: int = 30,
) -> tuple[int, bytes]:
"""POST JSON to one endpoint with stdlib HTTP client; returns (status, body)."""
conn = http.client.HTTPConnection(host, port, timeout=timeout_sec)
try:
body = json.dumps(payload).encode("utf-8")
conn.request("POST", path, body=body, headers={"Content-Type": "application/json"})
resp = conn.getresponse()
return resp.status, resp.read()
finally:
conn.close()
def post_json_raw(
host: str,
port: int,
path: str,
payload: dict[str, Any],
*,
timeout_sec: int = 30,
) -> tuple[int, bytes]:
"""POST JSON to one endpoint; returns (status, body)."""
return (
post_chat_completions_raw(
host,
port,
json.dumps(payload),
content_type="application/json",
timeout_sec=timeout_sec,
)
if path == "/v1/chat/completions"
else post_json_raw_http_client(
host,
port,
path,
payload,
timeout_sec=timeout_sec,
)
)
def extract_openai_error_contract_from_bytes(response_body: bytes) -> dict[str, Any] | None:
"""Best-effort parse OpenAI-style error object from raw response bytes."""
try:
payload = json.loads(response_body.decode("utf-8", errors="replace"))
except Exception: # noqa: BLE001
return None
return extract_openai_error_contract_from_payload(payload)
def extract_openai_error_contract_from_payload(payload: Any) -> dict[str, Any] | None:
"""Best-effort parse OpenAI-style error object from decoded JSON payload."""
if not isinstance(payload, dict):
return None
error_obj = payload.get("error")
if not isinstance(error_obj, dict):
return None
if not isinstance(error_obj.get("message"), str):
return None
return error_obj
def _build_sidecar_cmd(device: int, target_mem_ratio: float, hold_seconds: int, strict: bool) -> list[str]:
sidecar = r"""
import sys
import time
import torch
device = int(sys.argv[1])
target_ratio = float(sys.argv[2])
hold_seconds = int(sys.argv[3])
strict = sys.argv[4] == "1"
torch.cuda.init()
torch.cuda.set_device(device)
props = torch.cuda.get_device_properties(device)
free_before, total_bytes = torch.cuda.mem_get_info(device)
target_bytes = int(free_before * target_ratio)
chunk_bytes = 256 * 1024 * 1024
chunks = []
allocated = 0
while allocated < target_bytes:
req_bytes = min(chunk_bytes, target_bytes - allocated)
req_elems = max(1, req_bytes // 2) # float16 -> 2 bytes
try:
chunk = torch.empty((req_elems,), dtype=torch.float16, device=f"cuda:{device}")
chunks.append(chunk)
allocated += chunk.numel() * 2
except RuntimeError:
break
# In strict mode, keep filling with smaller chunks until allocator rejects.
# This minimizes residual free memory and makes fault-path assertions steadier.
if strict:
tail_chunk_bytes = [64 * 1024 * 1024, 16 * 1024 * 1024, 4 * 1024 * 1024, 1 * 1024 * 1024]
for tail_bytes in tail_chunk_bytes:
while True:
req_elems = max(1, tail_bytes // 2)
try:
chunk = torch.empty((req_elems,), dtype=torch.float16, device=f"cuda:{device}")
chunks.append(chunk)
allocated += chunk.numel() * 2
except RuntimeError:
break
achieved_ratio = allocated / max(1, props.total_memory)
achieved_free_ratio = allocated / max(1, int(free_before))
free_after, _ = torch.cuda.mem_get_info(device)
if strict and allocated < target_bytes:
print(
"ERROR:"
f"achieved_free_ratio={achieved_free_ratio:.4f};"
f"achieved_total_ratio={achieved_ratio:.4f};"
f"free_before={int(free_before)};"
f"free_after={int(free_after)};"
f"target_bytes={target_bytes};"
f"allocated={allocated}",
flush=True,
)
sys.exit(2)
print(
"READY:"
f"achieved_free_ratio={achieved_free_ratio:.4f};"
f"achieved_total_ratio={achieved_ratio:.4f};"
f"free_before={int(free_before)};"
f"free_after={int(free_after)};"
f"target_bytes={target_bytes};"
f"allocated={allocated}",
flush=True,
)
if hold_seconds <= 0:
while True:
time.sleep(3600)
time.sleep(hold_seconds)
print("DONE", flush=True)
"""
return [
sys.executable,
"-u",
"-c",
sidecar,
str(device),
str(target_mem_ratio),
str(hold_seconds),
"1" if strict else "0",
]
def start_gpu_oom_hog(
*,
device: int = 0,
target_mem_ratio: float = 0.95,
hold_seconds: int = 60,
startup_timeout_sec: int = 20,
strict: bool = True,
poll_interval_sec: float = 0.2,
) -> OomHandle:
"""Start a CUDA sidecar process that occupies GPU memory to trigger OOM.
Note:
``target_mem_ratio`` is evaluated against free memory at injection start
(not total GPU memory), i.e. success gate is ``allocated / free_before``.
``hold_seconds <= 0`` means keeping OOM pressure until the sidecar is
explicitly stopped via ``stop_gpu_oom_hog(s)``.
"""
if os.name == "nt":
raise RuntimeError("CUDA OOM sidecar is intended for Linux CI/runtime.")
if not (0.0 <= target_mem_ratio < 1.0):
raise ValueError("target_mem_ratio should be in [0.0, 1.0).")
# Explicit opt-out for debugging: keep API shape stable while disabling injection.
if target_mem_ratio == 0.0:
print(f"[oom-sidecar][gpu={device}] DISABLED: target_mem_ratio=0.0 (no OOM injection)", flush=True)
return OomHandle(
proc=None,
device=device,
target_mem_ratio=target_mem_ratio,
start_ts=time.time(),
)
cmd = _build_sidecar_cmd(device, target_mem_ratio, hold_seconds, strict)
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
assert proc.stdout is not None
deadline = time.time() + startup_timeout_sec
logs: list[str] = []
while time.time() < deadline:
ready, _, _ = select.select([proc.stdout], [], [], poll_interval_sec)
if ready:
line = proc.stdout.readline().strip()
if line:
logs.append(line)
print(f"[oom-sidecar][gpu={device}] {line}", flush=True)
if line.startswith("READY:"):
return OomHandle(
proc=proc,
device=device,
target_mem_ratio=target_mem_ratio,
start_ts=time.time(),
)
if line.startswith("ERROR:"):
proc.terminate()
raise RuntimeError(f"OOM sidecar failed to reach target: {line}")
if proc.poll() is not None:
break
proc.terminate()
if logs:
print(f"[oom-sidecar][gpu={device}] startup logs: {' | '.join(logs)}", flush=True)
raise TimeoutError(f"OOM sidecar startup timeout. logs={logs}")
def stop_gpu_oom_hog(handle: OomHandle, *, timeout_sec: int = 5) -> None:
"""Stop and cleanup CUDA OOM sidecar."""
proc = handle.proc
if proc is None:
return
if proc.poll() is not None:
return
proc.terminate()
try:
proc.wait(timeout=timeout_sec)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=timeout_sec)
def inject_gpu_oom(
*,
device: int | str | list[int] = 0,
target_mem_ratio: float = 0.95,
hold_seconds: int = 60,
startup_timeout_sec: int = 20,
strict: bool = True,
) -> OomHandle | list[OomHandle]:
"""Convenience wrapper to start CUDA OOM sidecar(s).
Args:
device: One device id (``0``), comma-separated string (``"0,1,2"``),
or a list of device ids (``[0, 1, 2]``).
hold_seconds: OOM hold time in seconds; ``<=0`` keeps pressure until
``stop_gpu_oom_hogs`` is called.
"""
if isinstance(device, int):
devices = [device]
elif isinstance(device, str):
devices = [int(x.strip()) for x in device.split(",") if x.strip()]
else:
devices = [int(x) for x in device]
if not devices:
raise ValueError("device must not be empty.")
handles = [
start_gpu_oom_hog(
device=dev,
target_mem_ratio=target_mem_ratio,
hold_seconds=hold_seconds,
startup_timeout_sec=startup_timeout_sec,
strict=strict,
)
for dev in devices
]
if len(handles) == 1:
return handles[0]
return handles
def stop_gpu_oom_hogs(handles: OomHandle | list[OomHandle], *, timeout_sec: int = 5) -> None:
"""Stop one or multiple OOM sidecars."""
if isinstance(handles, OomHandle):
stop_gpu_oom_hog(handles, timeout_sec=timeout_sec)
return
for handle in handles:
stop_gpu_oom_hog(handle, timeout_sec=timeout_sec)
def _runtime_teardown_ssh_target() -> str:
target = os.getenv("RUNTIME_TEARDOWN_SSH_TARGET", "").strip()
# Default to root@127.0.0.1 for same-host SSH control path.
return target or "root@127.0.0.1"
def _runtime_teardown_ssh_cmd(remote_cmd: str, *, step: str | None = None) -> subprocess.CompletedProcess[str]:
ssh_target = _runtime_teardown_ssh_target()
default_reuse_opts = "-o ControlMaster=auto -o ControlPersist=10m -o ControlPath=/tmp/vllm-rt-ssh-%r@%h:%p"
raw_opts = os.getenv("RUNTIME_TEARDOWN_SSH_OPTS", "").strip()
ssh_opts = shlex.split(raw_opts or default_reuse_opts)
timeout_sec = int(os.getenv("RUNTIME_TEARDOWN_SSH_TIMEOUT_SEC", "600"))
step_prefix = f"[runtime-teardown][ssh]{f'[{step}]' if step else ''}"
print(f"{step_prefix} target={ssh_target} running remote command...", flush=True)
# IMPORTANT: SSH joins remote argv into one shell command string. If we pass
# ["bash", "-c", remote_cmd] as separate argv items, remote shell parsing can
# make `-c` consume only the first word (e.g. "docker"), causing docker help.
# Wrap the whole command as one quoted string for remote bash -c.
remote_invocation = f"bash --noprofile --norc -c {shlex.quote(remote_cmd)}"
try:
out = subprocess.run(
["ssh", *ssh_opts, ssh_target, remote_invocation],
check=False,
capture_output=True,
text=True,
timeout=timeout_sec,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
f"{step_prefix} timed out after {timeout_sec}s. Increase RUNTIME_TEARDOWN_SSH_TIMEOUT_SEC if needed."
) from exc
print(f"{step_prefix} exit_code={out.returncode}", flush=True)
return out
def list_remote_process_pids_by_pattern(pattern: str) -> list[int]:
"""Return matched PIDs from remote host ``pgrep -f <pattern>`` via SSH."""
cmd = f"pgrep -f {shlex.quote(pattern)} || true"
out = _runtime_teardown_ssh_cmd(cmd, step="pgrep")
if out.returncode not in (0, 1):
raise RuntimeError(f"remote pgrep failed for pattern={pattern!r}: {out.stderr.strip()}")
return [int(item) for item in out.stdout.split() if item.strip().isdigit()]
def inject_process_kill(
*,
grep_pattern: str,
signal_name: str = "SIGTERM",
limit: int | None = None,
allow_zero_match: bool = False,
execute_kill: bool = True,
) -> list[int]:
"""Kill processes matching pattern with selected signal."""
if os.name == "nt":
raise RuntimeError("process-kill helper currently supports POSIX platforms only.")
if not grep_pattern.strip():
raise ValueError("grep_pattern must not be empty.")
sig = getattr(signal, signal_name, None)
if sig is None:
raise ValueError(f"Unsupported signal_name: {signal_name}")
out = subprocess.run(
["pgrep", "-f", grep_pattern],
check=False,
capture_output=True,
text=True,
)
pids = [int(item) for item in out.stdout.split() if item.strip().isdigit()]
if limit is not None:
pids = pids[:limit]
if not pids and not allow_zero_match:
raise RuntimeError(f"No process matched pattern: {grep_pattern}")
if execute_kill:
for pid in pids:
os.kill(pid, sig)
return pids
def _safe_proc_info(pid: int) -> tuple[str, str]:
"""Best-effort process name/cmdline lookup for debug logging."""
try:
proc = psutil.Process(pid)
name = proc.name()
cmdline = " ".join(proc.cmdline()) or "<empty-cmdline>"
return name, cmdline
except Exception: # noqa: BLE001
return "<unknown>", "<unavailable>"
def _list_server_process_tree(server: Any) -> list[int]:
"""Return [root, descendants...] PIDs for the current test server instance."""
root_proc = getattr(server, "proc", None)
if root_proc is None or getattr(root_proc, "pid", None) is None:
return []
root_pid = int(root_proc.pid)
try:
root = psutil.Process(root_pid)
except Exception: # noqa: BLE001
return [root_pid]
descendants = [child.pid for child in root.children(recursive=True)]
return [root_pid, *descendants]
def _log_server_process_tree(server: Any) -> None:
"""Print server process tree for debugging fault injection targets."""
pids = _list_server_process_tree(server)
if not pids:
logger.warning("[reliability][process-kill] current server has no visible process tree")
return
for pid in pids:
name, cmdline = _safe_proc_info(pid)
print(
f"[reliability][process-kill] current_server_proc pid={pid} name={name} cmdline={cmdline}",
flush=True,
)
FaultInjector = Callable[[Any], None]
"""Callable invoked with the live ``OmniServer`` after it is ready (see ``omni_server_after_fault``)."""
def make_process_kill_fault_injector(
*,
grep_patterns: str | Sequence[str],
signal_name: str = "SIGKILL",
limit: int = 1,
post_kill_wait_seconds: float = 0.0,
) -> FaultInjector:
"""Build a post-ready injector that kills processes matched by ``pgrep -f``.
Tries each pattern in order until at least one PID is killed. If none match,
the returned callable issues ``pytest.skip`` (same behavior as the previous
inline reliability test).
Args:
grep_patterns: One pattern or an ordered list of patterns.
signal_name: Passed to :func:`inject_process_kill` (e.g. ``SIGKILL``).
limit: Maximum PIDs to kill per pattern (default ``1``).
post_kill_wait_seconds: Optional wait time after kill before test request starts.
"""
patterns: tuple[str, ...] = (grep_patterns,) if isinstance(grep_patterns, str) else tuple(grep_patterns)
def _inject(server: Any) -> None:
_log_server_process_tree(server)
server_tree = set(_list_server_process_tree(server))
if not server_tree:
logger.warning(
"[reliability][process-kill] no server process tree found; fallback to global pgrep matching"
)
for pattern in patterns:
print(
f"[reliability][process-kill] trying pattern={pattern} signal={signal_name} limit={limit}",
flush=True,
)
pids = inject_process_kill(
grep_pattern=pattern,
signal_name=signal_name,
limit=limit,
allow_zero_match=True,
execute_kill=False,
)
filtered = [pid for pid in pids if not server_tree or pid in server_tree]
if pids and not filtered:
logger.warning(
"[reliability][process-kill] pattern=%s matched non-server pids=%s, skip them",
pattern,
pids,
)
continue
if filtered:
sig = getattr(signal, signal_name, None)
if sig is None:
raise ValueError(f"Unsupported signal_name: {signal_name}")
for pid in filtered:
name, cmdline = _safe_proc_info(pid)
print(
f"[reliability][process-kill] killing pid={pid} name={name} signal={signal_name} cmdline={cmdline}",
flush=True,
)
os.kill(pid, sig)
print(
f"[reliability][process-kill] matched pattern={pattern} killed_pids={filtered} killed_count={len(filtered)}",
flush=True,
)
if post_kill_wait_seconds > 0:
print(
f"[reliability][process-kill] waiting {post_kill_wait_seconds:.2f}s after kill",
flush=True,
)
time.sleep(post_kill_wait_seconds)
return
logger.warning(
"[reliability][process-kill] no process matched patterns=%s signal=%s limit=%s",
patterns,
signal_name,
limit,
)
pytest.skip("no matching runtime process found for kill injection")
return _inject