#!/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
# ---------------------------------------------------------------------------

import uuid
def make_prompt(token_len: int) -> str:
    prefix = str(uuid.uuid4())

    words = [
        "the", "quick", "brown", "fox",
        "jumps", "over", "lazy", "dog",
        "benchmark", "performance", "latency"
    ]

    tokens = [prefix]

    while len(tokens) < token_len:
        tokens.extend(words)

    return " ".join(tokens[:token_len])


# ---------------------------------------------------------------------------
# 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
                    )
            print("Starting warmup...")
            warmup_per_service = 10
            warmup_requests = warmup_per_service * len(ports)

            warmup_prompts = [
                make_prompt(input_len)
                for _ in range(warmup_requests)
            ]

            warmup_tasks = [
                send_request_with_retry(
                    session=session,
                    host=host,
                    lb=lb,
                    model=model,
                    request_id=-1,
                    prompt=p,
                    max_tokens=output_len,
                    temperature=temperature,
                )
                for p in warmup_prompts
            ]

            await asyncio.gather(*warmup_tasks)

            print("Warmup finished.")

            # 给 CUDA / Scheduler 一点时间稳定
            await asyncio.sleep(2)
            
            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()
