[smg][ci]: migrate benchmarks to e2e_test/benchmarks/, use parent conftest (#16597)
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
"""Benchmark-specific fixtures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from infra import GPUMonitor, should_monitor_gpu, terminate_process
|
||||
|
||||
from .results import BenchmarkResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_command(
|
||||
cli: str,
|
||||
router_url: str,
|
||||
model_path: str,
|
||||
experiment_folder: str,
|
||||
num_concurrency: int,
|
||||
traffic_scenario: str,
|
||||
max_requests: int,
|
||||
) -> list[str]:
|
||||
"""Build genai-bench command."""
|
||||
return [
|
||||
cli,
|
||||
"benchmark",
|
||||
"--api-backend",
|
||||
"openai",
|
||||
"--api-base",
|
||||
router_url,
|
||||
"--api-key",
|
||||
"dummy-token",
|
||||
"--api-model-name",
|
||||
model_path,
|
||||
"--model-tokenizer",
|
||||
model_path,
|
||||
"--task",
|
||||
"text-to-text",
|
||||
"--num-concurrency",
|
||||
str(num_concurrency),
|
||||
"--traffic-scenario",
|
||||
traffic_scenario,
|
||||
"--max-requests-per-run",
|
||||
str(max_requests),
|
||||
"--max-time-per-run",
|
||||
"3",
|
||||
"--experiment-folder-name",
|
||||
experiment_folder,
|
||||
"--experiment-base-dir",
|
||||
str(Path.cwd()),
|
||||
]
|
||||
|
||||
|
||||
def _find_results(experiment_folder: str, timeout: int = 10) -> list[Path]:
|
||||
"""Find benchmark result JSON files."""
|
||||
base = Path.cwd()
|
||||
folder = base / experiment_folder
|
||||
|
||||
if not folder.is_dir():
|
||||
# Search for folder
|
||||
for p in base.rglob(experiment_folder):
|
||||
if p.is_dir() and p.name == experiment_folder:
|
||||
folder = p
|
||||
break
|
||||
|
||||
if not folder.is_dir():
|
||||
raise AssertionError(f"Experiment folder not found: {experiment_folder}")
|
||||
|
||||
# Wait for JSON results
|
||||
for _ in range(timeout):
|
||||
files = [
|
||||
p
|
||||
for p in folder.rglob("*.json")
|
||||
if "experiment_metadata" not in p.name and "gpu_utilization" not in p.name
|
||||
]
|
||||
if files:
|
||||
return files
|
||||
time.sleep(1)
|
||||
|
||||
raise AssertionError(f"No JSON results found in {folder}")
|
||||
|
||||
|
||||
def _cleanup_procs(procs: list, drain_delay: int) -> None:
|
||||
"""Terminate processes gracefully."""
|
||||
if not procs:
|
||||
return
|
||||
if drain_delay > 0:
|
||||
time.sleep(drain_delay)
|
||||
for p in procs:
|
||||
try:
|
||||
proc = getattr(p, "proc", p) if hasattr(p, "proc") else p
|
||||
if isinstance(proc, subprocess.Popen):
|
||||
terminate_process(proc)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(2)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def genai_bench_runner():
|
||||
"""Run genai-bench and validate metrics.
|
||||
|
||||
Usage:
|
||||
def test_perf(setup_backend, genai_bench_runner):
|
||||
backend, model_path, client, gateway = setup_backend
|
||||
genai_bench_runner(
|
||||
router_url=gateway.base_url,
|
||||
model_path=model_path,
|
||||
experiment_folder="benchmark_results",
|
||||
thresholds={"ttft_mean_max": 5, "gpu_util_p50_min": 99},
|
||||
)
|
||||
"""
|
||||
|
||||
def _run(
|
||||
*,
|
||||
router_url: str,
|
||||
model_path: str,
|
||||
experiment_folder: str,
|
||||
thresholds: dict | None = None,
|
||||
timeout_sec: int | None = None,
|
||||
num_concurrency: int = 32,
|
||||
traffic_scenario: str = "D(4000,100)",
|
||||
max_requests_per_run: int | None = None,
|
||||
kill_procs: list | None = None,
|
||||
drain_delay_sec: int = 6,
|
||||
) -> None:
|
||||
cli = shutil.which("genai-bench")
|
||||
if not cli:
|
||||
pytest.fail("genai-bench CLI not found")
|
||||
|
||||
# Clean previous results
|
||||
exp_dir = Path.cwd() / experiment_folder
|
||||
if exp_dir.exists():
|
||||
shutil.rmtree(exp_dir, ignore_errors=True)
|
||||
|
||||
# Build and run command
|
||||
max_requests = max_requests_per_run or num_concurrency * 5
|
||||
cmd = _build_command(
|
||||
cli,
|
||||
router_url,
|
||||
model_path,
|
||||
experiment_folder,
|
||||
num_concurrency,
|
||||
traffic_scenario,
|
||||
max_requests,
|
||||
)
|
||||
timeout = timeout_sec or int(os.environ.get("GENAI_BENCH_TEST_TIMEOUT", "120"))
|
||||
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
env=os.environ.copy(),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Start GPU monitor if needed
|
||||
gpu_monitor: GPUMonitor | None = None
|
||||
if should_monitor_gpu(thresholds):
|
||||
interval = float(os.environ.get("GPU_UTIL_SAMPLE_INTERVAL", "2.0"))
|
||||
gpu_monitor = GPUMonitor(output_dir=exp_dir, interval=interval)
|
||||
gpu_monitor.start(target_pid=proc.pid)
|
||||
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
stdout, stderr = proc.communicate()
|
||||
|
||||
try:
|
||||
# Parse and validate results
|
||||
for path in _find_results(experiment_folder):
|
||||
result = BenchmarkResult.from_json(path)
|
||||
result.log(experiment_folder, logger)
|
||||
if thresholds:
|
||||
result.validate(thresholds)
|
||||
|
||||
# Validate GPU utilization
|
||||
if gpu_monitor:
|
||||
gpu_monitor.stop()
|
||||
gpu_monitor.log_summary()
|
||||
gpu_monitor.assert_thresholds(thresholds)
|
||||
|
||||
finally:
|
||||
_cleanup_procs(kill_procs, drain_delay_sec)
|
||||
if gpu_monitor:
|
||||
gpu_monitor.stop(timeout=2)
|
||||
|
||||
return _run
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Benchmark result dataclasses for parsing genai-bench and GPU monitor output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class BenchmarkResult:
|
||||
"""Parsed benchmark metrics from genai-bench output."""
|
||||
|
||||
ttft_mean: float
|
||||
e2e_latency_mean: float
|
||||
input_throughput_mean: float
|
||||
output_throughput_mean: float
|
||||
file_name: str
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, path: Path) -> "BenchmarkResult":
|
||||
"""Parse benchmark results from JSON file."""
|
||||
with path.open() as f:
|
||||
data = json.load(f)
|
||||
stats = data.get("aggregated_metrics", {}).get("stats", {})
|
||||
return cls(
|
||||
ttft_mean=float(stats.get("ttft", {}).get("mean", float("inf"))),
|
||||
e2e_latency_mean=float(
|
||||
stats.get("e2e_latency", {}).get("mean", float("inf"))
|
||||
),
|
||||
input_throughput_mean=float(
|
||||
stats.get("input_throughput", {}).get("mean", 0.0)
|
||||
),
|
||||
output_throughput_mean=float(
|
||||
stats.get("output_throughput", {}).get("mean", 0.0)
|
||||
),
|
||||
file_name=path.name,
|
||||
)
|
||||
|
||||
def log(self, experiment: str, logger) -> None:
|
||||
"""Log benchmark results."""
|
||||
logger.info(
|
||||
"genai-bench[%s] %s ttft=%.3fs e2e=%.3fs input=%.1f tok/s output=%.1f tok/s",
|
||||
experiment,
|
||||
self.file_name,
|
||||
self.ttft_mean,
|
||||
self.e2e_latency_mean,
|
||||
self.input_throughput_mean,
|
||||
self.output_throughput_mean,
|
||||
)
|
||||
|
||||
def validate(self, thresholds: dict) -> None:
|
||||
"""Validate metrics against thresholds."""
|
||||
checks = [
|
||||
("ttft_mean_max", self.ttft_mean, "<=", "TTFT"),
|
||||
("e2e_latency_mean_max", self.e2e_latency_mean, "<=", "E2E latency"),
|
||||
(
|
||||
"input_throughput_mean_min",
|
||||
self.input_throughput_mean,
|
||||
">=",
|
||||
"Input throughput",
|
||||
),
|
||||
(
|
||||
"output_throughput_mean_min",
|
||||
self.output_throughput_mean,
|
||||
">=",
|
||||
"Output throughput",
|
||||
),
|
||||
]
|
||||
for key, value, op, name in checks:
|
||||
if key not in thresholds:
|
||||
continue
|
||||
threshold = thresholds[key]
|
||||
if op == "<=" and value > threshold:
|
||||
raise AssertionError(f"{name}: {value:.2f} > {threshold}")
|
||||
if op == ">=" and value < threshold:
|
||||
raise AssertionError(f"{name}: {value:.2f} < {threshold}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class GPUUtilization:
|
||||
"""Parsed GPU utilization metrics from gpu_monitor output."""
|
||||
|
||||
overall_mean: float
|
||||
per_gpu: dict[str, dict[str, float]]
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, path: Path) -> "GPUUtilization | None":
|
||||
"""Parse GPU utilization from JSON file."""
|
||||
try:
|
||||
with path.open() as f:
|
||||
data = json.load(f)
|
||||
return cls(
|
||||
overall_mean=float(data.get("overall", {}).get("mean", 0)),
|
||||
per_gpu=data.get("per_gpu", {}),
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Generate benchmark summary for GitHub Actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from results import BenchmarkResult, GPUUtilization
|
||||
|
||||
|
||||
def discover_benchmarks(base_dir: Path) -> list[tuple[Path, str]]:
|
||||
"""Auto-discover benchmark folders and their result JSON files.
|
||||
|
||||
Returns list of (json_path, label) tuples sorted by folder name.
|
||||
"""
|
||||
results = []
|
||||
for folder in base_dir.rglob("benchmark_*"):
|
||||
if not folder.is_dir():
|
||||
continue
|
||||
# Find result JSON (exclude metadata and gpu files)
|
||||
for json_file in folder.glob("*.json"):
|
||||
if (
|
||||
"experiment_metadata" not in json_file.name
|
||||
and "gpu_utilization" not in json_file.name
|
||||
):
|
||||
# Generate label from folder name: benchmark_cache_aware_pd_grpc -> cache_aware pd grpc
|
||||
label = folder.name.replace("benchmark_", "").replace("_", " ")
|
||||
results.append((json_file, label))
|
||||
break # One JSON per folder
|
||||
return sorted(results, key=lambda x: x[0].parent.name)
|
||||
|
||||
|
||||
def find_gpu_utilization(result_path: Path) -> Path | None:
|
||||
"""Find GPU utilization JSON in same folder as result."""
|
||||
gpu_json = result_path.parent / "gpu_utilization.json"
|
||||
return gpu_json if gpu_json.exists() else None
|
||||
|
||||
|
||||
def generate_summary(base_dir: Path) -> str:
|
||||
"""Generate markdown summary."""
|
||||
benchmarks = discover_benchmarks(base_dir)
|
||||
|
||||
if not benchmarks:
|
||||
return (
|
||||
"## Gateway E2E Genai-Bench Results Summary\n\nNo benchmark results found."
|
||||
)
|
||||
|
||||
lines = [
|
||||
"## Gateway E2E Genai-Bench Results Summary",
|
||||
"",
|
||||
"| Scenario | Status | TTFT (s) | E2E Latency (s) | Input Throughput (tok/s) | Output Throughput (tok/s) |",
|
||||
"|----------|--------|----------|-----------------|--------------------------|---------------------------|",
|
||||
]
|
||||
|
||||
gpu_sections = []
|
||||
|
||||
for result_path, label in benchmarks:
|
||||
try:
|
||||
result = BenchmarkResult.from_json(result_path)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to parse {result_path}: {e}", file=sys.stderr)
|
||||
lines.append(f"| {label} | ❌ Failed | - | - | - | - |")
|
||||
continue
|
||||
|
||||
lines.append(
|
||||
f"| {label} | ✅ Success | "
|
||||
f"{result.ttft_mean:.2f} | "
|
||||
f"{result.e2e_latency_mean:.2f} | "
|
||||
f"{result.input_throughput_mean:.0f} | "
|
||||
f"{result.output_throughput_mean:.0f} |"
|
||||
)
|
||||
|
||||
# GPU utilization
|
||||
gpu_path = find_gpu_utilization(result_path)
|
||||
if gpu_path:
|
||||
gpu = GPUUtilization.from_json(gpu_path)
|
||||
if gpu and gpu.per_gpu:
|
||||
gpu_lines = [
|
||||
f"### GPU Utilization — {label}",
|
||||
"",
|
||||
f"Overall mean: {gpu.overall_mean:.2f}%",
|
||||
"",
|
||||
"| GPU | Mean (%) | p5 | p10 | p25 | p50 | p75 | p90 | p95 |",
|
||||
"|-----|----------|----|-----|-----|-----|-----|-----|-----|",
|
||||
]
|
||||
for gpu_id, stats in sorted(
|
||||
gpu.per_gpu.items(), key=lambda x: int(x[0])
|
||||
):
|
||||
gpu_lines.append(
|
||||
f"| {gpu_id} | {stats.get('mean', 0):.2f} | "
|
||||
f"{stats.get('p5', 0):.2f} | {stats.get('p10', 0):.2f} | "
|
||||
f"{stats.get('p25', 0):.2f} | {stats.get('p50', 0):.2f} | "
|
||||
f"{stats.get('p75', 0):.2f} | {stats.get('p90', 0):.2f} | "
|
||||
f"{stats.get('p95', 0):.2f} |"
|
||||
)
|
||||
gpu_sections.append("\n".join(gpu_lines))
|
||||
|
||||
return "\n".join(lines) + "\n" + "\n\n".join(gpu_sections)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point."""
|
||||
base_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
|
||||
summary = generate_summary(base_dir)
|
||||
|
||||
# Write to GITHUB_STEP_SUMMARY if available
|
||||
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if summary_file:
|
||||
with open(summary_file, "a") as f:
|
||||
f.write(summary)
|
||||
f.write("\n")
|
||||
print(f"Summary written to {summary_file}")
|
||||
else:
|
||||
print(summary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""PD (prefill/decode disaggregation) router performance benchmark test."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.workers(prefill=2, decode=2)
|
||||
@pytest.mark.parametrize("setup_backend", ["pd"], indirect=True)
|
||||
class TestPDPerf:
|
||||
"""Performance benchmark for PD disaggregation router."""
|
||||
|
||||
def test_pd_perf(self, setup_backend, genai_bench_runner):
|
||||
"""Run genai-bench against PD router and validate metrics."""
|
||||
backend, model_path, client, gateway = setup_backend
|
||||
genai_bench_runner(
|
||||
router_url=gateway.base_url,
|
||||
model_path=model_path,
|
||||
experiment_folder="benchmark_round_robin_pd",
|
||||
thresholds={
|
||||
"ttft_mean_max": 13,
|
||||
"e2e_latency_mean_max": 16,
|
||||
"input_throughput_mean_min": 350,
|
||||
"output_throughput_mean_min": 18,
|
||||
"gpu_util_p50_min": 99,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Regular router performance benchmark test."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.workers(count=4)
|
||||
@pytest.mark.gateway(policy="cache_aware")
|
||||
@pytest.mark.parametrize("setup_backend", ["http", "grpc"], indirect=True)
|
||||
class TestRegularPerf:
|
||||
"""Performance benchmark for regular (non-PD) router."""
|
||||
|
||||
def test_regular_perf(self, setup_backend, genai_bench_runner):
|
||||
"""Run genai-bench against regular router and validate metrics."""
|
||||
backend, model_path, client, gateway = setup_backend
|
||||
genai_bench_runner(
|
||||
router_url=gateway.base_url,
|
||||
model_path=model_path,
|
||||
experiment_folder=f"benchmark_cache_aware_regular_{backend}",
|
||||
thresholds={
|
||||
"ttft_mean_max": 6,
|
||||
"e2e_latency_mean_max": 14,
|
||||
"input_throughput_mean_min": 800,
|
||||
"output_throughput_mean_min": 12,
|
||||
"gpu_util_p50_min": 99,
|
||||
},
|
||||
)
|
||||
@@ -697,21 +697,33 @@ def setup_backend(request: pytest.FixtureRequest, model_pool: "ModelPool"):
|
||||
num_workers = workers_config.get("count") or 1
|
||||
|
||||
try:
|
||||
instance = model_pool.get(model_id, connection_mode)
|
||||
if num_workers > 1:
|
||||
# Launch multiple workers on separate GPUs
|
||||
instances = model_pool.launch_regular_workers(
|
||||
model_id=model_id,
|
||||
num_workers=num_workers,
|
||||
mode=connection_mode,
|
||||
startup_timeout=300,
|
||||
)
|
||||
if not instances:
|
||||
pytest.fail(
|
||||
f"Failed to launch {num_workers} workers for {model_id}"
|
||||
)
|
||||
worker_urls = [inst.worker_url for inst in instances]
|
||||
model_path = instances[0].model_path
|
||||
else:
|
||||
# Single worker - use existing get() method
|
||||
instance = model_pool.get(model_id, connection_mode)
|
||||
worker_urls = [instance.worker_url]
|
||||
model_path = instance.model_path
|
||||
except RuntimeError as e:
|
||||
pytest.fail(str(e))
|
||||
|
||||
# Build worker URLs list
|
||||
# For num_workers > 1, we need multiple workers from the pool
|
||||
# For now, we reuse the same worker URL (router will load balance)
|
||||
# TODO: Support launching multiple distinct workers for true LB testing
|
||||
worker_urls = [instance.worker_url] * num_workers
|
||||
|
||||
# Launch gateway with configuration
|
||||
gateway = Gateway()
|
||||
gateway.start(
|
||||
worker_urls=worker_urls,
|
||||
model_path=instance.model_path,
|
||||
model_path=model_path,
|
||||
policy=gateway_config["policy"],
|
||||
timeout=gateway_config["timeout"],
|
||||
extra_args=gateway_config["extra_args"],
|
||||
@@ -732,7 +744,7 @@ def setup_backend(request: pytest.FixtureRequest, model_pool: "ModelPool"):
|
||||
)
|
||||
|
||||
try:
|
||||
yield backend_name, instance.model_path, client, gateway
|
||||
yield backend_name, model_path, client, gateway
|
||||
finally:
|
||||
logger.info("Tearing down gateway for %s backend", backend_name)
|
||||
gateway.shutdown()
|
||||
|
||||
@@ -1,807 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Callable, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from sglang.test.test_utils import (
|
||||
DEFAULT_MODEL_NAME_FOR_TEST,
|
||||
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
|
||||
DEFAULT_URL_FOR_TEST,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _find_available_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _parse_url(base_url: str) -> tuple[str, str]:
|
||||
"""Parse a base URL and return (host, port) as strings.
|
||||
|
||||
This is more robust than simple string splitting and supports different schemes
|
||||
and URL shapes like trailing paths.
|
||||
"""
|
||||
parsed = urlparse(base_url)
|
||||
return parsed.hostname or "127.0.0.1", (
|
||||
str(parsed.port) if parsed.port is not None else ""
|
||||
)
|
||||
|
||||
|
||||
def _wait_router_health(base_url: str, timeout: float) -> None:
|
||||
start = time.perf_counter()
|
||||
with requests.Session() as session:
|
||||
while time.perf_counter() - start < timeout:
|
||||
try:
|
||||
r = session.get(f"{base_url}/health", timeout=5)
|
||||
if r.status_code == 200:
|
||||
return
|
||||
except requests.RequestException:
|
||||
pass
|
||||
time.sleep(2)
|
||||
raise TimeoutError("Router failed to become healthy in time")
|
||||
|
||||
|
||||
def _popen_launch_router(
|
||||
model: str,
|
||||
base_url: str,
|
||||
dp_size: int,
|
||||
timeout: float,
|
||||
policy: str = "cache_aware",
|
||||
) -> subprocess.Popen:
|
||||
host, port = _parse_url(base_url)
|
||||
|
||||
prom_port = _find_available_port()
|
||||
|
||||
cmd = [
|
||||
"python3",
|
||||
"-m",
|
||||
"sglang_router.launch_server",
|
||||
"--model-path",
|
||||
model,
|
||||
"--host",
|
||||
host,
|
||||
"--port",
|
||||
port,
|
||||
"--dp",
|
||||
str(dp_size),
|
||||
"--router-policy",
|
||||
policy,
|
||||
"--allow-auto-truncate",
|
||||
"--router-prometheus-port",
|
||||
str(prom_port),
|
||||
"--router-prometheus-host",
|
||||
"127.0.0.1",
|
||||
"--router-log-level",
|
||||
"warn",
|
||||
]
|
||||
|
||||
proc = subprocess.Popen(cmd)
|
||||
_wait_router_health(base_url, timeout)
|
||||
return proc
|
||||
|
||||
|
||||
def _popen_launch_worker(
|
||||
model: str,
|
||||
base_url: str,
|
||||
*,
|
||||
dp_size: int | None = None,
|
||||
api_key: str | None = None,
|
||||
base_gpu_id: int | None = 0,
|
||||
) -> subprocess.Popen:
|
||||
host, port = _parse_url(base_url)
|
||||
|
||||
cmd = [
|
||||
"python3",
|
||||
"-m",
|
||||
"sglang.launch_server",
|
||||
"--model-path",
|
||||
model,
|
||||
"--host",
|
||||
host,
|
||||
"--port",
|
||||
port,
|
||||
"--base-gpu-id",
|
||||
str(base_gpu_id or 0),
|
||||
"--log-level",
|
||||
"warning",
|
||||
]
|
||||
if dp_size is not None:
|
||||
cmd += ["--dp-size", str(dp_size)]
|
||||
if api_key is not None:
|
||||
cmd += ["--api-key", api_key]
|
||||
return subprocess.Popen(cmd)
|
||||
|
||||
|
||||
def _popen_launch_router_only(
|
||||
base_url: str,
|
||||
policy: str = "round_robin",
|
||||
timeout: float = 120.0,
|
||||
*,
|
||||
dp_aware: bool = False,
|
||||
enable_igw: bool = False,
|
||||
api_key: str | None = None,
|
||||
) -> subprocess.Popen:
|
||||
host, port = _parse_url(base_url)
|
||||
|
||||
prom_port = _find_available_port()
|
||||
cmd = [
|
||||
"python3",
|
||||
"-m",
|
||||
"sglang_router.launch_router",
|
||||
"--host",
|
||||
host,
|
||||
"--port",
|
||||
port,
|
||||
"--policy",
|
||||
policy,
|
||||
]
|
||||
if dp_aware:
|
||||
cmd += ["--dp-aware"]
|
||||
if enable_igw:
|
||||
cmd += ["--enable-igw"]
|
||||
if api_key is not None:
|
||||
cmd += ["--api-key", api_key]
|
||||
cmd += [
|
||||
"--prometheus-port",
|
||||
str(prom_port),
|
||||
"--prometheus-host",
|
||||
"127.0.0.1",
|
||||
"--log-level",
|
||||
"warn",
|
||||
]
|
||||
proc = subprocess.Popen(cmd)
|
||||
_wait_router_health(base_url, timeout)
|
||||
return proc
|
||||
|
||||
|
||||
def _terminate(proc: subprocess.Popen, timeout: float = 120) -> None:
|
||||
if proc is None:
|
||||
return
|
||||
proc.terminate()
|
||||
start = time.perf_counter()
|
||||
while proc.poll() is None:
|
||||
if time.perf_counter() - start > timeout:
|
||||
proc.kill()
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def _which(cmd: str) -> Optional[str]:
|
||||
try:
|
||||
return shutil.which(cmd)
|
||||
except Exception as e:
|
||||
logger.warning("shutil.which(%r) failed: %s", cmd, e)
|
||||
return None
|
||||
|
||||
|
||||
def _graceful_stop_popen(p: subprocess.Popen) -> None:
|
||||
if p is None:
|
||||
return
|
||||
try:
|
||||
if p.poll() is None:
|
||||
p.terminate()
|
||||
for _ in range(5):
|
||||
if p.poll() is not None:
|
||||
break
|
||||
time.sleep(1)
|
||||
if p.poll() is None:
|
||||
p.kill()
|
||||
except Exception as e:
|
||||
logger.warning("Exception during graceful stop of popen: %s", e)
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _graceful_stop_pid(pid: int) -> None:
|
||||
try:
|
||||
if _pid_alive(pid):
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except Exception:
|
||||
pass
|
||||
for _ in range(5):
|
||||
if not _pid_alive(pid):
|
||||
break
|
||||
time.sleep(1)
|
||||
if _pid_alive(pid):
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _graceful_stop_any(obj) -> None:
|
||||
try:
|
||||
if isinstance(obj, subprocess.Popen):
|
||||
_graceful_stop_popen(obj)
|
||||
return
|
||||
if isinstance(obj, int):
|
||||
_graceful_stop_pid(obj)
|
||||
return
|
||||
proc_obj = getattr(obj, "proc", None)
|
||||
if isinstance(proc_obj, subprocess.Popen):
|
||||
_graceful_stop_popen(proc_obj)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _gpu_monitor_should_run(thresholds: Optional[dict]) -> bool:
|
||||
"""Decide whether to enable the GPU monitor.
|
||||
|
||||
Runs if thresholds request GPU checks or if GPU_UTIL_LOG is truthy.
|
||||
"""
|
||||
want = False
|
||||
try:
|
||||
mean_th = None if thresholds is None else thresholds.get("gpu_util_mean_min")
|
||||
p50_th = None if thresholds is None else thresholds.get("gpu_util_p50_min")
|
||||
want = bool(mean_th is not None or p50_th is not None)
|
||||
except Exception:
|
||||
want = False
|
||||
if not want:
|
||||
env_flag = os.environ.get("GPU_UTIL_LOG", "").lower() in ("1", "true", "yes")
|
||||
want = want or env_flag
|
||||
return want
|
||||
|
||||
|
||||
def _gpu_monitor_path(experiment_folder: str) -> str:
|
||||
"""Return the JSON path for storing GPU monitor results."""
|
||||
base = Path.cwd() / experiment_folder
|
||||
return str(base / "gpu_utilization.json")
|
||||
|
||||
|
||||
def _launch_gpu_monitor(bench_pid: int, experiment_folder: str, interval: float):
|
||||
"""Start the GPU monitor process. Returns (proc, path) or (None, None)."""
|
||||
try:
|
||||
from multiprocessing import Process
|
||||
|
||||
out_path = _gpu_monitor_path(experiment_folder)
|
||||
proc = Process(
|
||||
target=_gpu_monitor_proc_entry,
|
||||
args=(bench_pid, out_path, interval),
|
||||
daemon=True,
|
||||
)
|
||||
proc.start()
|
||||
return proc, out_path
|
||||
except Exception as e:
|
||||
logger.warning("Failed to launch GPU monitor: %s", e)
|
||||
return None, None
|
||||
|
||||
|
||||
def _read_gpu_monitor_result(path: Optional[str]) -> Optional[dict]:
|
||||
try:
|
||||
if path and os.path.exists(path):
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to read GPU monitor result from %r: %s", path, e)
|
||||
return None
|
||||
|
||||
|
||||
def _log_and_assert_gpu_thresholds(
|
||||
result: Optional[dict], thresholds: Optional[dict]
|
||||
) -> None:
|
||||
if not result or not isinstance(result, dict) or result.get("count", 0) <= 0:
|
||||
logger.warning("GPU utilization monitor produced no samples.")
|
||||
return
|
||||
|
||||
overall = result.get("overall", {}) if isinstance(result, dict) else {}
|
||||
count = int(result.get("count", 0))
|
||||
mean_th = None if thresholds is None else thresholds.get("gpu_util_mean_min")
|
||||
p50_th = None if thresholds is None else thresholds.get("gpu_util_p50_min")
|
||||
|
||||
mean_v = float(overall.get("mean", 0.0))
|
||||
p50_v = overall.get("p50")
|
||||
|
||||
logger.info(
|
||||
"GPU utilization overall: mean=%.2f%% p50=%s (samples=%d)",
|
||||
mean_v,
|
||||
(f"{float(p50_v):.2f}%" if p50_v is not None else "n/a"),
|
||||
count,
|
||||
)
|
||||
|
||||
if mean_th is not None:
|
||||
assert mean_v >= float(
|
||||
mean_th
|
||||
), f"GPU utilization mean below threshold: {mean_v:.2f}% < {mean_th}%"
|
||||
if p50_th is not None and p50_v is not None:
|
||||
p50_f = float(p50_v)
|
||||
assert p50_f >= float(
|
||||
p50_th
|
||||
), f"GPU utilization p50 below threshold: {p50_f:.2f}% < {p50_th}%"
|
||||
|
||||
|
||||
def _gpu_monitor_proc_entry(bench_pid: int, out_file: str, interval: float) -> None:
|
||||
"""Low-impact GPU utilization monitor using NVML in a separate process.
|
||||
|
||||
Writes JSON to out_file that includes overall and per-GPU raw samples and summary stats.
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
os.nice(10)
|
||||
except Exception:
|
||||
pass
|
||||
total = 0.0
|
||||
n = 0
|
||||
try:
|
||||
import pynvml # type: ignore
|
||||
|
||||
pynvml.nvmlInit()
|
||||
except Exception:
|
||||
with open(out_file, "w") as f:
|
||||
os.makedirs(os.path.dirname(out_file), exist_ok=True)
|
||||
json.dump(
|
||||
{
|
||||
"count": 0,
|
||||
"overall": {"mean": 0.0},
|
||||
"per_gpu": {},
|
||||
"raw": {},
|
||||
},
|
||||
f,
|
||||
)
|
||||
return
|
||||
try:
|
||||
import pynvml # type: ignore
|
||||
|
||||
count = pynvml.nvmlDeviceGetCount()
|
||||
handles = [pynvml.nvmlDeviceGetHandleByIndex(i) for i in range(count)]
|
||||
except Exception:
|
||||
with open(out_file, "w") as f:
|
||||
os.makedirs(os.path.dirname(out_file), exist_ok=True)
|
||||
json.dump(
|
||||
{
|
||||
"count": 0,
|
||||
"overall": {"mean": 0.0},
|
||||
"per_gpu": {},
|
||||
"raw": {},
|
||||
},
|
||||
f,
|
||||
)
|
||||
return
|
||||
|
||||
# Prepare per-GPU and overall raw collectors
|
||||
per_gpu_samples: dict[str, list[float]] = {}
|
||||
overall_samples: list[float] = []
|
||||
|
||||
while True:
|
||||
if not os.path.exists(f"/proc/{bench_pid}"):
|
||||
break
|
||||
try:
|
||||
vals = []
|
||||
import pynvml # type: ignore
|
||||
|
||||
for idx, h in enumerate(handles):
|
||||
try:
|
||||
util = pynvml.nvmlDeviceGetUtilizationRates(h).gpu
|
||||
vals.append(float(util))
|
||||
key = str(idx)
|
||||
per_gpu_samples.setdefault(key, []).append(float(util))
|
||||
except Exception:
|
||||
continue
|
||||
if vals:
|
||||
avg = sum(vals) / len(vals)
|
||||
overall_samples.append(avg)
|
||||
total += avg
|
||||
n += 1
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(interval)
|
||||
finally:
|
||||
try:
|
||||
os.makedirs(os.path.dirname(out_file), exist_ok=True)
|
||||
with open(out_file, "w") as f:
|
||||
|
||||
def pct_from(samples: list[float], p: float) -> float:
|
||||
if not samples:
|
||||
return 0.0
|
||||
srt = sorted(samples)
|
||||
i = max(
|
||||
0, min(len(srt) - 1, int(round((p / 100.0) * (len(srt) - 1))))
|
||||
)
|
||||
return float(srt[i])
|
||||
|
||||
overall_mean = (total / n) if n > 0 else 0.0
|
||||
|
||||
per_gpu_summary: dict[str, dict] = {}
|
||||
for key, arr in per_gpu_samples.items():
|
||||
per_gpu_summary[key] = {
|
||||
"mean": float(sum(arr) / len(arr)) if arr else 0.0,
|
||||
"p5": pct_from(arr, 5),
|
||||
"p10": pct_from(arr, 10),
|
||||
"p25": pct_from(arr, 25),
|
||||
"p50": pct_from(arr, 50),
|
||||
"p75": pct_from(arr, 75),
|
||||
"p90": pct_from(arr, 90),
|
||||
"p95": pct_from(arr, 95),
|
||||
"min": float(min(arr)) if arr else 0.0,
|
||||
"max": float(max(arr)) if arr else 0.0,
|
||||
"count": len(arr),
|
||||
}
|
||||
|
||||
out_payload = {
|
||||
"bench_pid": bench_pid,
|
||||
"interval_sec": interval,
|
||||
"count": n,
|
||||
"overall": {
|
||||
"mean": float(overall_mean),
|
||||
"p5": pct_from(overall_samples, 5),
|
||||
"p10": pct_from(overall_samples, 10),
|
||||
"p25": pct_from(overall_samples, 25),
|
||||
"p50": pct_from(overall_samples, 50),
|
||||
"p75": pct_from(overall_samples, 75),
|
||||
"p90": pct_from(overall_samples, 90),
|
||||
"p95": pct_from(overall_samples, 95),
|
||||
"min": float(min(overall_samples)) if overall_samples else 0.0,
|
||||
"max": float(max(overall_samples)) if overall_samples else 0.0,
|
||||
},
|
||||
"per_gpu": per_gpu_summary,
|
||||
"raw": {
|
||||
"overall": overall_samples,
|
||||
"per_gpu": per_gpu_samples,
|
||||
},
|
||||
}
|
||||
json.dump(out_payload, f)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import pynvml # type: ignore
|
||||
|
||||
pynvml.nvmlShutdown()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def genai_bench_runner() -> Callable[..., None]:
|
||||
"""Provide a callable to run genai-bench and validate metrics.
|
||||
|
||||
Usage in tests:
|
||||
def test(..., genai_bench_runner):
|
||||
genai_bench_runner(router_url=..., model_path=..., experiment_folder=...)
|
||||
"""
|
||||
|
||||
def _run(
|
||||
*,
|
||||
router_url: str,
|
||||
model_path: str,
|
||||
experiment_folder: str,
|
||||
timeout_sec: int | None = None,
|
||||
thresholds: dict | None = None,
|
||||
extra_env: dict | None = None,
|
||||
num_concurrency: int = 32,
|
||||
traffic_scenario: str = "D(4000,100)",
|
||||
max_requests_per_run: int | None = None,
|
||||
clean_experiment: bool = True,
|
||||
kill_procs: list | None = None,
|
||||
drain_delay_sec: int = 6,
|
||||
) -> None:
|
||||
cli = _which("genai-bench")
|
||||
if not cli:
|
||||
pytest.fail(
|
||||
"genai-bench CLI not found; please install it to run benchmarks"
|
||||
)
|
||||
|
||||
# Clean previous experiment folder under current working directory
|
||||
if clean_experiment:
|
||||
exp_dir = Path.cwd() / experiment_folder
|
||||
if exp_dir.exists():
|
||||
shutil.rmtree(exp_dir, ignore_errors=True)
|
||||
|
||||
# Default requests per run if not provided
|
||||
mrr = (
|
||||
max_requests_per_run
|
||||
if max_requests_per_run is not None
|
||||
else num_concurrency * 5
|
||||
)
|
||||
|
||||
cmd = [
|
||||
cli,
|
||||
"benchmark",
|
||||
"--api-backend",
|
||||
"openai",
|
||||
"--api-base",
|
||||
router_url,
|
||||
"--api-key",
|
||||
"dummy-token",
|
||||
"--api-model-name",
|
||||
model_path,
|
||||
"--model-tokenizer",
|
||||
model_path,
|
||||
"--task",
|
||||
"text-to-text",
|
||||
"--num-concurrency",
|
||||
str(num_concurrency),
|
||||
"--traffic-scenario",
|
||||
traffic_scenario,
|
||||
"--max-requests-per-run",
|
||||
str(mrr),
|
||||
"--max-time-per-run",
|
||||
"3",
|
||||
"--experiment-folder-name",
|
||||
experiment_folder,
|
||||
"--experiment-base-dir",
|
||||
str(Path.cwd()),
|
||||
]
|
||||
|
||||
env = os.environ.copy()
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
|
||||
to = timeout_sec or int(os.environ.get("GENAI_BENCH_TEST_TIMEOUT", "120"))
|
||||
proc = subprocess.Popen(
|
||||
cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
||||
)
|
||||
# Optional GPU utilization monitor in a low-priority child process (pynvml only)
|
||||
# Enabled only when gpu_util_mean_min is provided in thresholds.
|
||||
monitor_path = None
|
||||
monitor_proc = None
|
||||
gpu_util_result: dict | None = None
|
||||
want_gpu_monitor = _gpu_monitor_should_run(thresholds)
|
||||
if want_gpu_monitor:
|
||||
interval = float(os.environ.get("GPU_UTIL_SAMPLE_INTERVAL", "2.0"))
|
||||
monitor_proc, monitor_path = _launch_gpu_monitor(
|
||||
bench_pid=proc.pid,
|
||||
experiment_folder=experiment_folder,
|
||||
interval=interval,
|
||||
)
|
||||
stdout = stderr = ""
|
||||
rc = None
|
||||
try:
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=to)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Simple: kill the CLI process if it doesn't exit in time
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
stdout, stderr = proc.communicate()
|
||||
rc = proc.returncode
|
||||
|
||||
# Prefer exact path under cwd; fallback to rglob search
|
||||
base = Path.cwd()
|
||||
direct = base / experiment_folder
|
||||
candidates = [direct] if direct.is_dir() else []
|
||||
if not candidates:
|
||||
for p in base.rglob(experiment_folder):
|
||||
if p.is_dir() and p.name == experiment_folder:
|
||||
candidates = [p]
|
||||
break
|
||||
if not candidates:
|
||||
raise AssertionError(
|
||||
"Benchmark failed: experiment folder not found: "
|
||||
f"{experiment_folder}\nExit code: {rc}\nSTDOUT (tail):\n{stdout[-1000:]}\nSTDERR (tail):\n{stderr[-1000:]}"
|
||||
)
|
||||
actual_folder = candidates[0]
|
||||
|
||||
json_files = []
|
||||
for _ in range(10):
|
||||
json_files = [
|
||||
p
|
||||
for p in actual_folder.rglob("*.json")
|
||||
if "experiment_metadata" not in p.name
|
||||
]
|
||||
if json_files:
|
||||
break
|
||||
time.sleep(1)
|
||||
if not json_files:
|
||||
raise AssertionError(
|
||||
"Benchmark failed: no JSON results found\n"
|
||||
f"Exit code: {rc}\nSTDOUT (tail):\n{stdout[-1000:]}\nSTDERR (tail):\n{stderr[-1000:]}"
|
||||
)
|
||||
|
||||
th = thresholds # None means "log only", no validation
|
||||
|
||||
for jf in json_files:
|
||||
with jf.open("r") as f:
|
||||
data = json.load(f)
|
||||
stats = data.get("aggregated_metrics", {}).get("stats", {})
|
||||
ttft_mean = float(stats.get("ttft", {}).get("mean", float("inf")))
|
||||
e2e_latency_mean = float(
|
||||
stats.get("e2e_latency", {}).get("mean", float("inf"))
|
||||
)
|
||||
input_tp_mean = float(
|
||||
stats.get("input_throughput", {}).get("mean", 0.0)
|
||||
)
|
||||
output_tp_mean = float(
|
||||
stats.get("output_throughput", {}).get("mean", 0.0)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"genai-bench[%s] %s ttft_mean=%.3fs e2e_latency_mean=%.3fs input_tp_mean=%.1f tok/s output_tp_mean=%.1f tok/s",
|
||||
experiment_folder,
|
||||
jf.name,
|
||||
ttft_mean,
|
||||
e2e_latency_mean,
|
||||
input_tp_mean,
|
||||
output_tp_mean,
|
||||
)
|
||||
|
||||
if th is not None:
|
||||
assert (
|
||||
ttft_mean <= th["ttft_mean_max"]
|
||||
), f"TTFT validation failed: {ttft_mean} > {th['ttft_mean_max']} (file={jf.name})"
|
||||
assert (
|
||||
e2e_latency_mean <= th["e2e_latency_mean_max"]
|
||||
), f"E2E latency validation failed: {e2e_latency_mean} > {th['e2e_latency_mean_max']} (file={jf.name})"
|
||||
assert (
|
||||
input_tp_mean >= th["input_throughput_mean_min"]
|
||||
), f"Input throughput validation failed: {input_tp_mean} < {th['input_throughput_mean_min']} (file={jf.name})"
|
||||
assert (
|
||||
output_tp_mean >= th["output_throughput_mean_min"]
|
||||
), f"Output throughput validation failed: {output_tp_mean} < {th['output_throughput_mean_min']} (file={jf.name})"
|
||||
|
||||
# Validate optional GPU utilization threshold if provided
|
||||
if want_gpu_monitor:
|
||||
try:
|
||||
if monitor_proc is not None:
|
||||
monitor_proc.join(timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
gpu_util_result = _read_gpu_monitor_result(monitor_path)
|
||||
_log_and_assert_gpu_thresholds(gpu_util_result, thresholds)
|
||||
|
||||
finally:
|
||||
# Always attempt to stop workers to avoid resource leakage
|
||||
if kill_procs:
|
||||
# Give router/workers a small grace period to finish any last drains
|
||||
if drain_delay_sec > 0:
|
||||
try:
|
||||
time.sleep(drain_delay_sec)
|
||||
except Exception:
|
||||
pass
|
||||
for p in kill_procs:
|
||||
_graceful_stop_any(p)
|
||||
try:
|
||||
time.sleep(2)
|
||||
except Exception:
|
||||
pass
|
||||
# Ensure GPU monitor process is cleaned up
|
||||
if monitor_proc is not None and monitor_proc.is_alive():
|
||||
try:
|
||||
monitor_proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _run
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line("markers", "e2e: mark as end-to-end test")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def e2e_model() -> str:
|
||||
# Always use the default test model
|
||||
return os.getenv("E2E_PRIMARY_MODEL", DEFAULT_MODEL_NAME_FOR_TEST)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def e2e_router(e2e_model: str):
|
||||
# Keep this available but tests below use router-only to avoid GPU contention
|
||||
base_url = DEFAULT_URL_FOR_TEST
|
||||
proc = _popen_launch_router(
|
||||
e2e_model, base_url, dp_size=2, timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH
|
||||
)
|
||||
try:
|
||||
yield SimpleNamespace(proc=proc, url=base_url)
|
||||
finally:
|
||||
_terminate(proc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def e2e_router_only_rr():
|
||||
port = _find_available_port()
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
proc = _popen_launch_router_only(base_url, policy="round_robin")
|
||||
try:
|
||||
yield SimpleNamespace(proc=proc, url=base_url)
|
||||
finally:
|
||||
_terminate(proc)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def e2e_embedding_model() -> str:
|
||||
"""Embedding model to use for E2E tests.
|
||||
|
||||
Defaults to an E5 Mistral model, can be overridden via E2E_EMBEDDING_MODEL env var.
|
||||
"""
|
||||
import os
|
||||
|
||||
return os.getenv("E2E_EMBEDDING_MODEL", "intfloat/e5-mistral-7b-instruct")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def e2e_primary_embedding_worker(e2e_embedding_model: str):
|
||||
"""Launch a single embedding worker using the specified model."""
|
||||
port = _find_available_port()
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
proc = _popen_launch_worker(e2e_embedding_model, base_url)
|
||||
try:
|
||||
yield SimpleNamespace(proc=proc, url=base_url)
|
||||
finally:
|
||||
_terminate(proc)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def e2e_primary_worker(e2e_model: str):
|
||||
port = _find_available_port()
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
proc = _popen_launch_worker(e2e_model, base_url)
|
||||
# Router health gate will handle worker readiness
|
||||
try:
|
||||
yield SimpleNamespace(proc=proc, url=base_url)
|
||||
finally:
|
||||
_terminate(proc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def e2e_router_only_rr_dp_aware_api():
|
||||
"""Router-only with dp-aware enabled and an API key."""
|
||||
port = _find_available_port()
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
api_key = "secret"
|
||||
proc = _popen_launch_router_only(
|
||||
base_url, policy="round_robin", timeout=180.0, dp_aware=True, api_key=api_key
|
||||
)
|
||||
try:
|
||||
yield SimpleNamespace(proc=proc, url=base_url, api_key=api_key)
|
||||
finally:
|
||||
_terminate(proc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def e2e_worker_dp2_api(e2e_model: str, e2e_router_only_rr_dp_aware_api):
|
||||
"""Worker with dp-size=2 and the same API key as the dp-aware router."""
|
||||
port = _find_available_port()
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
api_key = e2e_router_only_rr_dp_aware_api.api_key
|
||||
proc = _popen_launch_worker(e2e_model, base_url, dp_size=2, api_key=api_key)
|
||||
try:
|
||||
yield SimpleNamespace(proc=proc, url=base_url)
|
||||
finally:
|
||||
_terminate(proc)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def e2e_two_workers_dp2(e2e_model: str):
|
||||
"""Launch two workers, each with dp_size=2, mapped to GPUs [0,1] and [2,3]."""
|
||||
workers = []
|
||||
try:
|
||||
# Worker A on GPUs 0-1
|
||||
port_a = _find_available_port()
|
||||
url_a = f"http://127.0.0.1:{port_a}"
|
||||
proc_a = _popen_launch_worker(e2e_model, url_a, dp_size=2, base_gpu_id=0)
|
||||
workers.append(SimpleNamespace(proc=proc_a, url=url_a))
|
||||
|
||||
# Worker B on GPUs 2-3
|
||||
port_b = _find_available_port()
|
||||
url_b = f"http://127.0.0.1:{port_b}"
|
||||
proc_b = _popen_launch_worker(e2e_model, url_b, dp_size=2, base_gpu_id=2)
|
||||
workers.append(SimpleNamespace(proc=proc_b, url=url_b))
|
||||
|
||||
yield workers
|
||||
finally:
|
||||
for w in workers:
|
||||
_terminate(w.proc)
|
||||
@@ -1,245 +0,0 @@
|
||||
import logging
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _find_available_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _wait_health(url: str, timeout: float = 180.0) -> None:
|
||||
start = time.perf_counter()
|
||||
with requests.Session() as session:
|
||||
while time.perf_counter() - start < timeout:
|
||||
try:
|
||||
r = session.get(f"{url}/health", timeout=5)
|
||||
if r.status_code == 200:
|
||||
return
|
||||
except requests.RequestException:
|
||||
pass
|
||||
time.sleep(1)
|
||||
raise TimeoutError(f"Service at {url} failed to become healthy in time")
|
||||
|
||||
|
||||
def _detect_ib_device() -> Optional[str]:
|
||||
"""Return first active IB device name (e.g., mlx5_0) or None if unavailable."""
|
||||
# Fast check that ibv_devinfo exists
|
||||
try:
|
||||
subprocess.run(
|
||||
["ibv_devinfo", "-l"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=1,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
for i in range(12):
|
||||
dev = f"mlx5_{i}"
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["ibv_devinfo", dev],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
if res.returncode == 0 and ("state:" in res.stdout):
|
||||
for line in res.stdout.splitlines():
|
||||
if "state:" in line and "PORT_ACTIVE" in line:
|
||||
return dev
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _popen_launch_prefill_worker(
|
||||
model: str,
|
||||
bootstrap_port: int,
|
||||
ib_device: Optional[str] = None,
|
||||
base_gpu_id: int = 0,
|
||||
) -> SimpleNamespace:
|
||||
port = _find_available_port()
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
cmd = [
|
||||
"python3",
|
||||
"-m",
|
||||
"sglang.launch_server",
|
||||
"--model-path",
|
||||
model,
|
||||
"--disaggregation-mode",
|
||||
"prefill",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(port),
|
||||
"--disaggregation-bootstrap-port",
|
||||
str(bootstrap_port),
|
||||
"--base-gpu-id",
|
||||
str(base_gpu_id),
|
||||
]
|
||||
if ib_device:
|
||||
cmd += ["--disaggregation-ib-device", ib_device]
|
||||
proc = subprocess.Popen(cmd)
|
||||
_wait_health(url, timeout=300.0)
|
||||
return SimpleNamespace(proc=proc, url=url, bootstrap_port=bootstrap_port)
|
||||
|
||||
|
||||
def _popen_launch_decode_worker(
|
||||
model: str, ib_device: Optional[str] = None, base_gpu_id: int = 0
|
||||
) -> SimpleNamespace:
|
||||
port = _find_available_port()
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
cmd = [
|
||||
"python3",
|
||||
"-m",
|
||||
"sglang.launch_server",
|
||||
"--model-path",
|
||||
model,
|
||||
"--disaggregation-mode",
|
||||
"decode",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(port),
|
||||
"--base-gpu-id",
|
||||
str(base_gpu_id),
|
||||
]
|
||||
if ib_device:
|
||||
cmd += ["--disaggregation-ib-device", ib_device]
|
||||
proc = subprocess.Popen(cmd)
|
||||
_wait_health(url, timeout=300.0)
|
||||
return SimpleNamespace(proc=proc, url=url)
|
||||
|
||||
|
||||
def _terminate(proc: subprocess.Popen, timeout: float = 120) -> None:
|
||||
if proc is None:
|
||||
return
|
||||
proc.terminate()
|
||||
start = time.perf_counter()
|
||||
while proc.poll() is None:
|
||||
if time.perf_counter() - start > timeout:
|
||||
proc.kill()
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pd_cluster(e2e_model: str):
|
||||
"""Start 2 prefill + 2 decode workers and one PD router, once per module."""
|
||||
# Environment capability checks: require sgl_kernel and GPU backend
|
||||
try:
|
||||
import sgl_kernel # noqa: F401
|
||||
except Exception as e: # pragma: no cover - environment dependent
|
||||
pytest.fail(f"PD e2e requires sgl_kernel but it is not available: {e}")
|
||||
|
||||
try:
|
||||
import torch # noqa: F401
|
||||
except Exception as e: # pragma: no cover - environment dependent
|
||||
pytest.fail(
|
||||
f"PD e2e requires torch but it is not available or misconfigured: {e}"
|
||||
)
|
||||
|
||||
if not torch.cuda.is_available(): # pragma: no cover - environment dependent
|
||||
pytest.fail("PD e2e requires CUDA backend, but CUDA is not available")
|
||||
|
||||
workers: list[SimpleNamespace] = []
|
||||
router_proc = None
|
||||
try:
|
||||
ib_device = _detect_ib_device()
|
||||
|
||||
# Launch 4 workers across 4 GPUs: prefill on 0,1 and decode on 2,3
|
||||
pf1 = _popen_launch_prefill_worker(
|
||||
e2e_model,
|
||||
bootstrap_port=_find_available_port(),
|
||||
ib_device=ib_device,
|
||||
base_gpu_id=0,
|
||||
)
|
||||
pf2 = _popen_launch_prefill_worker(
|
||||
e2e_model,
|
||||
bootstrap_port=_find_available_port(),
|
||||
ib_device=ib_device,
|
||||
base_gpu_id=1,
|
||||
)
|
||||
dc1 = _popen_launch_decode_worker(e2e_model, ib_device=ib_device, base_gpu_id=2)
|
||||
dc2 = _popen_launch_decode_worker(e2e_model, ib_device=ib_device, base_gpu_id=3)
|
||||
prefills = [pf1, pf2]
|
||||
decodes = [dc1, dc2]
|
||||
workers.extend(prefills + decodes)
|
||||
|
||||
# PD router with two prefill and two decode endpoints
|
||||
rport = _find_available_port()
|
||||
router_url = f"http://127.0.0.1:{rport}"
|
||||
pport = _find_available_port()
|
||||
|
||||
prefill = [(pf.url, pf.bootstrap_port) for pf in prefills]
|
||||
decode = [dc.url for dc in decodes]
|
||||
|
||||
cmd = [
|
||||
"python3",
|
||||
"-m",
|
||||
"sglang_router.launch_router",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(rport),
|
||||
"--policy",
|
||||
"round_robin",
|
||||
"--pd-disaggregation",
|
||||
"--log-level",
|
||||
"warn",
|
||||
]
|
||||
for url, bport in prefill:
|
||||
cmd += ["--prefill", url, str(bport)]
|
||||
for url in decode:
|
||||
cmd += ["--decode", url]
|
||||
cmd += [
|
||||
"--prometheus-port",
|
||||
str(pport),
|
||||
"--prometheus-host",
|
||||
"127.0.0.1",
|
||||
]
|
||||
|
||||
router_proc = subprocess.Popen(cmd)
|
||||
_wait_health(router_url, timeout=180.0)
|
||||
|
||||
yield SimpleNamespace(
|
||||
router_url=router_url, workers=workers, router_proc=router_proc
|
||||
)
|
||||
finally:
|
||||
if router_proc is not None:
|
||||
_terminate(router_proc)
|
||||
for w in workers:
|
||||
_terminate(w.proc)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_pd_genai_bench(e2e_model: str, pd_cluster, genai_bench_runner):
|
||||
"""
|
||||
Launch 4 workers, start a PD router (2 prefill + 2 decode), then run a
|
||||
short genai-bench benchmark and validate aggregate metrics.
|
||||
"""
|
||||
# Run genai-bench against the shared router
|
||||
policy_label = "benchmark_round_robin_pd"
|
||||
genai_bench_runner(
|
||||
router_url=pd_cluster.router_url,
|
||||
model_path=e2e_model,
|
||||
experiment_folder=policy_label,
|
||||
thresholds={
|
||||
"ttft_mean_max": 13,
|
||||
"e2e_latency_mean_max": 16,
|
||||
"input_throughput_mean_min": 350,
|
||||
"output_throughput_mean_min": 18,
|
||||
"gpu_util_p50_min": 99,
|
||||
},
|
||||
kill_procs=pd_cluster.workers,
|
||||
)
|
||||
@@ -1,56 +0,0 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
|
||||
def _wait_for_workers(
|
||||
base_url: str, expected_count: int, timeout: float = 60.0, headers: dict = None
|
||||
) -> None:
|
||||
"""Poll /workers endpoint until expected number of workers are registered."""
|
||||
start = time.perf_counter()
|
||||
with requests.Session() as session:
|
||||
while time.perf_counter() - start < timeout:
|
||||
try:
|
||||
r = session.get(f"{base_url}/workers", headers=headers, timeout=5)
|
||||
if r.status_code == 200:
|
||||
workers = r.json().get("workers", [])
|
||||
if len(workers) >= expected_count:
|
||||
return
|
||||
except requests.RequestException:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
raise TimeoutError(
|
||||
f"Expected {expected_count} workers at {base_url}, timed out after {timeout}s"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_genai_bench(
|
||||
e2e_router_only_rr, e2e_two_workers_dp2, e2e_model, genai_bench_runner
|
||||
):
|
||||
"""Attach a worker to the regular router and run a short genai-bench."""
|
||||
base = e2e_router_only_rr.url
|
||||
for w in e2e_two_workers_dp2:
|
||||
r = requests.post(f"{base}/workers", json={"url": w.url}, timeout=180)
|
||||
assert (
|
||||
r.status_code == 202
|
||||
), f"Expected 202 ACCEPTED, got {r.status_code}: {r.text}"
|
||||
|
||||
# Wait for workers to be registered
|
||||
_wait_for_workers(base, expected_count=2, timeout=60.0)
|
||||
|
||||
genai_bench_runner(
|
||||
router_url=base,
|
||||
model_path=e2e_model,
|
||||
experiment_folder="benchmark_round_robin_regular",
|
||||
thresholds={
|
||||
"ttft_mean_max": 6,
|
||||
"e2e_latency_mean_max": 14,
|
||||
"input_throughput_mean_min": 800, # temp relax from 1000 to 800 for now
|
||||
"output_throughput_mean_min": 12,
|
||||
# Enforce GPU utilization p50 >= 99% during the run.
|
||||
"gpu_util_p50_min": 99,
|
||||
},
|
||||
kill_procs=e2e_two_workers_dp2,
|
||||
)
|
||||
@@ -35,6 +35,8 @@ from .gpu_allocator import (
|
||||
nvml_context,
|
||||
wait_for_gpu_memory_to_clear,
|
||||
)
|
||||
from .gpu_monitor import GPUMonitor
|
||||
from .gpu_monitor import should_monitor as should_monitor_gpu
|
||||
from .model_pool import ModelInstance, ModelPool
|
||||
from .model_specs import ( # Default model paths; Model groups
|
||||
CHAT_MODELS,
|
||||
@@ -104,6 +106,9 @@ __all__ = [
|
||||
"wait_for_health",
|
||||
"wait_for_workers_ready",
|
||||
"detect_ib_device",
|
||||
# GPU monitoring
|
||||
"GPUMonitor",
|
||||
"should_monitor_gpu",
|
||||
# Model management
|
||||
"ModelInstance",
|
||||
"ModelPool",
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"""GPU utilization monitoring for benchmarks.
|
||||
|
||||
This module provides a low-impact GPU monitor that runs in a separate process
|
||||
and collects utilization samples using NVML.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from multiprocessing import Process
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _percentile(samples: list[float], p: float) -> float:
|
||||
"""Calculate percentile from sorted samples."""
|
||||
if not samples:
|
||||
return 0.0
|
||||
sorted_samples = sorted(samples)
|
||||
idx = max(
|
||||
0,
|
||||
min(
|
||||
len(sorted_samples) - 1, int(round((p / 100.0) * (len(sorted_samples) - 1)))
|
||||
),
|
||||
)
|
||||
return float(sorted_samples[idx])
|
||||
|
||||
|
||||
def _compute_stats(samples: list[float]) -> dict[str, float]:
|
||||
"""Compute statistics for a list of samples."""
|
||||
if not samples:
|
||||
return {
|
||||
"mean": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 0.0,
|
||||
"p5": 0.0,
|
||||
"p10": 0.0,
|
||||
"p25": 0.0,
|
||||
"p50": 0.0,
|
||||
"p75": 0.0,
|
||||
"p90": 0.0,
|
||||
"p95": 0.0,
|
||||
"count": 0,
|
||||
}
|
||||
return {
|
||||
"mean": sum(samples) / len(samples),
|
||||
"min": min(samples),
|
||||
"max": max(samples),
|
||||
"p5": _percentile(samples, 5),
|
||||
"p10": _percentile(samples, 10),
|
||||
"p25": _percentile(samples, 25),
|
||||
"p50": _percentile(samples, 50),
|
||||
"p75": _percentile(samples, 75),
|
||||
"p90": _percentile(samples, 90),
|
||||
"p95": _percentile(samples, 95),
|
||||
"count": len(samples),
|
||||
}
|
||||
|
||||
|
||||
def _monitor_loop(pid: int, output_path: str, interval: float) -> None:
|
||||
"""Main monitoring loop - runs in separate process.
|
||||
|
||||
Monitors GPU utilization until the target process exits, then writes
|
||||
results to output_path as JSON.
|
||||
"""
|
||||
# Lower process priority to minimize impact on benchmark
|
||||
try:
|
||||
os.nice(10)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Initialize NVML
|
||||
try:
|
||||
import pynvml
|
||||
|
||||
pynvml.nvmlInit()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to initialize NVML: %s", e)
|
||||
_write_empty_result(output_path)
|
||||
return
|
||||
|
||||
# Get GPU handles
|
||||
try:
|
||||
device_count = pynvml.nvmlDeviceGetCount()
|
||||
handles = [pynvml.nvmlDeviceGetHandleByIndex(i) for i in range(device_count)]
|
||||
except Exception as e:
|
||||
logger.warning("Failed to get GPU handles: %s", e)
|
||||
_write_empty_result(output_path)
|
||||
_shutdown_nvml()
|
||||
return
|
||||
|
||||
# Collect samples
|
||||
per_gpu_samples: dict[str, list[float]] = {str(i): [] for i in range(device_count)}
|
||||
overall_samples: list[float] = []
|
||||
|
||||
try:
|
||||
while _process_alive(pid):
|
||||
try:
|
||||
gpu_utils = []
|
||||
for idx, handle in enumerate(handles):
|
||||
try:
|
||||
util = pynvml.nvmlDeviceGetUtilizationRates(handle).gpu
|
||||
gpu_utils.append(float(util))
|
||||
per_gpu_samples[str(idx)].append(float(util))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if gpu_utils:
|
||||
avg = sum(gpu_utils) / len(gpu_utils)
|
||||
overall_samples.append(avg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
time.sleep(interval)
|
||||
finally:
|
||||
# Write results
|
||||
_write_result(output_path, pid, interval, overall_samples, per_gpu_samples)
|
||||
_shutdown_nvml()
|
||||
|
||||
|
||||
def _process_alive(pid: int) -> bool:
|
||||
"""Check if process is still running."""
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except (OSError, ProcessLookupError):
|
||||
return False
|
||||
|
||||
|
||||
def _write_empty_result(path: str) -> None:
|
||||
"""Write empty result file."""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"count": 0,
|
||||
"overall": {"mean": 0.0},
|
||||
"per_gpu": {},
|
||||
"raw": {"overall": [], "per_gpu": {}},
|
||||
},
|
||||
f,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _write_result(
|
||||
path: str,
|
||||
pid: int,
|
||||
interval: float,
|
||||
overall_samples: list[float],
|
||||
per_gpu_samples: dict[str, list[float]],
|
||||
) -> None:
|
||||
"""Write monitoring results to JSON file."""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"bench_pid": pid,
|
||||
"interval_sec": interval,
|
||||
"count": len(overall_samples),
|
||||
"overall": _compute_stats(overall_samples),
|
||||
"per_gpu": {
|
||||
k: _compute_stats(v) for k, v in per_gpu_samples.items()
|
||||
},
|
||||
"raw": {
|
||||
"overall": overall_samples,
|
||||
"per_gpu": per_gpu_samples,
|
||||
},
|
||||
},
|
||||
f,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to write GPU monitor results: %s", e)
|
||||
|
||||
|
||||
def _shutdown_nvml() -> None:
|
||||
"""Shutdown NVML."""
|
||||
try:
|
||||
import pynvml
|
||||
|
||||
pynvml.nvmlShutdown()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class GPUMonitor:
|
||||
"""GPU utilization monitor for benchmarks.
|
||||
|
||||
Usage:
|
||||
monitor = GPUMonitor(output_dir="benchmark_results")
|
||||
monitor.start(target_pid=12345)
|
||||
# ... run benchmark ...
|
||||
result = monitor.stop()
|
||||
monitor.assert_thresholds({"gpu_util_p50_min": 99})
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
output_dir: str | Path = ".",
|
||||
interval: float = 2.0,
|
||||
):
|
||||
self.output_dir = Path(output_dir)
|
||||
self.interval = interval
|
||||
self._process: Process | None = None
|
||||
self._output_path: str | None = None
|
||||
self._result: dict[str, Any] | None = None
|
||||
|
||||
@property
|
||||
def output_path(self) -> str | None:
|
||||
"""Path to the GPU utilization JSON file."""
|
||||
return self._output_path
|
||||
|
||||
def start(self, target_pid: int) -> None:
|
||||
"""Start monitoring GPU utilization for the target process."""
|
||||
self._output_path = str(self.output_dir / "gpu_utilization.json")
|
||||
self._result = None
|
||||
|
||||
self._process = Process(
|
||||
target=_monitor_loop,
|
||||
args=(target_pid, self._output_path, self.interval),
|
||||
daemon=True,
|
||||
)
|
||||
self._process.start()
|
||||
logger.debug("Started GPU monitor for PID %d", target_pid)
|
||||
|
||||
def stop(self, timeout: float = 5.0) -> dict[str, Any] | None:
|
||||
"""Stop monitoring and return results."""
|
||||
if self._process is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
self._process.join(timeout=timeout)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self._process.is_alive():
|
||||
try:
|
||||
self._process.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._process = None
|
||||
self._result = self._read_result()
|
||||
return self._result
|
||||
|
||||
def _read_result(self) -> dict[str, Any] | None:
|
||||
"""Read results from output file."""
|
||||
if not self._output_path or not os.path.exists(self._output_path):
|
||||
return None
|
||||
try:
|
||||
with open(self._output_path) as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to read GPU monitor result: %s", e)
|
||||
return None
|
||||
|
||||
def log_summary(self) -> None:
|
||||
"""Log a summary of GPU utilization."""
|
||||
result = self._result or self._read_result()
|
||||
if not result or result.get("count", 0) <= 0:
|
||||
logger.warning("GPU utilization monitor produced no samples")
|
||||
return
|
||||
|
||||
overall = result.get("overall", {})
|
||||
logger.info(
|
||||
"GPU utilization: mean=%.2f%% p50=%.2f%% (samples=%d)",
|
||||
overall.get("mean", 0.0),
|
||||
overall.get("p50", 0.0),
|
||||
result.get("count", 0),
|
||||
)
|
||||
|
||||
def assert_thresholds(self, thresholds: dict[str, float] | None) -> None:
|
||||
"""Assert GPU utilization meets thresholds.
|
||||
|
||||
Supported thresholds:
|
||||
- gpu_util_mean_min: Minimum mean GPU utilization %
|
||||
- gpu_util_p50_min: Minimum p50 GPU utilization %
|
||||
"""
|
||||
if not thresholds:
|
||||
return
|
||||
|
||||
result = self._result or self._read_result()
|
||||
if not result or result.get("count", 0) <= 0:
|
||||
logger.warning("GPU utilization monitor produced no samples")
|
||||
return
|
||||
|
||||
overall = result.get("overall", {})
|
||||
|
||||
mean_threshold = thresholds.get("gpu_util_mean_min")
|
||||
if mean_threshold is not None:
|
||||
mean_value = overall.get("mean", 0.0)
|
||||
assert (
|
||||
mean_value >= mean_threshold
|
||||
), f"GPU utilization mean below threshold: {mean_value:.2f}% < {mean_threshold}%"
|
||||
|
||||
p50_threshold = thresholds.get("gpu_util_p50_min")
|
||||
if p50_threshold is not None:
|
||||
p50_value = overall.get("p50")
|
||||
if p50_value is not None:
|
||||
assert (
|
||||
p50_value >= p50_threshold
|
||||
), f"GPU utilization p50 below threshold: {p50_value:.2f}% < {p50_threshold}%"
|
||||
|
||||
|
||||
def should_monitor(thresholds: dict[str, Any] | None) -> bool:
|
||||
"""Check if GPU monitoring should be enabled.
|
||||
|
||||
Returns True if:
|
||||
- thresholds contains gpu_util_mean_min or gpu_util_p50_min, OR
|
||||
- GPU_UTIL_LOG environment variable is truthy
|
||||
"""
|
||||
if thresholds:
|
||||
if thresholds.get("gpu_util_mean_min") is not None:
|
||||
return True
|
||||
if thresholds.get("gpu_util_p50_min") is not None:
|
||||
return True
|
||||
|
||||
return os.environ.get("GPU_UTIL_LOG", "").lower() in ("1", "true", "yes")
|
||||
@@ -293,6 +293,7 @@ class ModelPool:
|
||||
worker_type: WorkerType = WorkerType.REGULAR,
|
||||
bootstrap_port: int | None = None,
|
||||
ib_device: str | None = None,
|
||||
instance_key: str | None = None,
|
||||
) -> ModelInstance:
|
||||
"""Launch a model instance.
|
||||
|
||||
@@ -303,6 +304,7 @@ class ModelPool:
|
||||
worker_type: Worker type (REGULAR, PREFILL, or DECODE).
|
||||
bootstrap_port: Bootstrap port for prefill workers in PD mode.
|
||||
ib_device: InfiniBand device for PD disaggregation.
|
||||
instance_key: Custom instance key, or None to auto-generate.
|
||||
|
||||
Returns:
|
||||
The launched ModelInstance.
|
||||
@@ -353,11 +355,15 @@ class ModelPool:
|
||||
cmd.extend(["--disaggregation-ib-device", ib_device])
|
||||
elif worker_type == WorkerType.DECODE:
|
||||
cmd.extend(["--disaggregation-mode", "decode"])
|
||||
# Base GPU ID 0 since CUDA_VISIBLE_DEVICES remaps the GPU
|
||||
cmd.extend(["--base-gpu-id", "0"])
|
||||
if ib_device:
|
||||
cmd.extend(["--disaggregation-ib-device", ib_device])
|
||||
|
||||
# Build key based on worker type
|
||||
if worker_type == WorkerType.REGULAR:
|
||||
# Build key based on worker type (or use custom key)
|
||||
if instance_key:
|
||||
key = instance_key
|
||||
elif worker_type == WorkerType.REGULAR:
|
||||
key = f"{model_id}:{mode.value}"
|
||||
else:
|
||||
key = f"{model_id}:{mode.value}:{worker_type.value}"
|
||||
@@ -560,7 +566,11 @@ class ModelPool:
|
||||
return instance
|
||||
|
||||
def _evict_for_gpus(
|
||||
self, required_gpus: int, exclude_model_id: str | None = None
|
||||
self,
|
||||
required_gpus: int,
|
||||
exclude_model_id: str | None = None,
|
||||
exclude_mode: ConnectionMode | None = None,
|
||||
exclude_worker_types: set[WorkerType] | None = None,
|
||||
) -> None:
|
||||
"""Evict models until we have enough GPUs available.
|
||||
|
||||
@@ -570,29 +580,45 @@ class ModelPool:
|
||||
|
||||
Args:
|
||||
required_gpus: Number of GPUs needed.
|
||||
exclude_model_id: Model ID to exclude from eviction (test may need
|
||||
multiple modes of the same model).
|
||||
exclude_model_id: Model ID to exclude from eviction.
|
||||
exclude_mode: Connection mode to exclude from eviction (optional).
|
||||
exclude_worker_types: Worker types to exclude from eviction.
|
||||
If None, falls back to excluding by model_id only (backward compatible).
|
||||
"""
|
||||
available = self.allocator.available_gpus()
|
||||
if len(available) >= required_gpus:
|
||||
return # Already have enough
|
||||
|
||||
# Sort by last_used descending (MRU eviction) - evict most recently used first
|
||||
# Exclude instances of the same model_id (test may need multiple modes)
|
||||
evictable = [
|
||||
inst
|
||||
for inst in self.instances.values()
|
||||
if exclude_model_id is None or inst.model_id != exclude_model_id
|
||||
]
|
||||
evictable.sort(key=lambda x: x.last_used, reverse=True)
|
||||
# Store (dict_key, instance) tuples to preserve the actual key for eviction
|
||||
evictable: list[tuple[str, ModelInstance]] = []
|
||||
for dict_key, inst in self.instances.items():
|
||||
if exclude_worker_types is not None:
|
||||
# Precise matching with worker types
|
||||
# Must match model_id AND worker_type, mode is optional
|
||||
if (
|
||||
exclude_model_id is not None
|
||||
and inst.model_id == exclude_model_id
|
||||
and inst.worker_type in exclude_worker_types
|
||||
):
|
||||
# If mode is specified, also require mode match
|
||||
if exclude_mode is None or inst.mode == exclude_mode:
|
||||
continue
|
||||
else:
|
||||
# Backward compatible: exclude by model_id only
|
||||
if exclude_model_id is not None and inst.model_id == exclude_model_id:
|
||||
continue
|
||||
evictable.append((dict_key, inst))
|
||||
|
||||
evictable.sort(key=lambda x: x[1].last_used, reverse=True)
|
||||
|
||||
freed_gpus = len(available)
|
||||
for inst in evictable:
|
||||
for dict_key, inst in evictable:
|
||||
if freed_gpus >= required_gpus:
|
||||
break
|
||||
|
||||
logger.info("Evicting model %s (MRU) to free GPUs", inst.key)
|
||||
self._evict_instance(inst.key)
|
||||
logger.info("Evicting model %s (MRU) to free GPUs", dict_key)
|
||||
self._evict_instance(dict_key)
|
||||
if inst.gpu_slot:
|
||||
freed_gpus += len(inst.gpu_slot.gpu_ids)
|
||||
|
||||
@@ -608,7 +634,13 @@ class ModelPool:
|
||||
spec = get_model_spec(model_id)
|
||||
required_gpus = spec.get("tp", 1)
|
||||
|
||||
self._evict_for_gpus(required_gpus, exclude_model_id=model_id)
|
||||
# Exclude REGULAR workers of same model from eviction (keep them)
|
||||
# but allow evicting PD workers (PREFILL/DECODE) to free GPUs
|
||||
self._evict_for_gpus(
|
||||
required_gpus,
|
||||
exclude_model_id=model_id,
|
||||
exclude_worker_types={WorkerType.REGULAR},
|
||||
)
|
||||
|
||||
available = self.allocator.available_gpus()
|
||||
if len(available) < required_gpus:
|
||||
@@ -682,6 +714,102 @@ class ModelPool:
|
||||
if inst.model_id == model_id and inst.worker_type == worker_type
|
||||
]
|
||||
|
||||
def launch_regular_workers(
|
||||
self,
|
||||
model_id: str,
|
||||
num_workers: int,
|
||||
mode: ConnectionMode = ConnectionMode.HTTP,
|
||||
startup_timeout: int = DEFAULT_STARTUP_TIMEOUT,
|
||||
allow_eviction: bool = True,
|
||||
) -> list[ModelInstance]:
|
||||
"""Launch multiple regular workers for load balancing.
|
||||
|
||||
Args:
|
||||
model_id: Model identifier from MODEL_SPECS.
|
||||
num_workers: Number of workers to launch.
|
||||
mode: Connection mode (HTTP or GRPC).
|
||||
startup_timeout: Timeout for workers to become healthy.
|
||||
allow_eviction: If True, evict MRU models to free GPUs.
|
||||
|
||||
Returns:
|
||||
List of ModelInstance objects.
|
||||
"""
|
||||
self._startup_timeout = startup_timeout
|
||||
|
||||
if model_id not in MODEL_SPECS:
|
||||
raise ValueError(f"Unknown model: {model_id}")
|
||||
|
||||
spec = get_model_spec(model_id)
|
||||
tp = spec.get("tp", 1)
|
||||
required_gpus = num_workers * tp
|
||||
|
||||
# Check if we have enough GPUs
|
||||
available = self.allocator.available_gpus()
|
||||
if len(available) < required_gpus:
|
||||
if allow_eviction:
|
||||
logger.info(
|
||||
"Need %d GPUs for %d workers, only %d available. Evicting MRU models...",
|
||||
required_gpus,
|
||||
num_workers,
|
||||
len(available),
|
||||
)
|
||||
# Exclude REGULAR workers of same model/mode from eviction
|
||||
self._evict_for_gpus(
|
||||
required_gpus,
|
||||
exclude_model_id=model_id,
|
||||
exclude_mode=mode,
|
||||
exclude_worker_types={WorkerType.REGULAR},
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Need %d GPUs for %d workers, only %d available. "
|
||||
"Skipping (eviction not allowed).",
|
||||
required_gpus,
|
||||
num_workers,
|
||||
len(available),
|
||||
)
|
||||
return []
|
||||
|
||||
# Build allocation specs for all workers
|
||||
allocation_specs = {}
|
||||
for i in range(num_workers):
|
||||
key = f"{model_id}:{mode.value}:{i}"
|
||||
allocation_specs[key] = {
|
||||
"model": spec["model"],
|
||||
"memory_gb": spec.get("memory_gb", 16),
|
||||
"tp": tp,
|
||||
}
|
||||
|
||||
# Allocate GPU slots
|
||||
slots = self.allocator.allocate_slots(allocation_specs)
|
||||
slot_map = {slot.assigned_model: slot for slot in slots}
|
||||
|
||||
if not slots:
|
||||
raise RuntimeError(
|
||||
f"Failed to allocate GPU slots for {num_workers} workers after eviction. "
|
||||
f"Need {required_gpus} GPUs."
|
||||
)
|
||||
|
||||
instances: list[ModelInstance] = []
|
||||
|
||||
# Launch workers
|
||||
for i in range(num_workers):
|
||||
key = f"{model_id}:{mode.value}:{i}"
|
||||
gpu_slot = slot_map.get(key)
|
||||
instance = self._launch_model(
|
||||
model_id=model_id,
|
||||
mode=mode,
|
||||
gpu_slot=gpu_slot,
|
||||
worker_type=WorkerType.REGULAR,
|
||||
instance_key=key,
|
||||
)
|
||||
instances.append(instance)
|
||||
|
||||
# Wait for all to be healthy
|
||||
self._wait_all_healthy()
|
||||
|
||||
return instances
|
||||
|
||||
def launch_pd_workers(
|
||||
self,
|
||||
model_id: str,
|
||||
@@ -728,7 +856,13 @@ class ModelPool:
|
||||
required_gpus,
|
||||
len(available),
|
||||
)
|
||||
self._evict_for_gpus(required_gpus, exclude_model_id=model_id)
|
||||
# Exclude PD workers of same model/mode, but evict REGULAR workers
|
||||
self._evict_for_gpus(
|
||||
required_gpus,
|
||||
exclude_model_id=model_id,
|
||||
exclude_mode=mode,
|
||||
exclude_worker_types={WorkerType.PREFILL, WorkerType.DECODE},
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Need %d GPUs for PD workers, only %d available. "
|
||||
@@ -781,6 +915,7 @@ class ModelPool:
|
||||
worker_type=WorkerType.PREFILL,
|
||||
bootstrap_port=bootstrap_port,
|
||||
ib_device=ib_device,
|
||||
instance_key=key,
|
||||
)
|
||||
prefill_instances.append(instance)
|
||||
|
||||
@@ -794,6 +929,7 @@ class ModelPool:
|
||||
gpu_slot=gpu_slot,
|
||||
worker_type=WorkerType.DECODE,
|
||||
ib_device=ib_device,
|
||||
instance_key=key,
|
||||
)
|
||||
decode_instances.append(instance)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user