#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.10" # dependencies = ["httpx>=0.27"] # /// """Benchmark Ollama models for FabledScribe chat / curator workloads. Measures time-to-first-token, total wall time, and tokens/sec for each (model, scenario) pair. Designed to be runnable against both local and remote Ollama servers so results from your two CPU servers are directly comparable. Default forces CPU-only inference (num_gpu=0). Pass --num-gpu 99 for full GPU offload, or any integer for partial. Scenarios: chat — small input, small output, no thinking. Mirrors the chat-only journal companion's expected load (short user message → curious follow-up). curator — longer transcript input, structured-output extraction. Thinking defaults ON (qwen3 family) but is overridable via --think for cross-family comparisons. Think modes: auto — chat=off, curator=on (default; matches scenario intent). off — force think disabled for all runs (fair cross-family comparison; non-qwen3 models silently ignore the field anyway, so this aligns behaviour explicitly). on — force think enabled for all runs (measures what think contributes vs `off` on the same model). Prerequisites: - `uv` installed (https://docs.astral.sh/uv/). The script's shebang uses `uv run --script`, which auto-creates an ephemeral venv with httpx — no project install required. - Ollama running and reachable at --server (default http://localhost:11434). - The models named in --models must already be pulled (`ollama pull qwen3:30b-a3b` etc). Running: # Preferred — uv handles the venv: uv run scripts/bench_ollama.py --models qwen3:30b-a3b --scenario curator # Or via the shebang (the file is executable): ./scripts/bench_ollama.py --models qwen3:30b-a3b --scenario curator # `python scripts/bench_ollama.py …` works ONLY if httpx is on the # interpreter's path — usually it isn't outside the project venv. Usage examples: # Curator candidate on CPU, 3 runs each (default), one model: uv run scripts/bench_ollama.py --models qwen3:30b-a3b --scenario curator # Chat candidate on GPU, against a remote server: uv run scripts/bench_ollama.py \\ --server http://dock02:11434 \\ --models llama3.2:3b \\ --scenario chat --num-gpu 99 # Compare two qwen curator candidates on CPU, 5 runs each: uv run scripts/bench_ollama.py \\ --models qwen3:30b-a3b,qwen3:32b \\ --scenario curator --runs 5 --out bench-qwen-curator.md # Cross-family curator comparison with think forced OFF (apples-to-apples): uv run scripts/bench_ollama.py \\ --models qwen3:30b-a3b,gemma2:27b,mistral-small:22b \\ --scenario curator --think off --runs 3 \\ --out bench-curator-no-think.md The first request to a (model, num_gpu) pair triggers a model load and is excluded from the timing as a warm-up. Subsequent runs reflect warm-cache performance — closest to how the running app will see the model in practice. """ from __future__ import annotations import argparse import json import statistics import sys import time from dataclasses import dataclass, field import httpx SYSTEM_PROMPT_CHAT = ( "You are the user's journal companion. Be warm and curious. Ask one " "short follow-up question. Don't presume to help; let the user lead. " "Match the user's length." ) USER_MESSAGE_CHAT = ( "Got a lot done at Famous Supply today. Finished the network restage " "on the Bedford swarm and there was one weird issue with a Windows " "node. Tired but it feels good to be making progress." ) SYSTEM_PROMPT_CURATOR = ( "You are reading a journal conversation. Extract the user's notable " "moments — events, decisions, encounters, observations, plans. " "Output a JSON array. Each entry has fields: content (1-2 sentence " "summary in the user's voice), tags (list of strings), people (list " "of names mentioned), projects (list of project names mentioned). " "Skip meta-conversational chatter." ) USER_TRANSCRIPT_CURATOR = """\ [12:04] User: morning. starting on the Famous Supply network restage today. [12:04] Assistant: how big a job is that? [12:05] User: bedford location swarm has 3 nodes. one of them is a Windows host that's been flaky for a while. probably going to take all afternoon. [12:05] Assistant: anything you want to flag before you start in case something goes sideways? [12:06] User: yeah — the Windows node sometimes loses its bridge network on reboot. if that happens i'll have to manually rejoin it. last time it took an hour to figure out. [12:06] Assistant: noted. how are you feeling going in? [12:07] User: decent. didn't sleep great but i had coffee with Sarah this morning which was nice. she mentioned she's hiring on her team if i ever want to make a jump. [12:07] Assistant: good to know. anything to capture about the work itself before you dive in, or you good? [12:08] User: nope, just wanted to externalize before starting. [14:42] User: ok the Windows node DID lose its bridge. fixing it now. [14:42] Assistant: any guess what triggers it? [14:43] User: i think it's a stale dhcp lease that gets re-issued before the bridge comes up. might write that down as a thing to fix properly later. [15:55] User: done. all three nodes restaged, the windows one too. wrote a note about the dhcp issue for future me. [15:55] Assistant: how do you feel about how the day went? [15:56] User: tired but good. ate lunch standing up which was dumb. watching a show with Victoria tonight to decompress. """ @dataclass class RunResult: ttft_ms: float total_ms: float prompt_tokens: int output_tokens: int tokens_per_sec: float @dataclass class ScenarioResult: model: str scenario: str runs: list[RunResult] = field(default_factory=list) error: str | None = None def summary(self) -> dict: if self.error or not self.runs: return { "model": self.model, "scenario": self.scenario, "error": self.error or "no successful runs", } return { "model": self.model, "scenario": self.scenario, "runs": len(self.runs), "ttft_ms_p50": statistics.median(r.ttft_ms for r in self.runs), "total_ms_p50": statistics.median(r.total_ms for r in self.runs), "tokens_per_sec_p50": statistics.median( r.tokens_per_sec for r in self.runs ), "output_tokens_mean": statistics.mean( r.output_tokens for r in self.runs ), "prompt_tokens": self.runs[0].prompt_tokens, } def _resolve_think(scenario: str, think_mode: str) -> bool: """Map (scenario, --think mode) → boolean think flag.""" if think_mode == "on": return True if think_mode == "off": return False # auto: scenario-driven return scenario == "curator" def build_request( scenario: str, model: str, num_gpu: int, keep_alive: str, think_mode: str = "auto", ) -> dict: if scenario == "chat": messages = [ {"role": "system", "content": SYSTEM_PROMPT_CHAT}, {"role": "user", "content": USER_MESSAGE_CHAT}, ] elif scenario == "curator": messages = [ {"role": "system", "content": SYSTEM_PROMPT_CURATOR}, {"role": "user", "content": USER_TRANSCRIPT_CURATOR}, ] else: raise ValueError(f"unknown scenario: {scenario}") think = _resolve_think(scenario, think_mode) return { "model": model, "messages": messages, "stream": True, "think": think, "keep_alive": keep_alive, "options": { "num_gpu": num_gpu, "temperature": 0.3, "num_ctx": 8192, }, } def run_once(server: str, payload: dict) -> RunResult: """Stream one chat request and time it. Uses Ollama-reported `eval_count` and `eval_duration` for tokens/sec (authoritative; doesn't include client-side stream overhead). TTFT is wall-clock from request send to first content chunk. """ url = f"{server.rstrip('/')}/api/chat" t_start = time.monotonic() ttft = None prompt_tokens = 0 output_tokens = 0 eval_duration_ns = 0 with httpx.stream("POST", url, json=payload, timeout=600.0) as resp: resp.raise_for_status() for line in resp.iter_lines(): if not line: continue chunk = json.loads(line) if ttft is None and chunk.get("message", {}).get("content"): ttft = time.monotonic() - t_start if chunk.get("done"): prompt_tokens = chunk.get("prompt_eval_count", 0) output_tokens = chunk.get("eval_count", 0) eval_duration_ns = chunk.get("eval_duration", 0) total = time.monotonic() - t_start tps = ( output_tokens / (eval_duration_ns / 1e9) if eval_duration_ns else 0.0 ) return RunResult( ttft_ms=(ttft if ttft is not None else total) * 1000, total_ms=total * 1000, prompt_tokens=prompt_tokens, output_tokens=output_tokens, tokens_per_sec=tps, ) def benchmark( *, server: str, models: list[str], scenarios: list[str], runs: int, num_gpu: int, keep_alive: str, think_mode: str, ) -> list[ScenarioResult]: results: list[ScenarioResult] = [] for model in models: for scenario in scenarios: sr = ScenarioResult(model=model, scenario=scenario) payload = build_request( scenario, model, num_gpu, keep_alive, think_mode ) # Warm-up run loads the model into RAM/VRAM with the requested # num_gpu setting. Excluded from the measured runs because it # otherwise dominates TTFT with model-load time. print( f"[{model} :: {scenario}] warm-up (loading model)...", flush=True, ) try: run_once(server, payload) except httpx.HTTPError as e: sr.error = f"warm-up failed: {e}" print(f" {sr.error}", file=sys.stderr) results.append(sr) continue except Exception as e: sr.error = f"warm-up exception: {e}" print(f" {sr.error}", file=sys.stderr) results.append(sr) continue for i in range(runs): try: r = run_once(server, payload) except Exception as e: print(f" run {i+1} failed: {e}", file=sys.stderr) continue sr.runs.append(r) print( f" run {i+1}/{runs}: ttft={r.ttft_ms:.0f}ms " f"total={r.total_ms:.0f}ms tps={r.tokens_per_sec:.1f} " f"out_tokens={r.output_tokens}", flush=True, ) results.append(sr) return results def format_markdown( results: list[ScenarioResult], *, server: str, num_gpu: int, think_mode: str, ) -> str: mode = "CPU only" if num_gpu == 0 else ( f"GPU offload ({num_gpu} layers)" if num_gpu > 0 else "Ollama default" ) think_label = { "auto": "auto (chat=off, curator=on)", "on": "forced ON", "off": "forced OFF", }.get(think_mode, think_mode) lines = [ "# Ollama benchmark", "", f"- Server: `{server}`", f"- Hardware mode: {mode} (`num_gpu={num_gpu}`)", f"- Think: {think_label}", "", "| Model | Scenario | Runs | Prompt tok | TTFT p50 (ms) " "| Total p50 (ms) | tok/s p50 | Output tok (mean) |", "|---|---|---|---|---|---|---|---|", ] for sr in results: s = sr.summary() if "error" in s: lines.append( f"| {s['model']} | {s['scenario']} | — | — | — | — | — " f"| error: {s['error']} |" ) continue lines.append( f"| {s['model']} | {s['scenario']} | {s['runs']} " f"| {s['prompt_tokens']} " f"| {s['ttft_ms_p50']:.0f} | {s['total_ms_p50']:.0f} " f"| {s['tokens_per_sec_p50']:.1f} " f"| {s['output_tokens_mean']:.0f} |" ) return "\n".join(lines) + "\n" def main(): parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--server", default="http://localhost:11434", help="Ollama server URL (default %(default)s)", ) parser.add_argument( "--models", required=True, help="Comma-separated model tags (e.g. qwen2.5:32b,qwen3:14b)", ) parser.add_argument( "--scenario", choices=["chat", "curator", "both"], default="both", ) parser.add_argument( "--runs", type=int, default=3, help="Runs per (model,scenario), excluding warm-up (default %(default)s)", ) parser.add_argument( "--num-gpu", type=int, default=0, help="0 = CPU only (default), 99 = full offload, -1 = Ollama default", ) parser.add_argument( "--keep-alive", default="10m", help="Ollama keep_alive (default %(default)s)", ) parser.add_argument( "--think", choices=["auto", "on", "off"], default="auto", help=( "Think-mode control. auto = chat off / curator on (default). " "off = force disabled for fair cross-family comparison " "(non-qwen3 models silently ignore think anyway). " "on = force enabled to measure what think contributes." ), ) parser.add_argument( "--out", help="Write markdown table to this file (also prints to stdout)", ) args = parser.parse_args() models = [m.strip() for m in args.models.split(",") if m.strip()] scenarios = ( ["chat", "curator"] if args.scenario == "both" else [args.scenario] ) results = benchmark( server=args.server, models=models, scenarios=scenarios, runs=args.runs, num_gpu=args.num_gpu, keep_alive=args.keep_alive, think_mode=args.think, ) md = format_markdown( results, server=args.server, num_gpu=args.num_gpu, think_mode=args.think, ) print("\n" + md) if args.out: with open(args.out, "w") as f: f.write(md) print(f"Wrote results to {args.out}", file=sys.stderr) if __name__ == "__main__": main()