Files
vllm-omni/tests/dfx/stability/helpers.py
T

505 lines
17 KiB
Python

"""Stability helpers for resource monitoring and benchmark execution."""
from __future__ import annotations
import json
import os
import random
import re
import shlex
import subprocess
import sys
import tempfile
import threading
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any
from tests.dfx.conftest import run_benchmark
STABILITY_DIR = Path(__file__).resolve().parent
RESOURCE_MONITOR_SCRIPT = STABILITY_DIR / "scripts" / "resource_monitor.sh"
REPO_ROOT = STABILITY_DIR.parent.parent.parent
_BUCKET_KEY_PATTERN = re.compile(r"^\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,]+)\s*\)$")
RunOneBatchFn = Callable[
[str, int, str, dict[str, Any], int, float | None, int | None, str, int],
dict[str, Any],
]
def start_resource_monitor():
"""Start `resource_monitor.sh start` in the background and return `Popen` or `None`."""
if not RESOURCE_MONITOR_SCRIPT.is_file():
return None
try:
proc = subprocess.Popen(
["bash", str(RESOURCE_MONITOR_SCRIPT), "start", "--backend", "gpu"],
cwd=str(REPO_ROOT),
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
start_new_session=True,
)
try:
proc.wait(timeout=2)
if proc.returncode != 0:
stderr = proc.stderr.read().decode("utf-8", errors="ignore") if proc.stderr else ""
if stderr.strip():
sys.stderr.write(f"[Stability] Resource monitor failed to start: {stderr.strip()}\n")
return None
except subprocess.TimeoutExpired:
pass
return proc
except (FileNotFoundError, OSError):
return None
def get_monitor_data_root() -> Path:
data_root = os.environ.get("RESOURCE_MONITOR_DATA_ROOT") or os.environ.get("GPU_MONITOR_DATA_ROOT")
if data_root:
return Path(data_root)
return STABILITY_DIR / "gpu_monitor_data"
def wait_for_run_dir(timeout_sec: int = 10) -> Path | None:
data_root = get_monitor_data_root()
run_id_file = data_root / "current_run_id"
deadline = time.time() + timeout_sec
while time.time() < deadline:
if run_id_file.is_file():
run_id = run_id_file.read_text(encoding="utf-8").strip()
if run_id:
run_dir = data_root / run_id
if run_dir.is_dir():
return run_dir
time.sleep(0.5)
return None
def report_latest_gpu_samples(stop_event: threading.Event) -> None:
"""Periodically print the latest sampled GPU line."""
log_interval = int(
os.environ.get("RESOURCE_MONITOR_LOG_INTERVAL") or os.environ.get("GPU_MONITOR_LOG_INTERVAL") or "15"
)
log_interval = max(log_interval, 1)
last_line = ""
time.sleep(min(log_interval, 5))
while not stop_event.wait(log_interval):
run_dir = wait_for_run_dir(timeout_sec=1)
if run_dir is None:
continue
csv_file = run_dir / "gpu_metrics.csv"
if not csv_file.is_file():
continue
try:
lines = csv_file.read_text(encoding="utf-8").splitlines()
except OSError:
continue
if len(lines) <= 1:
continue
latest = lines[-1].strip()
if latest and latest != last_line:
last_line = latest
sys.stderr.write(f"[GPU] {latest}\n")
def finalize_resource_monitor() -> str | None:
"""
Run `resource_monitor.sh finalize` for the current run and generate the report.
Returns the bundle dir path (for this test case's report) if successful, else None.
"""
if not RESOURCE_MONITOR_SCRIPT.is_file():
return None
try:
result = subprocess.run(
["bash", str(RESOURCE_MONITOR_SCRIPT), "finalize", "--backend", "gpu"],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=60,
check=False,
)
if result.returncode != 0:
return None
for line in (result.stdout or "").splitlines():
if line.startswith("GPU_MONITOR_BUNDLE_DIR=") or line.startswith("RESOURCE_MONITOR_BUNDLE_DIR="):
_, _, value = line.partition("=")
return value.strip() if value else None
return None
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return None
def _normalize_bench_metrics(raw: dict[str, Any]) -> dict[str, Any]:
completed = int(raw.get("completed", raw.get("completed_requests", 0) or 0))
failed = int(raw.get("failed", raw.get("failed_requests", 0) or 0))
duration = float(raw.get("duration", 0.0) or 0.0)
errors = list(raw.get("errors") or [])
if failed and not errors:
errors = [f"{failed} benchmark request(s) failed"]
return {"completed": completed, "failed": failed, "duration": duration, "errors": errors}
def _build_base_args(params: dict[str, Any], host: str, port: int) -> list[str]:
exclude = {
"request_rate",
"max_concurrency",
"num_prompts",
"baseline",
"duration_sec",
"num_prompts_per_batch",
}
args = ["--host", host, "--port", str(port)]
for key, value in params.items():
if key in exclude or value is None:
continue
arg_name = f"--{key.replace('_', '-')}"
if isinstance(value, bool) and value:
args.append(arg_name)
elif isinstance(value, dict):
args.extend([arg_name, json.dumps(value, ensure_ascii=False, separators=(",", ":"))])
elif not isinstance(value, bool):
args.extend([arg_name, str(value)])
return args
def _build_diffusion_cmd(
host: str,
port: int,
model: str,
params: dict[str, Any],
num_prompts: int,
request_rate: float | None,
max_concurrency: int | None,
output_path: Path,
diffusion_benchmark_script: Path,
) -> list[str]:
skip_keys = {
"request_rate",
"max_concurrency",
"num_prompts",
"baseline",
"duration_sec",
"num_prompts_per_batch",
}
cmd: list[str] = [
sys.executable,
"-u",
str(diffusion_benchmark_script),
"--host",
host,
"--port",
str(port),
"--model",
model,
"--output-file",
str(output_path),
]
for key, value in params.items():
if key in skip_keys or value is None:
continue
flag = f"--{str(key).replace('_', '-')}"
if isinstance(value, bool) and value:
cmd.append(flag)
elif isinstance(value, bool):
continue
elif isinstance(value, (dict, list)):
cmd.extend([flag, json.dumps(value, ensure_ascii=False, separators=(",", ":"))])
else:
cmd.extend([flag, str(value)])
cmd.extend(["--num-prompts", str(num_prompts)])
if request_rate is not None:
cmd.extend(["--request-rate", str(request_rate)])
else:
cmd.extend(["--max-concurrency", str(max_concurrency), "--request-rate", "inf"])
return cmd
def _sample_int_from_range_spec(value: Any, rng: random.Random) -> Any:
"""Resolve one value that may be scalar or range spec into an int."""
if isinstance(value, int):
return value
if isinstance(value, (list, tuple)) and len(value) == 2 and all(isinstance(v, int) for v in value):
low, high = int(value[0]), int(value[1])
if low > high:
low, high = high, low
return rng.randint(low, high)
if isinstance(value, dict) and {"min", "max"} <= set(value):
low, high = int(value["min"]), int(value["max"])
if low > high:
low, high = high, low
return rng.randint(low, high)
if isinstance(value, str):
raw = value.strip()
if raw.isdigit():
return int(raw)
if "-" in raw:
parts = [p.strip() for p in raw.split("-", 1)]
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
low, high = int(parts[0]), int(parts[1])
if low > high:
low, high = high, low
return rng.randint(low, high)
return value
def _sample_bucket_key(raw_key: str, rng: random.Random) -> str:
"""Sample bucket tuple keys that use range syntax, e.g. ``(128-512, 128-512, 1)``."""
match = _BUCKET_KEY_PATTERN.match(raw_key.strip())
if not match:
return raw_key
sampled_parts: list[int] = []
for token in match.groups():
sampled = _sample_int_from_range_spec(token.strip(), rng)
if not isinstance(sampled, int):
return raw_key
sampled_parts.append(sampled)
# For video buckets (height>0 and num_frames>1), enforce even H/W to avoid
# ffmpeg yuv420p encoding/decoding failures ("Could not open video stream").
if sampled_parts[0] > 0 and sampled_parts[2] > 1:
sampled_parts[0] = max(2, sampled_parts[0] - (sampled_parts[0] % 2))
sampled_parts[1] = max(2, sampled_parts[1] - (sampled_parts[1] % 2))
return f"({sampled_parts[0]}, {sampled_parts[1]}, {sampled_parts[2]})"
def _sample_stability_batch_params(params: dict[str, Any], batch_index: int) -> dict[str, Any]:
"""Materialize per-batch random values for configured range fields."""
sampled = dict(params)
rng = random.Random(time.time_ns() + batch_index)
for field_name in (
"random_input_len",
"random_output_len",
"random_mm_base_items_per_request",
"width",
"height",
):
if field_name in sampled:
sampled[field_name] = _sample_int_from_range_spec(sampled[field_name], rng)
bucket_config = sampled.get("random_mm_bucket_config")
if isinstance(bucket_config, dict):
sampled_bucket_config: dict[str, float] = {}
for raw_key, probability in bucket_config.items():
sampled_key = _sample_bucket_key(str(raw_key), rng)
sampled_bucket_config[sampled_key] = sampled_bucket_config.get(sampled_key, 0.0) + float(probability)
sampled["random_mm_bucket_config"] = sampled_bucket_config
return sampled
def _run_one_vllm_bench_batch(
host: str,
port: int,
_model: str,
params: dict[str, Any],
num_prompts: int,
request_rate: float | None,
max_concurrency: int | None,
result_dir: str,
batch_index: int,
) -> dict[str, Any]:
base = _build_base_args(params, host, port)
if request_rate is not None:
args = base + ["--request-rate", str(request_rate), "--num-prompts", str(num_prompts)]
flow = request_rate
else:
args = base + [
"--max-concurrency",
str(max_concurrency),
"--num-prompts",
str(num_prompts),
"--request-rate",
"inf",
]
flow = max_concurrency
# Print the exact per-batch benchmark CLI (randomized params are already materialized).
preview_cmd = ["vllm", "bench", "serve", "--omni", *args]
print(f"\n[Stability][Batch {batch_index}] Benchmark command:")
print(shlex.join(preview_cmd))
dataset_name = params.get("dataset_name", "random")
old_benchmark_dir = os.environ.get("BENCHMARK_DIR")
try:
os.environ["BENCHMARK_DIR"] = result_dir
result = run_benchmark(
args=args,
test_name="stability",
flow=flow,
dataset_name=dataset_name,
num_prompt=num_prompts,
random_input_len=params.get("random_input_len"),
random_output_len=params.get("random_output_len"),
)
return _normalize_bench_metrics(result)
except (FileNotFoundError, OSError) as exc:
return {
"completed": 0,
"failed": 1,
"duration": 0.0,
"errors": [f"Benchmark batch failed: {type(exc).__name__}: {exc}"],
}
finally:
if old_benchmark_dir is not None:
os.environ["BENCHMARK_DIR"] = old_benchmark_dir
elif "BENCHMARK_DIR" in os.environ:
os.environ.pop("BENCHMARK_DIR")
def _run_one_diffusion_batch(
host: str,
port: int,
model: str,
params: dict[str, Any],
num_prompts: int,
request_rate: float | None,
max_concurrency: int | None,
_result_dir: str,
_batch_index: int,
) -> dict[str, Any]:
diffusion_benchmark_script = Path(REPO_ROOT / "benchmarks" / "diffusion" / "diffusion_benchmark_serving.py")
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", prefix="stability_diffusion_", delete=False) as tmp:
out_path = Path(tmp.name)
try:
cmd = _build_diffusion_cmd(
host,
port,
model,
params,
num_prompts,
request_rate,
max_concurrency,
out_path,
diffusion_benchmark_script,
)
proc = subprocess.run(
cmd,
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
)
if proc.stdout:
print(proc.stdout, end="" if proc.stdout.endswith("\n") else "\n")
if proc.stderr:
print(proc.stderr, end="" if proc.stderr.endswith("\n") else "\n")
if proc.returncode != 0:
return {
"completed": 0,
"failed": 1,
"duration": 0.0,
"errors": [f"diffusion_benchmark_serving.py exited {proc.returncode}"],
}
if not out_path.is_file():
return {
"completed": 0,
"failed": 1,
"duration": 0.0,
"errors": [f"Missing benchmark output: {out_path}"],
}
with open(out_path, encoding="utf-8") as file:
metrics = json.load(file)
return _normalize_bench_metrics(metrics)
except (FileNotFoundError, OSError, json.JSONDecodeError) as exc:
return {
"completed": 0,
"failed": 1,
"duration": 0.0,
"errors": [f"Diffusion batch failed: {type(exc).__name__}: {exc}"],
}
finally:
out_path.unlink(missing_ok=True)
def merge_batch_results(batch_results: list[dict[str, Any]], total_duration_sec: float) -> dict[str, Any]:
if not batch_results:
return {"completed": 0, "failed": 0, "duration": total_duration_sec, "errors": []}
completed = sum(result.get("completed", 0) for result in batch_results)
failed = sum(result.get("failed", 0) for result in batch_results)
merged: dict[str, Any] = {
"completed": completed,
"failed": failed,
"duration": total_duration_sec,
"errors": [],
}
for result in batch_results:
merged["errors"].extend(result.get("errors") or [])
return merged
def print_merged_report(result: dict[str, Any]) -> None:
fmt = "{:<40} {:<10}"
fmt_float = "{:<40} {:<10.2f}"
completed = result.get("completed", 0)
failed = result.get("failed", 0)
duration = float(result.get("duration", 0.0) or 0.0)
print("\n============ Stability Benchmark Summary ============")
print(fmt.format("Successful requests:", completed))
print(fmt.format("Failed requests:", failed))
print(fmt_float.format("Total duration (s):", duration))
print("==================================================\n")
def run_stability_benchmark_loop(
host: str,
port: int,
model: str,
duration_sec: int | float,
params: dict[str, Any],
*,
request_rate: float | None,
max_concurrency: int | None,
result_dir: str,
num_prompts_per_batch: int,
run_one_batch: RunOneBatchFn,
result_filename: str | None = None,
) -> dict[str, Any]:
if (request_rate is None) == (max_concurrency is None):
raise ValueError("Exactly one of request_rate or max_concurrency must be specified")
start_time = time.perf_counter()
batch_results: list[dict[str, Any]] = []
batch_index = 0
while True:
if (time.perf_counter() - start_time) >= duration_sec:
break
sampled_params = _sample_stability_batch_params(params, batch_index)
result = run_one_batch(
host,
port,
model,
sampled_params,
num_prompts_per_batch,
request_rate,
max_concurrency,
result_dir,
batch_index,
)
batch_results.append(result)
batch_index += 1
if (time.perf_counter() - start_time) >= duration_sec:
break
total_duration = time.perf_counter() - start_time
merged = merge_batch_results(batch_results, total_duration)
print_merged_report(merged)
if result_filename and result_dir:
result_path = Path(result_dir) / result_filename
with open(result_path, "w", encoding="utf-8") as file:
json.dump(merged, file, indent=2, ensure_ascii=False)
return merged