test(bench): drop the pre-probe from the conversation battery

The warm-up request was a warm prefix-cache hit on the next run, so it
hid exactly what the arms were meant to differ on (prefill).  Token
counts now come from the streamed usage of each job itself, and --salt
defeats cross-arm inheritance.
This commit is contained in:
Frank committed 2026-09-20 17:51:27 -07:00
1 parent b8d1ab42ba
commit bcdd898750
1 file changed
+33 -37
+33 -37
View File
@@ -17,10 +17,10 @@ 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 from the one before it. Run one arm, restart the engine, run the next with a
new salt. new salt.
Answers are generated at temperature 0 so the arms can be compared for Answers are generated at temperature 0 so arms serving the SAME model can be
identity as well as speed. Identity is only meaningful across configurations compared for identity as well as speed; different runtimes serving different
of the SAME model — different runtimes serving different quants legitimately quants legitimately answer differently, which is why the first line of every
answer differently, which is why the first line of every answer is printed. answer is printed.
Usage: Usage:
bench-conversation.py <base-url> [--model NAME] [--salt TAG] bench-conversation.py <base-url> [--model NAME] [--salt TAG]
@@ -36,7 +36,6 @@ stopped while anything measures.
import argparse import argparse
import json import json
import math
import statistics import statistics
import sys import sys
import threading import threading
@@ -78,7 +77,7 @@ BATTERY = [
] ]
# The long-context job reads real repo files so prefill is large and identical # The long-context job reads real repo files so prefill is large and identical
# across arms (~30k characters, roughly 8k tokens). # across arms (~30k characters).
LONG_FILES = [ LONG_FILES = [
"/home/dank/serverAppConfigs/AGENTS.md", "/home/dank/serverAppConfigs/AGENTS.md",
"/home/dank/serverAppConfigs/serverAppConfigs-docs/LLM/" "/home/dank/serverAppConfigs/serverAppConfigs-docs/LLM/"
@@ -103,9 +102,15 @@ def long_prompt() -> str:
def stream_chat(base: str, model: str, messages, max_tokens: int, timeout: int): def stream_chat(base: str, model: str, messages, max_tokens: int, timeout: int):
"""One streamed chat completion -> (ttft, total, completion_tokens, text). """One streamed chat completion -> (ttft, total, usage, delta_count, text).
Returns ttft=None when the engine produced no content delta. 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 = { body = {
"model": model, "model": model,
@@ -123,7 +128,7 @@ def stream_chat(base: str, model: str, messages, max_tokens: int, timeout: int):
start = time.perf_counter() start = time.perf_counter()
ttft = None ttft = None
deltas = 0 deltas = 0
usage_tokens = None usage = {}
text = [] text = []
with urllib.request.urlopen(request, timeout=timeout) as response: with urllib.request.urlopen(request, timeout=timeout) as response:
for raw in response: for raw in response:
@@ -138,35 +143,24 @@ def stream_chat(base: str, model: str, messages, max_tokens: int, timeout: int):
except json.JSONDecodeError: except json.JSONDecodeError:
continue continue
if chunk.get("usage"): if chunk.get("usage"):
usage_tokens = chunk["usage"].get("completion_tokens") usage.update(chunk["usage"])
for choice in chunk.get("choices", []): for choice in chunk.get("choices", []):
piece = (choice.get("delta") or {}) piece = choice.get("delta") or {}
content = piece.get("content") or piece.get("reasoning_content") 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 content:
if ttft is None: if ttft is None:
ttft = time.perf_counter() - start ttft = time.perf_counter() - start
deltas += 1 deltas += 1
text.append(content) text.append(content)
total = time.perf_counter() - start total = time.perf_counter() - start
return ttft, total, (usage_tokens or deltas), "".join(text) return ttft, total, usage, deltas, "".join(text)
def usage_prompt(base: str, model: str, messages, max_tokens: int):
"""prompt_tokens for a job, from one non-streamed call with max_tokens=1."""
body = {
"model": model,
"messages": messages,
"max_tokens": 1,
"temperature": 0,
"stream": False,
}
request = urllib.request.Request(
base + "/v1/chat/completions",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=300) as response:
return json.load(response).get("usage", {}).get("prompt_tokens")
def main() -> int: def main() -> int:
@@ -196,22 +190,23 @@ def main() -> int:
] ]
limit = args.max_tokens or cap limit = args.max_tokens or cap
try: try:
ptok = usage_prompt(base, args.model, messages, limit) ttft, total, usage, deltas, text = stream_chat(
ttft, total, ctok, text = stream_chat(
base, args.model, messages, limit, args.timeout) base, args.model, messages, limit, args.timeout)
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as exc: except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as exc:
print(f"{name:<10} FAILED {exc}") print(f"{name:<10} FAILED {exc}")
rows.append({"job": name, "error": str(exc)}) rows.append({"job": name, "error": str(exc)})
continue continue
ptok = usage.get("prompt_tokens")
ctok = usage.get("completion_tokens") or deltas
decode = None decode = None
if ttft is not None and ctok and total > ttft: if ttft is not None and ctok and total > ttft:
decode = (ctok - 1) / (total - ttft) decode = (ctok - 1) / (total - ttft)
prefill_rate = (ptok / ttft) if (ptok and ttft) else None prefill = (ptok / ttft) if (ptok and ttft) else None
first = text.strip().splitlines()[0][:48] if text.strip() else "(empty)" first = text.strip().splitlines()[0][:48] if text.strip() else "(empty)"
print(f"{name:<10} {ptok or 0:>7} {ctok:>6} " 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}") 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, rows.append({"job": name, "prompt_tokens": ptok, "completion_tokens": ctok,
"ttft_s": ttft, "decode_tps": decode, "prefill_tps": prefill_rate, "ttft_s": ttft, "decode_tps": decode, "prefill_tps": prefill,
"total_s": total, "answer_head": first}) "total_s": total, "answer_head": first})
good = [r for r in rows if r.get("decode_tps")] good = [r for r in rows if r.get("decode_tps")]
@@ -236,8 +231,9 @@ def main() -> int:
def worker(): def worker():
try: try:
ttft, total, ctok, _ = stream_chat( ttft, total, usage, deltas, _ = stream_chat(
base, args.model, messages, 640, args.timeout) base, args.model, messages, 640, args.timeout)
ctok = usage.get("completion_tokens") or deltas
with lock: with lock:
results.append((ttft, total, ctok)) results.append((ttft, total, ctok))
except Exception as exc: # noqa: BLE001 - report, do not abort the burst except Exception as exc: # noqa: BLE001 - report, do not abort the burst
@@ -253,7 +249,7 @@ def main() -> int:
wall = time.perf_counter() - t0 wall = time.perf_counter() - t0
ok = [r for r in results if r[0] is not None] ok = [r for r in results if r[0] is not None]
if ok: if ok:
tokens = sum(r[2] for r in 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 " print(f"burst {args.burst}x plan: wall {wall:.1f}s aggregate "
f"{tokens / wall:.1f} tok/s " f"{tokens / wall:.1f} tok/s "
f"per-stream {statistics.mean([r[2] / r[1] for r in ok]):.1f} tok/s" f"per-stream {statistics.mean([r[2] / r[1] for r in ok]):.1f} tok/s"