#!/usr/bin/env python3 """Reproducible HTTP benchmark for Ollama and OpenAI-compatible runtimes.""" from __future__ import annotations import argparse import concurrent.futures import csv import json import math import platform import statistics import subprocess import sys import threading import time import urllib.error import urllib.request import uuid from dataclasses import asdict, dataclass from datetime import UTC, datetime from pathlib import Path from typing import Any PROMPTS = { "short": ( "In exactly four concise bullet points, explain why database transactions are useful. " "Do not use a heading." ), "medium": ( "You are reviewing a Laravel service that transfers money between two accounts. " "Explain the failure modes involving concurrency, partial writes, retries, and audit logs. " "Then provide a robust implementation plan. Keep the response technical and concise." ), "code": ( "Write a Python 3.12 function named merge_intervals that accepts a list of integer pairs, " "validates each pair, merges overlapping or adjacent intervals, and returns sorted tuples. " "Include type hints, a short docstring, and five assert-based examples. Output only code." ), } CSV_FIELDS = ( "run_id", "runtime", "model", "phase", "concurrency", "round", "slot", "request_id", "prompt_name", "prompt_chars", "max_tokens", "started_at", "ttft_s", "total_s", "prompt_tokens", "output_tokens", "prompt_tps", "decode_tps", "request_tps", "group_elapsed_s", "group_output_tps", "error", ) @dataclass(frozen=True) class RuntimeConfig: name: str kind: str base_url: str model: str @dataclass class Measurement: run_id: str runtime: str model: str phase: str concurrency: int round: int slot: int request_id: str prompt_name: str prompt_chars: int max_tokens: int started_at: str ttft_s: float | None = None total_s: float | None = None prompt_tokens: int | None = None output_tokens: int | None = None prompt_tps: float | None = None decode_tps: float | None = None request_tps: float | None = None group_elapsed_s: float | None = None group_output_tps: float | None = None error: str | None = None def utc_now() -> str: return datetime.now(UTC).isoformat() def percentile(values: list[float], fraction: float) -> float | None: if not values: return None ordered = sorted(values) position = (len(ordered) - 1) * fraction lower = math.floor(position) upper = math.ceil(position) if lower == upper: return ordered[lower] return ordered[lower] + (ordered[upper] - ordered[lower]) * (position - lower) def safe_version(command: list[str]) -> str | None: try: result = subprocess.run(command, capture_output=True, text=True, timeout=15, check=False) except (OSError, subprocess.SubprocessError): return None value = (result.stdout or result.stderr).strip() return value or None def http_json(url: str, timeout: float = 15) -> dict[str, Any]: request = urllib.request.Request(url, headers={"Accept": "application/json"}) with urllib.request.urlopen(request, timeout=timeout) as response: return json.load(response) def read_ndjson(response: Any, started: float) -> tuple[float | None, dict[str, Any], int]: first_token_at: float | None = None output_chars = 0 terminal: dict[str, Any] = {} for raw_line in response: if not raw_line.strip(): continue event = json.loads(raw_line) content = event.get("response", "") if content: output_chars += len(content) if first_token_at is None: first_token_at = time.perf_counter() if event.get("done"): terminal = event return (first_token_at - started if first_token_at else None, terminal, output_chars) def read_sse(response: Any, started: float) -> tuple[float | None, dict[str, Any], int]: first_token_at: float | None = None output_chars = 0 terminal: dict[str, Any] = {} for raw_line in response: line = raw_line.decode("utf-8").strip() if not line.startswith("data:"): continue payload = line[5:].strip() if payload == "[DONE]": break event = json.loads(payload) terminal = event choices = event.get("choices") or [] if choices: text = choices[0].get("text") or choices[0].get("delta", {}).get("content") or "" if text: output_chars += len(text) if first_token_at is None: first_token_at = time.perf_counter() return (first_token_at - started if first_token_at else None, terminal, output_chars) def benchmark_request( runtime: RuntimeConfig, prompt_name: str, prompt: str, max_tokens: int, seed: int, timeout: float, run_id: str, phase: str, concurrency: int, round_number: int, slot: int, ) -> Measurement: measurement = Measurement( run_id=run_id, runtime=runtime.name, model=runtime.model, phase=phase, concurrency=concurrency, round=round_number, slot=slot, request_id=str(uuid.uuid4()), prompt_name=prompt_name, prompt_chars=len(prompt), max_tokens=max_tokens, started_at=utc_now(), ) headers = {"Content-Type": "application/json", "Accept": "text/event-stream"} if runtime.kind == "ollama": url = f"{runtime.base_url.rstrip('/')}/api/generate" payload = { "model": runtime.model, "prompt": prompt, "stream": True, "raw": True, "keep_alive": "30m", "options": { "temperature": 0, "seed": seed, "num_predict": max_tokens, "num_ctx": 8192, "draft_num_predict": 0, }, } else: url = f"{runtime.base_url.rstrip('/')}/v1/completions" payload = { "model": runtime.model, "prompt": prompt, "stream": True, "stream_options": {"include_usage": True}, "temperature": 0, "seed": seed, "max_tokens": max_tokens, } started = time.perf_counter() try: request = urllib.request.Request( url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST", ) with urllib.request.urlopen(request, timeout=timeout) as response: if runtime.kind == "ollama": ttft, terminal, output_chars = read_ndjson(response, started) measurement.prompt_tokens = terminal.get("prompt_eval_count") measurement.output_tokens = terminal.get("eval_count") prompt_ns = terminal.get("prompt_eval_duration") decode_ns = terminal.get("eval_duration") if prompt_ns and measurement.prompt_tokens: measurement.prompt_tps = measurement.prompt_tokens / (prompt_ns / 1e9) if decode_ns and measurement.output_tokens: measurement.decode_tps = measurement.output_tokens / (decode_ns / 1e9) else: ttft, terminal, output_chars = read_sse(response, started) usage = terminal.get("usage") or {} measurement.prompt_tokens = usage.get("prompt_tokens") measurement.output_tokens = usage.get("completion_tokens") measurement.ttft_s = ttft measurement.total_s = time.perf_counter() - started if measurement.output_tokens and measurement.total_s: measurement.request_tps = measurement.output_tokens / measurement.total_s if measurement.decode_tps is None and measurement.ttft_s is not None: decode_time = measurement.total_s - measurement.ttft_s if decode_time > 0: measurement.decode_tps = measurement.output_tokens / decode_time if output_chars == 0: measurement.error = "stream contained no generated text" except urllib.error.HTTPError as error: detail = error.read().decode("utf-8", errors="replace")[:1000] measurement.error = f"HTTP {error.code}: {detail}" measurement.total_s = time.perf_counter() - started except (OSError, ValueError, json.JSONDecodeError) as error: measurement.error = f"{type(error).__name__}: {error}" measurement.total_s = time.perf_counter() - started return measurement def run_round( runtime: RuntimeConfig, prompt_items: list[tuple[str, str]], max_tokens: int, seed: int, timeout: float, run_id: str, phase: str, concurrency: int, round_number: int, ) -> tuple[list[Measurement], float]: barrier = threading.Barrier(concurrency) def execute(slot: int) -> Measurement: prompt_name, prompt = prompt_items[(round_number * concurrency + slot) % len(prompt_items)] barrier.wait() return benchmark_request( runtime, prompt_name, prompt, max_tokens, seed, timeout, run_id, phase, concurrency, round_number, slot, ) group_started = time.perf_counter() with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as executor: measurements = list(executor.map(execute, range(concurrency))) group_elapsed = time.perf_counter() - group_started successful_tokens = sum(item.output_tokens or 0 for item in measurements if not item.error) group_tps = successful_tokens / group_elapsed if group_elapsed else 0.0 for item in measurements: item.group_elapsed_s = group_elapsed item.group_output_tps = group_tps return measurements, group_elapsed def summarize(measurements: list[Measurement]) -> list[dict[str, Any]]: groups: dict[tuple[str, int], list[Measurement]] = {} for measurement in measurements: if measurement.phase != "measured": continue groups.setdefault((measurement.runtime, measurement.concurrency), []).append(measurement) summaries: list[dict[str, Any]] = [] for (runtime, concurrency), items in sorted(groups.items()): successful = [item for item in items if not item.error] summary: dict[str, Any] = { "runtime": runtime, "model": items[0].model, "concurrency": concurrency, "requests": len(items), "successes": len(successful), "failures": len(items) - len(successful), } for field in ("ttft_s", "total_s", "prompt_tps", "decode_tps", "request_tps"): values = [ float(value) for item in successful if (value := getattr(item, field)) is not None ] summary[f"{field}_mean"] = statistics.fmean(values) if values else None summary[f"{field}_p50"] = percentile(values, 0.50) summary[f"{field}_p95"] = percentile(values, 0.95) group_tps = [ float(item.group_output_tps) for item in successful if item.slot == 0 and item.group_output_tps is not None ] summary["aggregate_output_tps_mean"] = statistics.fmean(group_tps) if group_tps else None summary["aggregate_output_tps_p50"] = percentile(group_tps, 0.50) summary["aggregate_output_tps_p95"] = percentile(group_tps, 0.95) summaries.append(summary) return summaries def write_csv(path: Path, measurements: list[Measurement]) -> None: with path.open("w", newline="", encoding="utf-8") as handle: writer = csv.DictWriter(handle, fieldnames=CSV_FIELDS, lineterminator="\n") writer.writeheader() for measurement in measurements: writer.writerow(asdict(measurement)) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--output-dir", type=Path, default=Path(__file__).parent / "results") parser.add_argument("--runtime", choices=("all", "ollama", "vllm"), default="all") parser.add_argument("--ollama-url", default="http://127.0.0.1:11434") parser.add_argument("--ollama-model", default="qwen3.8:27b-mlx") parser.add_argument("--vllm-url", default="http://127.0.0.1:8001") parser.add_argument("--vllm-model", default="qwen3.8-27b-nvfp4") parser.add_argument("--warmups", type=int, default=2) parser.add_argument("--rounds", type=int, default=5) parser.add_argument("--concurrency", type=int, nargs="+", default=(1, 4)) parser.add_argument("--max-tokens", type=int, default=128) parser.add_argument("--seed", type=int, default=42) parser.add_argument("--timeout", type=float, default=600) return parser.parse_args() def main() -> int: args = parse_args() if args.warmups < 0 or args.rounds < 1 or any(value < 1 for value in args.concurrency): raise SystemExit("warmups must be >= 0; rounds and concurrency must be >= 1") runtimes = { "ollama": RuntimeConfig("ollama", "ollama", args.ollama_url, args.ollama_model), "vllm": RuntimeConfig("vllm-metal", "openai", args.vllm_url, args.vllm_model), } selected = list(runtimes.values()) if args.runtime == "all" else [runtimes[args.runtime]] for runtime in selected: health_url = ( f"{runtime.base_url.rstrip('/')}/api/tags" if runtime.kind == "ollama" else f"{runtime.base_url.rstrip('/')}/v1/models" ) try: http_json(health_url) except Exception as error: raise SystemExit(f"{runtime.name} is not ready at {health_url}: {error}") from error run_id = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") output_dir = args.output_dir / run_id output_dir.mkdir(parents=True, exist_ok=False) metadata = { "run_id": run_id, "created_at": utc_now(), "host": { "platform": platform.platform(), "machine": platform.machine(), "python": platform.python_version(), }, "arguments": vars(args) | {"output_dir": str(args.output_dir)}, "runtimes": [asdict(runtime) for runtime in selected], "versions": { "ollama": safe_version(["ollama", "--version"]), "vllm": safe_version([str(Path.home() / ".venv-vllm-metal/bin/vllm"), "--version"]), }, "model_metadata": {}, "prompts": PROMPTS, "measurement_notes": { "ttft": ( "Client wall time from request start until first non-empty generated text chunk." ), "total": "Client wall time from request start until stream completion.", "decode_tps": "Ollama server eval metric; otherwise output tokens / (total - TTFT).", "aggregate_output_tps": "Sum of successful output tokens / concurrent group wall time.", }, } for runtime in selected: url = ( f"{runtime.base_url.rstrip('/')}/api/show" if runtime.kind == "ollama" else f"{runtime.base_url.rstrip('/')}/v1/models" ) try: if runtime.kind == "ollama": request = urllib.request.Request( url, data=json.dumps({"model": runtime.model}).encode(), headers={"Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(request, timeout=30) as response: metadata["model_metadata"][runtime.name] = json.load(response) else: metadata["model_metadata"][runtime.name] = http_json(url) except Exception as error: metadata["model_metadata"][runtime.name] = {"metadata_error": str(error)} prompt_items = list(PROMPTS.items()) measurements: list[Measurement] = [] total_groups = len(selected) * sum(args.warmups + args.rounds for _ in args.concurrency) completed_groups = 0 for runtime in selected: for concurrency in args.concurrency: for phase, rounds in (("warmup", args.warmups), ("measured", args.rounds)): for round_number in range(rounds): items, elapsed = run_round( runtime, prompt_items, args.max_tokens, args.seed, args.timeout, run_id, phase, concurrency, round_number, ) measurements.extend(items) completed_groups += 1 errors = sum(bool(item.error) for item in items) print( f"[{completed_groups}/{total_groups}] {runtime.name} phase={phase} " f"concurrency={concurrency} round={round_number + 1}/{rounds} " f"elapsed={elapsed:.3f}s errors={errors}", flush=True, ) summaries = summarize(measurements) metadata["completed_at"] = utc_now() metadata["summary"] = summaries (output_dir / "raw.json").write_text( json.dumps([asdict(item) | { "group_elapsed_s": getattr(item, "group_elapsed_s", None), "group_output_tps": getattr(item, "group_output_tps", None), } for item in measurements], indent=2), encoding="utf-8", ) (output_dir / "metadata.json").write_text( json.dumps(metadata, indent=2, default=str), encoding="utf-8" ) (output_dir / "summary.json").write_text(json.dumps(summaries, indent=2), encoding="utf-8") write_csv(output_dir / "raw.csv", measurements) print(json.dumps(summaries, indent=2)) print(f"Results: {output_dir}") return 1 if any(item.error for item in measurements if item.phase == "measured") else 0 if __name__ == "__main__": sys.exit(main())