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:
1 parent
b8d1ab42ba
commit
bcdd898750
1 file changed
+33
-37
@@ -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"
|
||||||
|
|||||||
Reference in new issue
Block a user