#!/usr/bin/env python3
"""Cache-busted CONCURRENT throughput bench against a vLLM OpenAI seat.

Companion to quickbench.py, which is bs=1 only. Single-stream tok/s and
aggregate throughput are different questions: one is latency-bound, the other is
batch-bound, and a seat can win on one while losing the other.

MEASUREMENT TRAPS THIS AVOIDS (all three have produced confident wrong results
here before — see docs/pfi/model-quantization-playbook.md §5):

  1. Prefix caching fakes speed. Every request gets a FRESH UNSEEDED nonce, so
     no prompt is ever repeated. Never seed the cache-buster: a seeded nonce
     regenerates the previous run's prompts verbatim and reads cache-hit
     throughput (~41k tok/s) instead of real prefill (~5k).
  2. MTP acceptance is read from /metrics as a DELTA across the run, not as a
     cumulative total, or a long-lived seat's history swamps the measurement.
  3. Aggregate throughput is computed from the WALL CLOCK of the whole batch,
     not the sum of per-request rates — the latter double-counts overlap and
     reports a number the seat cannot actually deliver.

Usage:
    uv run concbench.py --base http://10.250.50.54:8015 \
        --model qwen3.8-27b-uncensored --concurrency 1 --concurrency 6
"""
import argparse, json, random, statistics, string, sys, time
import urllib.request
from concurrent.futures import ThreadPoolExecutor

TOPICS = [
    "a coral reef ecosystem", "the Roman aqueduct system",
    "how lithium-ion cells degrade", "the history of the printing press",
    "how radar altimeters work", "glacier mass balance",
    "the design of the Saturn V F-1 engine", "how sourdough fermentation works",
    "the metallurgy of Damascus steel", "how noise-cancelling headphones work",
    "the ecology of mangrove forests", "the development of the marine chronometer",
]


def nonce(n=14):
    # Unseeded on purpose. A seeded RNG reproduces the previous run's prompts
    # and turns this into a prefix-cache benchmark.
    return "".join(random.choices(string.ascii_lowercase + string.digits, k=n))


def spec_counters(base):
    """(drafted, accepted) from /metrics, or (None, None) if unavailable."""
    try:
        with urllib.request.urlopen(base + "/metrics", timeout=30) as r:
            text = r.read().decode()
    except Exception:
        return None, None
    d = a = None
    for line in text.splitlines():
        if line.startswith("#"):
            continue
        if line.startswith("vllm:spec_decode_num_draft_tokens_total"):
            d = float(line.rsplit(" ", 1)[1])
        elif line.startswith("vllm:spec_decode_num_accepted_tokens_total"):
            a = float(line.rsplit(" ", 1)[1])
    return d, a


def one(base, model, max_tokens, timeout):
    body = {
        "model": model,
        "messages": [{"role": "user", "content":
                      f"[req-{nonce()}] Explain {random.choice(TOPICS)} in detail."}],
        "max_tokens": max_tokens,
        "temperature": 0.7,
    }
    req = urllib.request.Request(
        base + "/v1/chat/completions", data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json"})
    t0 = time.time()
    with urllib.request.urlopen(req, timeout=timeout) as r:
        d = json.load(r)
    dt = time.time() - t0
    return d["usage"]["completion_tokens"], dt


def run(base, model, conc, reqs, max_tokens, timeout):
    d0, a0 = spec_counters(base)
    t0 = time.time()
    with ThreadPoolExecutor(max_workers=conc) as ex:
        out = list(ex.map(lambda _: one(base, model, max_tokens, timeout), range(reqs)))
    wall = time.time() - t0
    d1, a1 = spec_counters(base)

    toks = sum(t for t, _ in out)
    lat = [dt for _, dt in out]
    acc = None
    if d0 is not None and a0 is not None and d1 is not None and a1 is not None:
        drafted = d1 - d0
        if drafted > 0:
            acc = (a1 - a0) / drafted * 100
    return {
        "concurrency": conc, "requests": reqs, "wall_s": round(wall, 2),
        "completion_tokens": toks,
        "aggregate_tok_s": round(toks / wall, 2),           # wall clock, not sum-of-rates
        "per_stream_tok_s": round(toks / wall / conc, 2),
        "median_latency_s": round(statistics.median(lat), 2),
        "mtp_acceptance_pct": round(acc, 1) if acc is not None else None,
    }


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--base", default="http://10.250.50.54:8015")
    ap.add_argument("--model", default="qwen3.8-27b-uncensored")
    ap.add_argument("--concurrency", type=int, action="append", default=[])
    ap.add_argument("--requests-per-stream", type=int, default=4)
    ap.add_argument("--max-tokens", type=int, default=400)
    ap.add_argument("--timeout", type=int, default=900)
    ap.add_argument("--tag", default="run")
    ap.add_argument("--out", default=None)
    a = ap.parse_args()
    concs = a.concurrency or [1, 6]

    results = []
    for c in concs:
        reqs = c * a.requests_per_stream
        r = run(a.base, a.model, c, reqs, a.max_tokens, a.timeout)
        results.append(r)
        print(f"  conc={c:<3} reqs={reqs:<3} wall={r['wall_s']:>7.2f}s  "
              f"aggregate={r['aggregate_tok_s']:>8.2f} tok/s  "
              f"per-stream={r['per_stream_tok_s']:>7.2f}  "
              f"median-lat={r['median_latency_s']:>6.2f}s  "
              f"MTP={r['mtp_acceptance_pct']}%")

    if a.out:
        json.dump({"tag": a.tag, "model": a.model, "base": a.base,
                   "max_tokens": a.max_tokens, "results": results},
                  open(a.out, "w"), indent=2)
    return 0


if __name__ == "__main__":
    sys.exit(main())
