Signed-off-by: zhumingjue <zhumingjue@huawei.com> Signed-off-by: zhumingjue138 <zhumingjue@huawei.com>
505 lines
17 KiB
Python
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
|