#!/usr/bin/env python3 """bench-conversation.py — what an endpoint actually feels like, in tokens/s. The other harnesses here answer engine questions (which batch, which proposer, which cache). This one answers the user question: give the same ten jobs to an OpenAI-compatible endpoint and report the rate it answers them at. Every job is a real conversation turn — system prompt plus one user turn — because that is the shape the fleet serves, and it is measured by STREAMING so prefill and decode are separated: ttft seconds to the first content delta -> prefill + queue decode tok/s completion_tokens / (total - ttft) -> the rate you read at Both arms of a comparison must run the SAME prompt bytes, so a per-run salt rides in the system prompt: it changes the prompt itself, which is llama.cpp's prefix-cache key and vLLM's APC key, so no arm silently inherits a warm cache from the one before it. Run one arm, restart the engine, run the next with a new salt. Answers are generated at temperature 0 so arms serving the SAME model can be compared for identity as well as speed; different runtimes serving different quants legitimately answer differently, which is why the first line of every answer is printed. Usage: bench-conversation.py [--model NAME] [--salt TAG] [--max-tokens N] [--burst N] [--out file.json] bench-conversation.py http://127.0.0.1:7770 --model frank-medium --salt L1 bench-conversation.py http://127.0.0.1:7770 --model frank-medium --salt R2 --burst 4 One engine at a time: on a single GPU pair there is no room for two copies of a 27B model, and the benchmarking policy requires the production service to be stopped while anything measures. """ import argparse import json import statistics import sys import threading import time import urllib.error import urllib.request # Ten jobs that cover what this fleet is actually asked to do. Ordered roughly # by prefill size so a runtime that is good at short prompts and one that is # good at long ones cannot hide behind an average. SYSTEM = ( "You are a staff engineer on a homelab fleet. Be concise and concrete. " "Salt: {salt}" ) BATTERY = [ ("greet", 128, "Say hello in one sentence."), ("shell", 192, "One bash command: find every file under /home modified in " "the last 2 days, sorted by size, largest first."), ("math", 256, "A train covers 180 km in 1h40m, then 240 km in 2h20m. " "What is the average speed in km/h? Show the arithmetic."), ("json", 384, "Emit exactly 12 JSON records, one per line, no prose, each " 'with keys "host", "service", "port" and "up" for hosts dankclaw, ' "dankserver and dankware running llama-server, caddy and grafana."), ("review", 512, "Review this function for bugs and name the failure mode:\n" "def rotate(cfg, n):\n for k in cfg:\n cfg[k] = cfg[k][n:] + cfg[k][:n]\n" " return cfg\n"), ("explain", 448, "Explain in four sentences why PCIe atomic operation routing " "matters for GPU collectives, and what fails when a bridge reports " "AtomicOpsCap: Routing-."), ("translate", 256, "Translate to German, then to Japanese, one line each: " "The load balancer refused the connection because the port was still held."), ("plan", 640, "Give a 6-step runbook for swapping the model behind a live " "LLM endpoint on a host that serves real users, with a rollback at each step."), ("debug", 512, "A systemd user service restarts every 10 seconds and its log " "ends with 'Error starting server failed to bind udp listener on 10.89.1.1:53: " "Cannot assign requested address'. What is the root cause and the repair?"), ("longdoc", 512, None), # filled from LONG_FILES at runtime ] # The long-context job reads real repo files so prefill is large and identical # across arms (~30k characters). LONG_FILES = [ "/home/dank/serverAppConfigs/AGENTS.md", "/home/dank/serverAppConfigs/serverAppConfigs-docs/LLM/" "frank-medium-llamacpp-dflash2.md", ] LONG_CHARS = 30_000 def long_prompt() -> str: parts = [] for path in LONG_FILES: try: with open(path, encoding="utf-8", errors="replace") as handle: parts.append(handle.read()) except OSError: continue text = "\n\n".join(parts)[:LONG_CHARS] return ("Using only this document, answer: which deployment shapes for " "two GPUs are ruled out by missing PCIe atomics, and what is the " "one software path that still lets both cards serve one model? " "Cite the measured numbers.\n\n---\n" + text) def stream_chat(base: str, model: str, messages, max_tokens: int, timeout: int): """One streamed chat completion -> (ttft, total, usage, delta_count, text). usage is the engine's own {prompt_tokens, completion_tokens}, asked for with stream_options.include_usage. There is deliberately NO cheap non-streamed pre-probe to learn the prompt length: that probe would populate the prefix cache and turn the measured run's prefill into a cache hit, which is exactly the difference between runtimes this harness is trying to see. ttft is None when the engine produced no content delta. """ body = { "model": model, "messages": messages, "max_tokens": max_tokens, "temperature": 0, "stream": True, "stream_options": {"include_usage": True}, } request = urllib.request.Request( base + "/v1/chat/completions", data=json.dumps(body).encode(), headers={"Content-Type": "application/json"}, ) start = time.perf_counter() ttft = None deltas = 0 usage = {} text = [] with urllib.request.urlopen(request, timeout=timeout) as response: for raw in response: line = raw.decode("utf-8", "replace").strip() if not line.startswith("data:"): continue payload = line[5:].strip() if payload == "[DONE]": break try: chunk = json.loads(payload) except json.JSONDecodeError: continue if chunk.get("usage"): usage.update(chunk["usage"]) for choice in chunk.get("choices", []): piece = choice.get("delta") or {} # llama.cpp streams thinking as reasoning_content; vLLM's # reasoning parser streams it as reasoning. Either one is a # generated token, so TTFT must be the first token of ANY kind — # counting only `content` made a thinking-heavy arm look like it # answered instantly (measured 2026-09-20: radiance's geomean # read 994 tok/s with 6 of 10 jobs recorded as "(empty)"). content = next((piece[k] for k in ("content", "reasoning", "reasoning_content") if piece.get(k)), "") if content: if ttft is None: ttft = time.perf_counter() - start deltas += 1 text.append(content) total = time.perf_counter() - start return ttft, total, usage, deltas, "".join(text) def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("base") ap.add_argument("--model", default="frank-medium") ap.add_argument("--salt", default=f"C{int(time.time())}") ap.add_argument("--max-tokens", type=int, default=None) ap.add_argument("--burst", type=int, default=0, help="also run N concurrent copies of the plan job") ap.add_argument("--timeout", type=int, default=1800) ap.add_argument("--out", default=None) args = ap.parse_args() base = args.base.rstrip("/") rows = [] print(f"# battery salt={args.salt} model={args.model} base={base}\n") print(f"{'job':<10} {'ptok':>7} {'ctok':>6} {'ttft_s':>8} {'dec_tps':>8} " f"{'total_s':>8} answer") for name, cap, prompt in BATTERY: if prompt is None: prompt = long_prompt() messages = [ {"role": "system", "content": SYSTEM.format(salt=args.salt)}, {"role": "user", "content": prompt}, ] limit = args.max_tokens or cap try: ttft, total, usage, deltas, text = stream_chat( base, args.model, messages, limit, args.timeout) except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as exc: print(f"{name:<10} FAILED {exc}") rows.append({"job": name, "error": str(exc)}) continue ptok = usage.get("prompt_tokens") ctok = usage.get("completion_tokens") or deltas decode = None if ttft is not None and ctok and total > ttft: decode = (ctok - 1) / (total - ttft) prefill = (ptok / ttft) if (ptok and ttft) else None first = text.strip().splitlines()[0][:48] if text.strip() else "(empty)" print(f"{name:<10} {ptok or 0:>7} {ctok:>6} " f"{(ttft or 0):>8.2f} {(decode or 0):>8.1f} {total:>8.2f} {first}") rows.append({"job": name, "prompt_tokens": ptok, "completion_tokens": ctok, "ttft_s": ttft, "decode_tps": decode, "prefill_tps": prefill, "total_s": total, "answer_head": first}) good = [r for r in rows if r.get("decode_tps")] if good: rates = [r["decode_tps"] for r in good] ttfts = [r["ttft_s"] for r in good] pre = [r["prefill_tps"] for r in good if r.get("prefill_tps")] print(f"\ngeomean decode {statistics.geometric_mean(rates):8.1f} tok/s" f" (min {min(rates):.1f} / max {max(rates):.1f})") print(f"mean ttft {statistics.mean(ttfts):8.2f} s") if pre: print(f"geomean prefill {statistics.geometric_mean(pre):8.1f} tok/s") if args.burst > 1: prompt = next(p for n, _, p in BATTERY if n == "plan") messages = [ {"role": "system", "content": SYSTEM.format(salt=args.salt + "B")}, {"role": "user", "content": prompt}, ] results = [] lock = threading.Lock() def worker(): try: ttft, total, usage, deltas, _ = stream_chat( base, args.model, messages, 640, args.timeout) ctok = usage.get("completion_tokens") or deltas with lock: results.append((ttft, total, ctok)) except Exception as exc: # noqa: BLE001 - report, do not abort the burst with lock: results.append((None, 0.0, str(exc))) threads = [threading.Thread(target=worker) for _ in range(args.burst)] t0 = time.perf_counter() for thread in threads: thread.start() for thread in threads: thread.join() wall = time.perf_counter() - t0 ok = [r for r in results if r[0] is not None] if ok: tokens = sum(r[2] for r in ok if isinstance(r[2], int)) print(f"burst {args.burst}x plan: wall {wall:.1f}s aggregate " f"{tokens / wall:.1f} tok/s " f"per-stream {statistics.mean([r[2] / r[1] for r in ok]):.1f} tok/s" f" mean ttft {statistics.mean([r[0] for r in ok]):.2f}s") else: print(f"burst {args.burst}x plan: all failed: {results[0][2]}") if args.out: with open(args.out, "w", encoding="utf-8") as handle: json.dump({"salt": args.salt, "model": args.model, "base": base, "rows": rows, "burst": args.burst}, handle, indent=1) print(f"wrote {args.out}") return 0 if good else 1 if __name__ == "__main__": sys.exit(main())