Files
tatlock/scripts/bench_serving.py
T
jpmschweitzerandClaude Fable 5 f578ebded8 feat(bench): serving benchmark — the baseline instrument for the migration
A scripted 8-turn conversation through the real pipeline (tool call,
memory recall, librarian route, small talk), run against any base URL so
the same instrument measures Ollama today and llama-server after the
cutover. Non-stream mode records wall time and usage; --stream records
time-to-first-token; a VRAM poller and an optional interleaved second
tenant capture the contention the serving plan calls out.

Planted facts carry a BENCH-MARKER prefix so anything the biographer
learns from a bench run stays recognizable in the memory stores.

First smoke against production: 9.7s mean per simple turn, and TTFT
equals wall time — the client sees nothing until the whole
steward-orchestrate-synthesize pipeline has finished. Time-to-first-word
for desklock is currently the full pipeline latency, which makes
streamed synthesis a first-class goal of the serving work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 21:07:57 +02:00

356 lines
12 KiB
Python

"""Serving-layer benchmark: a scripted conversation through the real pipeline.
Measures what the serving rework is judged on (workspace serving plan,
2026-09-11): per-turn wall time, time-to-first-token in --stream mode, token
usage per turn, and VRAM peak while the conversation runs. Run it against a
backend to produce a baseline, run it again after a serving change, and
compare the JSON artifacts side by side.
The conversation replays full history each turn, exactly like a real client.
Planted facts are prefixed BENCH-MARKER so anything the biographer learns
from a bench run is recognizable in memory stores later.
Usage:
.venv/bin/python scripts/bench_serving.py # dev server
.venv/bin/python scripts/bench_serving.py --base-url http://localhost:8000
.venv/bin/python scripts/bench_serving.py --stream # TTFT mode
.venv/bin/python scripts/bench_serving.py --interleave-url http://localhost:11434/v1 --interleave-every 20
Exit codes: 0 ok, 69 backend unreachable.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import threading
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import httpx
TURN_TIMEOUT_S = 300.0
# Labels name the pipeline path each turn is expected to exercise.
CONVERSATION: list[tuple[str, str]] = [
(
"plant-fact",
"For the record: BENCH-MARKER my favorite tea is lapsang souchong. "
"Please remember that.",
),
("tool-arithmetic", "What is 61 plus 12?"),
("small-talk", "Good evening Tatlock, how are the household systems?"),
("memory-recall", "What did I tell you my favorite tea was?"),
(
"librarian-routed",
"What do you know about the desklock device in the living room?",
),
("tool-arithmetic-2", "Multiply 17 by 23, then add 100."),
("synthesis-light", "Suggest a two-line dinner idea using rice."),
(
"context-summary",
"Summarize what we discussed in this conversation in one sentence.",
),
]
class VramPoller:
"""Samples nvidia-smi memory.used until stopped; keeps peak and samples."""
def __init__(self, interval_s: float = 0.5):
self.interval_s = interval_s
self.samples_mib: list[int] = []
self._stop = threading.Event()
self._thread = threading.Thread(target=self._run, daemon=True)
def _read_mib(self) -> int | None:
try:
out = subprocess.run(
[
"nvidia-smi",
"--query-gpu=memory.used",
"--format=csv,noheader,nounits",
],
capture_output=True,
text=True,
timeout=5,
)
return int(out.stdout.strip().splitlines()[0])
except (OSError, ValueError, IndexError, subprocess.TimeoutExpired):
return None
def _run(self) -> None:
while not self._stop.is_set():
mib = self._read_mib()
if mib is not None:
self.samples_mib.append(mib)
self._stop.wait(self.interval_s)
def __enter__(self) -> VramPoller:
self._thread.start()
return self
def __exit__(self, *exc: object) -> None:
self._stop.set()
self._thread.join(timeout=5)
@property
def peak_mib(self) -> int | None:
return max(self.samples_mib) if self.samples_mib else None
class Interleaver:
"""Fires a plain completion at a second backend on a fixed cadence.
Simulates an Open-WebUI-style tenant competing for the same card while
the main conversation runs — the slot-eviction case the serving plan
calls out. Each request's wall time is recorded so contention shows on
both sides of the measurement.
"""
def __init__(self, url: str, model: str, every_s: float):
self.url = url.rstrip("/")
self.model = model
self.every_s = every_s
self.results: list[dict[str, Any]] = []
self._stop = threading.Event()
self._thread = threading.Thread(target=self._run, daemon=True)
def _run(self) -> None:
n = 0
while not self._stop.is_set():
n += 1
started = time.monotonic()
try:
r = httpx.post(
f"{self.url}/chat/completions",
json={
"model": self.model,
"messages": [
{
"role": "user",
"content": f"Interleave probe {n}: name one European capital.",
}
],
"max_tokens": 32,
},
timeout=TURN_TIMEOUT_S,
)
self.results.append(
{
"n": n,
"wall_s": round(time.monotonic() - started, 3),
"status": r.status_code,
}
)
except httpx.HTTPError as e:
self.results.append(
{
"n": n,
"wall_s": round(time.monotonic() - started, 3),
"error": type(e).__name__,
}
)
self._stop.wait(self.every_s)
def __enter__(self) -> Interleaver:
self._thread.start()
return self
def __exit__(self, *exc: object) -> None:
self._stop.set()
self._thread.join(timeout=10)
def run_turn(
client: httpx.Client, base_url: str, model: str, messages: list[dict[str, str]]
) -> dict[str, Any]:
"""One non-streamed turn: wall time and usage."""
started = time.monotonic()
r = client.post(
f"{base_url}/v1/chat/completions",
json={"model": model, "messages": messages},
timeout=TURN_TIMEOUT_S,
)
wall = time.monotonic() - started
r.raise_for_status()
body = r.json()
reply = body["choices"][0]["message"]["content"] or ""
return {
"wall_s": round(wall, 3),
"usage": body.get("usage"),
"reply_chars": len(reply),
"reply": reply,
}
def run_turn_streamed(
client: httpx.Client, base_url: str, model: str, messages: list[dict[str, str]]
) -> dict[str, Any]:
"""One streamed turn: wall time and time-to-first-token."""
started = time.monotonic()
ttft: float | None = None
chunks: list[str] = []
with client.stream(
"POST",
f"{base_url}/v1/chat/completions",
json={"model": model, "messages": messages, "stream": True},
timeout=TURN_TIMEOUT_S,
) as r:
r.raise_for_status()
for line in r.iter_lines():
if not line.startswith("data: "):
continue
payload = line[len("data: ") :]
if payload.strip() == "[DONE]":
break
try:
delta = json.loads(payload)["choices"][0]["delta"]
except (json.JSONDecodeError, KeyError, IndexError):
continue
content = delta.get("content")
if content:
if ttft is None:
ttft = time.monotonic() - started
chunks.append(content)
reply = "".join(chunks)
return {
"wall_s": round(time.monotonic() - started, 3),
"ttft_s": round(ttft, 3) if ttft is not None else None,
"reply_chars": len(reply),
"reply": reply,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default="http://localhost:8777")
parser.add_argument(
"--model",
default="Tatlock",
help="model id as the target serves it (tatlock registry: 'Tatlock'; raw backend: e.g. 'gemma4:e2b')",
)
parser.add_argument("--turns", type=int, default=len(CONVERSATION))
parser.add_argument(
"--stream",
action="store_true",
help="measure time-to-first-token instead of usage",
)
parser.add_argument(
"--interleave-url",
default=None,
help="OpenAI-compat base (e.g. http://localhost:11434/v1) for a competing tenant",
)
parser.add_argument("--interleave-every", type=float, default=20.0)
parser.add_argument(
"--interleave-model",
default="gemma4:e2b",
help="model id at the interleave backend (raw Ollama/llama-server tag)",
)
parser.add_argument("--label", default=None, help="run label stored in the artifact")
parser.add_argument("--out-dir", default="build/bench")
args = parser.parse_args()
base_url = args.base_url.rstrip("/")
client = httpx.Client()
try:
health = client.get(f"{base_url}/health", timeout=10).json()
except httpx.HTTPError as e:
print(f"backend unreachable at {base_url}: {type(e).__name__}", file=sys.stderr)
return 69
started_at = datetime.now(UTC).isoformat()
turns: list[dict[str, Any]] = []
messages: list[dict[str, str]] = []
interleaver = (
Interleaver(args.interleave_url, args.interleave_model, args.interleave_every)
if args.interleave_url
else None
)
with VramPoller() as vram:
ctx = interleaver if interleaver else _NullCtx()
with ctx:
for label, text in CONVERSATION[: args.turns]:
messages.append({"role": "user", "content": text})
runner = run_turn_streamed if args.stream else run_turn
try:
result = runner(client, base_url, args.model, messages)
except httpx.HTTPStatusError as e:
detail = e.response.text[:200]
print(
f" {label}: FAILED {e.response.status_code} {detail}",
file=sys.stderr,
)
turns.append(
{
"label": label,
"error": type(e).__name__,
"status": e.response.status_code,
"detail": detail,
}
)
messages.pop()
continue
except httpx.HTTPError as e:
print(f" {label}: FAILED ({type(e).__name__})", file=sys.stderr)
turns.append({"label": label, "error": type(e).__name__})
messages.pop()
continue
messages.append({"role": "assistant", "content": result.pop("reply")})
turns.append({"label": label, **result})
shown = result.get("ttft_s") if args.stream else result.get("wall_s")
print(f" {label}: {shown}s ({result})")
artifact = {
"label": args.label,
"started_at": started_at,
"base_url": base_url,
"model": args.model,
"mode": "stream" if args.stream else "usage",
"backend_health": health,
"interleave": {
"url": args.interleave_url,
"every_s": args.interleave_every,
"results": interleaver.results if interleaver else [],
},
"vram": {"peak_mib": vram.peak_mib, "samples": len(vram.samples_mib)},
"turns": turns,
}
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
stamp = started_at.replace(":", "").replace("-", "")[:15]
out_path = out_dir / f"bench-{stamp}-{args.label or 'run'}.json"
out_path.write_text(json.dumps(artifact, indent=2))
ok_walls = [t["wall_s"] for t in turns if "wall_s" in t]
print(f"\nturns: {len(ok_walls)}/{len(turns)} ok")
if ok_walls:
print(f"wall total {sum(ok_walls):.1f}s mean {sum(ok_walls) / len(ok_walls):.1f}s")
if args.stream:
ttfts = [t["ttft_s"] for t in turns if t.get("ttft_s") is not None]
if ttfts:
print(f"ttft mean {sum(ttfts) / len(ttfts):.2f}s max {max(ttfts):.2f}s")
print(f"vram peak {vram.peak_mib} MiB over {len(vram.samples_mib)} samples")
print(f"artifact: {out_path}")
return 0
class _NullCtx:
def __enter__(self) -> _NullCtx:
return self
def __exit__(self, *exc: object) -> None:
return None
if __name__ == "__main__":
sys.exit(main())