363 lines
12 KiB
Python
363 lines
12 KiB
Python
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = ["aiohttp>=3.13.3,<4", "rich>=14.3.3,<15"]
|
|
# ///
|
|
"""Async HTTP benchmark client for the MLflow AI Gateway.
|
|
|
|
Can be imported by run.py or used standalone:
|
|
uv run benchmark.py --url http://127.0.0.1:5731/gateway/benchmark-chat/mlflow/invocations
|
|
uv run benchmark.py --url http://... --requests 5000 --max-concurrent 100 --runs 3
|
|
"""
|
|
|
|
import argparse
|
|
import asyncio
|
|
import math
|
|
import statistics
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
import aiohttp
|
|
from rich.console import Console # type: ignore[import-not-found]
|
|
from rich.progress import ( # type: ignore[import-not-found]
|
|
BarColumn,
|
|
MofNCompleteColumn,
|
|
Progress,
|
|
SpinnerColumn,
|
|
TaskID,
|
|
TextColumn,
|
|
TimeElapsedColumn,
|
|
)
|
|
from rich.table import Table # type: ignore[import-not-found]
|
|
|
|
console = Console()
|
|
|
|
_BODY = {
|
|
"messages": [{"role": "user", "content": "benchmark request"}],
|
|
"temperature": 0.0,
|
|
"max_tokens": 50,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class RunResult:
|
|
latencies_ms: list[float] = field(default_factory=list)
|
|
failures: dict[str, int] = field(default_factory=dict)
|
|
wall_time: float = 0.0
|
|
|
|
@property
|
|
def n_success(self) -> int:
|
|
return len(self.latencies_ms)
|
|
|
|
@property
|
|
def n_failures(self) -> int:
|
|
return sum(self.failures.values())
|
|
|
|
@property
|
|
def throughput(self) -> float:
|
|
return self.n_success / self.wall_time if self.wall_time > 0 else 0.0
|
|
|
|
def percentile(self, p: float) -> float:
|
|
if not self.latencies_ms:
|
|
return 0.0
|
|
s = sorted(self.latencies_ms)
|
|
idx = max(0, math.ceil(p / 100 * len(s)) - 1)
|
|
return s[idx]
|
|
|
|
|
|
async def _send(
|
|
session: aiohttp.ClientSession,
|
|
url: str,
|
|
sem: asyncio.Semaphore,
|
|
auth: aiohttp.BasicAuth | None = None,
|
|
) -> tuple[float, str | None]:
|
|
async with sem:
|
|
t0 = time.perf_counter()
|
|
try:
|
|
async with session.post(url, json=_BODY, auth=auth) as resp:
|
|
await resp.read()
|
|
ms = (time.perf_counter() - t0) * 1000
|
|
if resp.status == 200:
|
|
return ms, None
|
|
return ms, f"HTTP {resp.status}"
|
|
except Exception as e:
|
|
return (time.perf_counter() - t0) * 1000, type(e).__name__
|
|
|
|
|
|
async def _run_once(
|
|
url: str,
|
|
n: int,
|
|
max_concurrent: int,
|
|
progress: Progress,
|
|
task_id: TaskID,
|
|
auth: aiohttp.BasicAuth | None = None,
|
|
) -> RunResult:
|
|
sem = asyncio.Semaphore(max_concurrent)
|
|
connector = aiohttp.TCPConnector(
|
|
limit=max(max_concurrent * 2, 200),
|
|
limit_per_host=max(max_concurrent, 200),
|
|
force_close=False,
|
|
enable_cleanup_closed=True,
|
|
)
|
|
result = RunResult()
|
|
total_time = 0.0
|
|
max_time = 0.0
|
|
|
|
async with aiohttp.ClientSession(connector=connector) as session:
|
|
t0 = time.perf_counter()
|
|
for coro in asyncio.as_completed([_send(session, url, sem, auth) for _ in range(n)]):
|
|
ms, error = await coro
|
|
if error:
|
|
result.failures[error] = result.failures.get(error, 0) + 1
|
|
else:
|
|
result.latencies_ms.append(ms)
|
|
total_time += ms
|
|
if ms > max_time:
|
|
max_time = ms
|
|
|
|
n_ok = result.n_success
|
|
n_fail = result.n_failures
|
|
mean = total_time / n_ok if n_ok else 0.0
|
|
fail_part = f"[red]✗{n_fail}[/red] " if n_fail else ""
|
|
live = f"{fail_part}✓{n_ok} mean={mean:.0f}ms max={max_time:.0f}ms"
|
|
progress.update(task_id, advance=1, live=live)
|
|
|
|
result.wall_time = time.perf_counter() - t0
|
|
return result
|
|
|
|
|
|
async def _warmup(
|
|
url: str, n: int, max_concurrent: int, auth: aiohttp.BasicAuth | None = None
|
|
) -> None:
|
|
sem = asyncio.Semaphore(max_concurrent)
|
|
connector = aiohttp.TCPConnector(limit=max(max_concurrent * 2, 200))
|
|
async with aiohttp.ClientSession(connector=connector) as session:
|
|
await asyncio.gather(*[_send(session, url, sem, auth) for _ in range(n)])
|
|
|
|
|
|
def run_benchmark(
|
|
url: str,
|
|
n_requests: int = 2000,
|
|
max_concurrent: int = 50,
|
|
runs: int = 3,
|
|
auth: aiohttp.BasicAuth | None = None,
|
|
) -> list[RunResult]:
|
|
warmup_n = min(max(50, max_concurrent), n_requests)
|
|
console.print(f" [dim]Warming up ({warmup_n} requests)...[/dim]")
|
|
asyncio.run(_warmup(url, warmup_n, max_concurrent, auth))
|
|
|
|
results = []
|
|
with Progress(
|
|
SpinnerColumn(),
|
|
TextColumn("[progress.description]{task.description}"),
|
|
BarColumn(),
|
|
MofNCompleteColumn(),
|
|
TimeElapsedColumn(),
|
|
TextColumn(" {task.fields[live]}"),
|
|
console=console,
|
|
) as progress:
|
|
for i in range(runs):
|
|
task_id = progress.add_task(f" Run {i + 1}/{runs}", total=n_requests, live="")
|
|
results.append(
|
|
asyncio.run(_run_once(url, n_requests, max_concurrent, progress, task_id, auth))
|
|
)
|
|
return results
|
|
|
|
|
|
def results_to_dict(results: list[RunResult]) -> dict[str, Any]:
|
|
runs = [
|
|
{
|
|
"n_success": r.n_success,
|
|
"n_failures": r.n_failures,
|
|
"failures": r.failures,
|
|
"wall_time_s": r.wall_time,
|
|
"mean_ms": statistics.mean(r.latencies_ms) if r.latencies_ms else 0.0,
|
|
"p50_ms": r.percentile(50),
|
|
"p95_ms": r.percentile(95),
|
|
"p99_ms": r.percentile(99),
|
|
"max_ms": max(r.latencies_ms) if r.latencies_ms else 0.0,
|
|
"rps": r.throughput,
|
|
}
|
|
for r in results
|
|
]
|
|
summary: dict[str, Any] = (
|
|
{
|
|
"avg_mean_ms": statistics.mean(
|
|
statistics.mean(r.latencies_ms) if r.latencies_ms else 0.0 for r in results
|
|
),
|
|
"avg_p50_ms": statistics.mean(r.percentile(50) for r in results),
|
|
"avg_p99_ms": statistics.mean(r.percentile(99) for r in results),
|
|
"avg_rps": statistics.mean(r.throughput for r in results),
|
|
}
|
|
if results
|
|
else {}
|
|
)
|
|
return {"runs": runs, "summary": summary}
|
|
|
|
|
|
def print_results(results: list[RunResult]) -> None:
|
|
table = Table(show_header=True, header_style="bold cyan", box=None, padding=(0, 2))
|
|
table.add_column("Run", style="dim", width=5)
|
|
table.add_column("Mean ms", justify="right")
|
|
table.add_column("P50 ms", justify="right")
|
|
table.add_column("P95 ms", justify="right")
|
|
table.add_column("P99 ms", justify="right")
|
|
table.add_column("Max ms", justify="right")
|
|
table.add_column("Req/s", justify="right")
|
|
table.add_column("Failures", justify="right")
|
|
|
|
means = []
|
|
p50s = []
|
|
p95s = []
|
|
p99s = []
|
|
maxes = []
|
|
throughputs = []
|
|
for i, r in enumerate(results):
|
|
mean = statistics.mean(r.latencies_ms) if r.latencies_ms else 0.0
|
|
p50 = r.percentile(50)
|
|
p95 = r.percentile(95)
|
|
p99 = r.percentile(99)
|
|
mx = max(r.latencies_ms) if r.latencies_ms else 0.0
|
|
means.append(mean)
|
|
p50s.append(p50)
|
|
p95s.append(p95)
|
|
p99s.append(p99)
|
|
maxes.append(mx)
|
|
throughputs.append(r.throughput)
|
|
fail_str = f"[red]{r.n_failures}[/red]" if r.n_failures else "0"
|
|
table.add_row(
|
|
str(i + 1),
|
|
f"{mean:.1f}",
|
|
f"{p50:.1f}",
|
|
f"{p95:.1f}",
|
|
f"{p99:.1f}",
|
|
f"{mx:.1f}",
|
|
f"{r.throughput:.0f}",
|
|
fail_str,
|
|
)
|
|
|
|
if len(results) > 1:
|
|
table.add_section()
|
|
table.add_row(
|
|
"[bold]avg[/bold]",
|
|
f"[bold]{statistics.mean(means):.1f}[/bold]",
|
|
f"[bold]{statistics.mean(p50s):.1f}[/bold]",
|
|
f"[bold]{statistics.mean(p95s):.1f}[/bold]",
|
|
f"[bold]{statistics.mean(p99s):.1f}[/bold]",
|
|
f"[bold]{statistics.mean(maxes):.1f}[/bold]",
|
|
f"[bold]{statistics.mean(throughputs):.0f}[/bold]",
|
|
"",
|
|
)
|
|
|
|
console.print()
|
|
console.print(table)
|
|
|
|
combined: dict[str, int] = {}
|
|
for r in results:
|
|
for k, v in r.failures.items():
|
|
combined[k] = combined.get(k, 0) + v
|
|
if combined:
|
|
console.print()
|
|
console.print("[red]Failure breakdown:[/red]")
|
|
for reason, count in sorted(combined.items(), key=lambda x: -x[1]):
|
|
console.print(f" {reason}: {count}")
|
|
|
|
|
|
def check_thresholds(
|
|
results: list[RunResult],
|
|
min_rps: float | None = None,
|
|
max_p50_ms: float | None = None,
|
|
max_p99_ms: float | None = None,
|
|
) -> bool:
|
|
"""Check results against performance thresholds. Returns True if all pass."""
|
|
avg_rps = statistics.mean(r.throughput for r in results)
|
|
avg_p50 = statistics.mean(r.percentile(50) for r in results)
|
|
avg_p99 = statistics.mean(r.percentile(99) for r in results)
|
|
passed = True
|
|
|
|
if min_rps is not None and avg_rps < min_rps:
|
|
console.print(
|
|
f"\n[red]THRESHOLD FAILED:[/red] avg throughput {avg_rps:.0f} req/s"
|
|
f" < minimum {min_rps:.0f} req/s"
|
|
)
|
|
passed = False
|
|
|
|
if max_p50_ms is not None and avg_p50 > max_p50_ms:
|
|
console.print(
|
|
f"\n[red]THRESHOLD FAILED:[/red] avg P50 {avg_p50:.1f} ms > maximum {max_p50_ms:.1f} ms"
|
|
)
|
|
passed = False
|
|
|
|
if max_p99_ms is not None and avg_p99 > max_p99_ms:
|
|
console.print(
|
|
f"\n[red]THRESHOLD FAILED:[/red] avg P99 {avg_p99:.1f} ms > maximum {max_p99_ms:.1f} ms"
|
|
)
|
|
passed = False
|
|
|
|
if passed and (min_rps is not None or max_p50_ms is not None or max_p99_ms is not None):
|
|
console.print("\n[green]All thresholds passed.[/green]")
|
|
|
|
return passed
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Async HTTP benchmark client for MLflow Gateway")
|
|
parser.add_argument("--url", required=True, help="Gateway invocation URL")
|
|
parser.add_argument("--requests", type=int, default=2000)
|
|
parser.add_argument("--max-concurrent", type=int, default=50)
|
|
parser.add_argument("--runs", type=int, default=3)
|
|
parser.add_argument(
|
|
"--min-rps",
|
|
type=float,
|
|
default=None,
|
|
metavar="N",
|
|
help="Fail (exit 1) if average throughput falls below N req/s",
|
|
)
|
|
parser.add_argument(
|
|
"--max-p50-ms",
|
|
type=float,
|
|
default=None,
|
|
metavar="N",
|
|
help="Fail (exit 1) if average P50 latency exceeds N ms",
|
|
)
|
|
parser.add_argument(
|
|
"--max-p99-ms",
|
|
type=float,
|
|
default=None,
|
|
metavar="N",
|
|
help="Fail (exit 1) if average P99 latency exceeds N ms",
|
|
)
|
|
parser.add_argument(
|
|
"--auth-username",
|
|
default=None,
|
|
help="Basic auth username. If set together with --auth-password, sent on every request.",
|
|
)
|
|
parser.add_argument(
|
|
"--auth-password",
|
|
default=None,
|
|
help="Basic auth password. If set together with --auth-username, sent on every request.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
auth = (
|
|
aiohttp.BasicAuth(args.auth_username, args.auth_password)
|
|
if args.auth_username and args.auth_password
|
|
else None
|
|
)
|
|
|
|
console.print(f"\n[bold]Benchmarking[/bold] {args.url}")
|
|
console.print(
|
|
f" {args.requests} requests · {args.max_concurrent} concurrent · {args.runs} runs\n"
|
|
)
|
|
results = run_benchmark(args.url, args.requests, args.max_concurrent, args.runs, auth)
|
|
print_results(results)
|
|
|
|
if not check_thresholds(
|
|
results, min_rps=args.min_rps, max_p50_ms=args.max_p50_ms, max_p99_ms=args.max_p99_ms
|
|
):
|
|
raise SystemExit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|