Quantong Qiu 4d5b6a77ca Add custom benchmark setup for vLLM TP=8 on dsv4 H200
- Create configuration file for the custom benchmark experiment.
- Implement result parsing script to handle JSONL outputs from the benchmark.
- Develop run script to orchestrate the benchmark execution, including server management and health checks.
- Add server start script to launch a single vLLM service on all available GPUs.
2026-07-10 09:40:10 +00:00

618 lines
23 KiB
Python
Executable File

#!/usr/bin/env python3
"""Custom benchmark client for vLLM / OpenAI-compatible API with multi-service load balancing and health-check failover.
Sends concurrent requests to multiple inference servers and records per-request
latency metrics (TTFT, TPOT, ITL, E2E) with fine-grained timing.
Designed for TP=2 on 8-GPU setups where 4 independent services run on
(0,1), (2,3), (4,5), (6,7). Requests are distributed across services via
round-robin or random load balancing. Unhealthy services are automatically
skipped with periodic retry.
Usage:
# Single service (backward compatible)
python3 bench_client.py \
--host 127.0.0.1 --port 30005 \
--model deepseek-v4-flash \
--concurrency 64 --input-len 512 --output-len 256 --num-prompts 320 \
--output-file results.jsonl
# Multi-service (4 services on ports 30005-30008)
python3 bench_client.py \
--host 127.0.0.1 \
--ports 30005,30006,30007,30008 \
--model deepseek-v4-flash \
--concurrency 64 --input-len 512 --output-len 256 --num-prompts 320 \
--lb-strategy round_robin \
--output-file results.jsonl
The output JSONL contains one object per request with raw timing data.
A final summary line ("completed" field) aggregates all requests.
"""
import argparse
import asyncio
import json
import random
import sys
import time
import urllib.request
from dataclasses import asdict, dataclass, field
from typing import Any
# Try aiohttp first; fall back to urllib for stdlib-only environments.
try:
import aiohttp
HAS_AIOHTTP = True
except ImportError:
HAS_AIOHTTP = False
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclass
class RequestResult:
request_id: int
input_tokens: int = 0
output_tokens: int = 0
first_token_time_ms: float = 0.0 # TTFT
e2e_latency_ms: float = 0.0 # total wall time
inter_token_latencies: list[float] = field(default_factory=list)
success: bool = False
error: str = ""
target_port: int = 0 # which service handled this request
retry_count: int = 0 # how many times this request was retried
@property
def tpot_ms(self) -> float:
"""Mean TPOT = (e2e - ttft) / (output_tokens - 1) if >1 token."""
if self.output_tokens <= 1:
return 0.0
return (self.e2e_latency_ms - self.first_token_time_ms) / (self.output_tokens - 1)
@property
def mean_itl_ms(self) -> float:
if not self.inter_token_latencies:
return 0.0
return sum(self.inter_token_latencies) / len(self.inter_token_latencies)
def to_dict(self) -> dict[str, Any]:
d = asdict(self)
d["tpot_ms"] = self.tpot_ms
d["mean_itl_ms"] = self.mean_itl_ms
return d
# ---------------------------------------------------------------------------
# Health checker
# ---------------------------------------------------------------------------
class HealthChecker:
"""Periodically check service health and maintain a healthy-port list."""
def __init__(
self,
host: str,
ports: list[int],
check_interval: float = 5.0,
max_failures: int = 2,
recovery_interval: float = 10.0,
):
self.host = host
self.all_ports = ports
self.check_interval = check_interval
self.max_failures = max_failures
self.recovery_interval = recovery_interval
# port -> consecutive failure count
self._failures: dict[int, int] = {p: 0 for p in ports}
# port -> last healthy timestamp
self._last_healthy: dict[int, float] = {p: time.monotonic() for p in ports}
self._lock = asyncio.Lock()
self._task: asyncio.Task | None = None
async def _check_one(self, port: int, session: aiohttp.ClientSession | None) -> bool:
url = f"http://{self.host}:{port}/health"
try:
if HAS_AIOHTTP and session is not None:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
return resp.status == 200
else:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status == 200
except Exception:
return False
async def _check_all(self, session: aiohttp.ClientSession | None) -> None:
checks = [self._check_one(p, session) for p in self.all_ports]
results = await asyncio.gather(*checks, return_exceptions=True)
now = time.monotonic()
async with self._lock:
for port, ok in zip(self.all_ports, results):
if isinstance(ok, Exception):
ok = False
if ok:
self._failures[port] = 0
self._last_healthy[port] = now
else:
self._failures[port] += 1
async def start(self, session: aiohttp.ClientSession | None) -> None:
async def _loop() -> None:
while True:
await asyncio.sleep(self.check_interval)
await self._check_all(session)
self._task = asyncio.create_task(_loop())
async def stop(self) -> None:
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
@property
async def healthy_ports(self) -> list[int]:
now = time.monotonic()
async with self._lock:
healthy = []
for p in self.all_ports:
if self._failures[p] < self.max_failures:
healthy.append(p)
elif now - self._last_healthy[p] > self.recovery_interval:
# Give it another chance after recovery_interval.
self._failures[p] = max(0, self._failures[p] - 1)
healthy.append(p)
return healthy
async def is_healthy(self, port: int) -> bool:
ports = await self.healthy_ports
return port in ports
# ---------------------------------------------------------------------------
# Load balancer with failover
# ---------------------------------------------------------------------------
class LoadBalancer:
"""Distribute requests across healthy backend ports with auto-failover."""
def __init__(
self,
ports: list[int],
health_checker: HealthChecker,
strategy: str = "round_robin",
):
self.all_ports = ports
self.health_checker = health_checker
self.strategy = strategy
self._idx = 0
self._lock = asyncio.Lock()
def _next_round_robin(self, healthy: list[int]) -> int:
if not healthy:
return self.all_ports[self._idx % len(self.all_ports)]
for _ in range(len(self.all_ports)):
port = self.all_ports[self._idx % len(self.all_ports)]
self._idx += 1
if port in healthy:
return port
# Fallback: all unhealthy, pick first healthy anyway.
return healthy[0] if healthy else self.all_ports[0]
def _next_random(self, healthy: list[int]) -> int:
if healthy:
return random.choice(healthy)
return random.choice(self.all_ports)
async def next_port(self) -> int:
healthy = await self.health_checker.healthy_ports
async with self._lock:
if self.strategy == "round_robin":
return self._next_round_robin(healthy)
elif self.strategy in ("random", "least_conn"):
return self._next_random(healthy)
else:
return self._next_round_robin(healthy)
@property
def port_count(self) -> int:
return len(self.all_ports)
# ---------------------------------------------------------------------------
# Token helpers
# ---------------------------------------------------------------------------
def make_prompt(token_len: int, vocab_size: int = 32000) -> str:
"""Generate a random-ish prompt of approximately `token_len` tokens."""
words = ["the", "quick", "brown", "fox", "jumps", "over", "lazy",
"dog", "hello", "world", "deep", "seek", "model", "test",
"benchmark", "performance", "latency", "throughput", "token"]
needed = max(token_len, 1)
tokens: list[str] = []
while len(tokens) < needed:
tokens.extend(words)
return " ".join(tokens[:needed])
# ---------------------------------------------------------------------------
# aiohttp-based async request (preferred)
# ---------------------------------------------------------------------------
async def send_request_aiohttp(
session: aiohttp.ClientSession,
host: str,
port: int,
model: str,
request_id: int,
prompt: str,
max_tokens: int,
temperature: float,
) -> RequestResult:
url = f"http://{host}:{port}/v1/chat/completions"
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens,
"temperature": temperature,
"stream": True,
}
headers = {"Content-Type": "application/json"}
result = RequestResult(request_id=request_id, target_port=port)
start_time = time.perf_counter()
first_token_received = False
last_token_time = 0.0
try:
async with session.post(url, json=payload, headers=headers) as resp:
if resp.status != 200:
text = await resp.text()
result.error = f"HTTP {resp.status}: {text[:200]}"
return result
async for line in resp.content:
line = line.decode("utf-8").strip()
if not line or not line.startswith("data: "):
continue
data_str = line[len("data: "):]
if data_str == "[DONE]":
break
try:
chunk = json.loads(data_str)
except json.JSONDecodeError:
continue
choices = chunk.get("choices", [])
if not choices:
continue
delta = choices[0].get("delta", {})
content = delta.get("content", "")
if content:
now = time.perf_counter()
if not first_token_received:
result.first_token_time_ms = (now - start_time) * 1000
first_token_received = True
else:
result.inter_token_latencies.append((now - last_token_time) * 1000)
last_token_time = now
result.output_tokens += 1
result.e2e_latency_ms = (time.perf_counter() - start_time) * 1000
result.input_tokens = len(prompt.split()) # approximate
result.success = True
except asyncio.TimeoutError:
result.error = "timeout"
except Exception as e:
result.error = str(e)[:200]
return result
# ---------------------------------------------------------------------------
# urllib-based synchronous request (fallback, no aiohttp)
# ---------------------------------------------------------------------------
def send_request_urllib(
host: str,
port: int,
model: str,
request_id: int,
prompt: str,
max_tokens: int,
temperature: float,
) -> RequestResult:
"""Blocking fallback using stdlib urllib. Used when aiohttp is absent."""
url = f"http://{host}:{port}/v1/chat/completions"
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens,
"temperature": temperature,
"stream": False,
}
data = json.dumps(payload).encode("utf-8")
headers = {"Content-Type": "application/json", "Content-Length": str(len(data))}
result = RequestResult(request_id=request_id, target_port=port)
start_time = time.perf_counter()
try:
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=600) as resp:
body = json.loads(resp.read().decode("utf-8"))
choices = body.get("choices", [])
if not choices:
result.error = "no choices in response"
return result
content = choices[0].get("message", {}).get("content", "")
result.output_tokens = len(content.split()) if content else 0
result.e2e_latency_ms = (time.perf_counter() - start_time) * 1000
result.first_token_time_ms = result.e2e_latency_ms
result.input_tokens = len(prompt.split())
result.success = True
except Exception as e:
result.error = str(e)[:200]
return result
# ---------------------------------------------------------------------------
# Unified send_request wrapper with retry & failover
# ---------------------------------------------------------------------------
async def send_request_with_retry(
session: aiohttp.ClientSession | None,
host: str,
lb: LoadBalancer,
model: str,
request_id: int,
prompt: str,
max_tokens: int,
temperature: float,
max_retries: int = 2,
) -> RequestResult:
"""Send a request, retrying on failure with a different port each time."""
result = RequestResult(request_id=request_id)
for attempt in range(max_retries + 1):
port = await lb.next_port()
if HAS_AIOHTTP and session is not None:
result = await send_request_aiohttp(
session, host, port, model, request_id, prompt, max_tokens, temperature
)
else:
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None, send_request_urllib,
host, port, model, request_id, prompt, max_tokens, temperature
)
result.retry_count = attempt
if result.success:
return result
# If failed, try another port on next iteration (unless last attempt).
if attempt < max_retries:
await asyncio.sleep(0.5 * (attempt + 1)) # exponential backoff-ish
return result
# ---------------------------------------------------------------------------
# Benchmark orchestration
# ---------------------------------------------------------------------------
async def run_benchmark(
host: str,
ports: list[int],
lb: LoadBalancer,
health_checker: HealthChecker,
model: str,
concurrency: int,
input_len: int,
output_len: int,
num_prompts: int,
temperature: float,
output_file: str,
) -> dict[str, Any]:
"""Run the benchmark and write a JSONL file."""
prompts = [make_prompt(input_len) for _ in range(num_prompts)]
results: list[RequestResult] = []
semaphore = asyncio.Semaphore(concurrency)
start = time.perf_counter()
if HAS_AIOHTTP:
connector = aiohttp.TCPConnector(limit=concurrency * 2)
timeout = aiohttp.ClientTimeout(total=600, sock_read=300)
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
await health_checker.start(session)
async def bounded_send(idx: int, prompt: str) -> RequestResult:
async with semaphore:
return await send_request_with_retry(
session, host, lb, model, idx, prompt, output_len, temperature
)
tasks = [bounded_send(i, p) for i, p in enumerate(prompts)]
results = await asyncio.gather(*tasks)
await health_checker.stop()
else:
await health_checker.start(None)
from concurrent.futures import ThreadPoolExecutor
async def bounded_send(idx: int, prompt: str) -> RequestResult:
async with semaphore:
return await send_request_with_retry(
None, host, lb, model, idx, prompt, output_len, temperature
)
tasks = [bounded_send(i, p) for i, p in enumerate(prompts)]
results = await asyncio.gather(*tasks)
await health_checker.stop()
duration = time.perf_counter() - start
# Write JSONL
with open(output_file, "w", encoding="utf-8") as f:
for r in results:
f.write(json.dumps(r.to_dict(), ensure_ascii=False) + "\n")
# Compute summary
successful = [r for r in results if r.success]
failed = len(results) - len(successful)
retried = [r for r in results if r.retry_count > 0]
def percentile(values: list[float], p: float) -> float:
if not values:
return 0.0
s = sorted(values)
k = (len(s) - 1) * p / 100.0
f = int(k)
c = min(f + 1, len(s) - 1)
return s[f] + (k - f) * (s[c] - s[f])
ttfts = [r.first_token_time_ms for r in successful]
tpots = [r.tpot_ms for r in successful if r.output_tokens > 1]
e2es = [r.e2e_latency_ms for r in successful]
itls = [r.mean_itl_ms for r in successful if r.inter_token_latencies]
total_input_tokens = sum(r.input_tokens for r in successful)
total_output_tokens = sum(r.output_tokens for r in successful)
# Per-port breakdown
per_port_stats: dict[int, dict[str, Any]] = {}
for port in ports:
port_results = [r for r in successful if r.target_port == port]
port_failed = [r for r in results if r.target_port == port and not r.success]
if port_results or port_failed:
port_ttfts = [r.first_token_time_ms for r in port_results]
port_e2es = [r.e2e_latency_ms for r in port_results]
per_port_stats[port] = {
"count": len(port_results),
"failed": len(port_failed),
"mean_ttft_ms": sum(port_ttfts) / len(port_ttfts) if port_ttfts else 0.0,
"p95_ttft_ms": percentile(port_ttfts, 95) if port_ttfts else 0.0,
"mean_e2e_ms": sum(port_e2es) / len(port_e2es) if port_e2es else 0.0,
}
# Health-check stats
healthy_at_end = await health_checker.healthy_ports
summary = {
"completed": len(successful),
"failed": failed,
"retried": len(retried),
"duration": duration,
"request_throughput": len(successful) / duration if duration > 0 else 0.0,
"input_throughput": total_input_tokens / duration if duration > 0 else 0.0,
"output_throughput": total_output_tokens / duration if duration > 0 else 0.0,
"total_throughput": (total_input_tokens + total_output_tokens) / duration if duration > 0 else 0.0,
"total_input_tokens": total_input_tokens,
"total_output_tokens": total_output_tokens,
"mean_ttft_ms": sum(ttfts) / len(ttfts) if ttfts else 0.0,
"median_ttft_ms": percentile(ttfts, 50),
"p90_ttft_ms": percentile(ttfts, 90),
"p95_ttft_ms": percentile(ttfts, 95),
"p99_ttft_ms": percentile(ttfts, 99),
"mean_tpot_ms": sum(tpots) / len(tpots) if tpots else 0.0,
"median_tpot_ms": percentile(tpots, 50),
"p90_tpot_ms": percentile(tpots, 90),
"p95_tpot_ms": percentile(tpots, 95),
"p99_tpot_ms": percentile(tpots, 99),
"mean_e2e_latency_ms": sum(e2es) / len(e2es) if e2es else 0.0,
"median_e2e_latency_ms": percentile(e2es, 50),
"p90_e2e_latency_ms": percentile(e2es, 90),
"p95_e2e_latency_ms": percentile(e2es, 95),
"p99_e2e_latency_ms": percentile(e2es, 99),
"mean_itl_ms": sum(itls) / len(itls) if itls else 0.0,
"median_itl_ms": percentile(itls, 50),
"p90_itl_ms": percentile(itls, 90),
"p95_itl_ms": percentile(itls, 95),
"p99_itl_ms": percentile(itls, 99),
"per_port_stats": per_port_stats,
"healthy_ports_at_end": healthy_at_end,
"lb_strategy": lb.strategy,
"num_services": lb.port_count,
}
# Append summary as the last line
with open(output_file, "a", encoding="utf-8") as f:
f.write(json.dumps(summary, ensure_ascii=False) + "\n")
return summary
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description="Custom vLLM benchmark client with multi-service LB and health-check failover")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=30005,
help="Single service port (backward compatible)")
parser.add_argument("--ports", default="",
help="Comma-separated list of ports for multi-service, e.g. 30005,30006,30007,30008")
parser.add_argument("--model", default="deepseek-v4-flash")
parser.add_argument("--concurrency", type=int, default=1)
parser.add_argument("--input-len", type=int, default=512)
parser.add_argument("--output-len", type=int, default=256)
parser.add_argument("--num-prompts", type=int, default=10)
parser.add_argument("--temperature", type=float, default=0.0)
parser.add_argument("--lb-strategy", default="round_robin",
choices=["round_robin", "random", "least_conn"],
help="Load balancing strategy across services")
parser.add_argument("--health-check-interval", type=float, default=5.0,
help="Seconds between health checks (default: 5)")
parser.add_argument("--health-max-failures", type=int, default=2,
help="Consecutive failures before marking a port unhealthy (default: 2)")
parser.add_argument("--health-recovery-interval", type=float, default=10.0,
help="Seconds before retrying an unhealthy port (default: 10)")
parser.add_argument("--request-max-retries", type=int, default=2,
help="Max retries per request on failure (default: 2)")
parser.add_argument("--output-file", required=True)
args = parser.parse_args()
# Determine ports: --ports takes precedence, else fall back to --port.
if args.ports:
ports = [int(p.strip()) for p in args.ports.split(",")]
else:
ports = [args.port]
health_checker = HealthChecker(
host=args.host,
ports=ports,
check_interval=args.health_check_interval,
max_failures=args.health_max_failures,
recovery_interval=args.health_recovery_interval,
)
lb = LoadBalancer(ports, health_checker=health_checker, strategy=args.lb_strategy)
summary = asyncio.run(run_benchmark(
host=args.host,
ports=ports,
lb=lb,
health_checker=health_checker,
model=args.model,
concurrency=args.concurrency,
input_len=args.input_len,
output_len=args.output_len,
num_prompts=args.num_prompts,
temperature=args.temperature,
output_file=args.output_file,
))
print(json.dumps(summary, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()