Coverage for orchestrant/benchmark/openai_api.py: 60%
513 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-19 11:22 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-19 11:22 +0000
1#!/usr/bin/env python3
2"""Benchmark any OpenAI-compatible LLM endpoint, with CPU and RAM tracking.
4Usage:
5 orchestrant-bench speed
6 orchestrant-bench speed --model gemma4:26b --prompts 10
7 orchestrant-bench speed --output benchmark_results.json
8"""
10import argparse
11import json
12import os
13import sys
14import threading
15import time
16import urllib.error
17import urllib.request
18from datetime import UTC, datetime
20from orchestrant.benchmark import answers, correctness, speed_summary
21from orchestrant.benchmark.answers import accounting, from_body, read_stream
22from orchestrant.benchmark.client import (
23 entry_config,
24 post_json,
25 request_headers,
26 run_start,
27)
28from orchestrant.benchmark.correctness import CORRECTNESS_PROBES, print_correctness
29from orchestrant.benchmark.energy import energy_block, energy_lines, renet
30from orchestrant.benchmark.hostload import RequestBracket, open_meters, summary_lines
31from orchestrant.benchmark.provenance import collect_or_error
34# Hashed into tool_sha256: every file that decides what a speed row says.
35SPEED_TOOL_FILES = (
36 "openai_api.py",
37 "answers.py",
38 "correctness.py",
39 "energy.py",
40 "hostload.py",
41)
44# LLM_BASE_URL is the name to use; OLLAMA_BASE_URL still works for existing scripts.
45LLM_BASE_URL = (
46 os.environ.get("LLM_BASE_URL")
47 or os.environ.get("OLLAMA_BASE_URL")
48 or "http://localhost:11434"
49)
50OLLAMA_BASE_URL = LLM_BASE_URL # backwards-compatible alias
51GLANCES_URL = os.environ.get("GLANCES_URL", "http://localhost:61208")
54def collect_gpu_info():
55 """Name the accelerator that served the endpoint, if one is readable."""
56 try:
57 from orchestrant.monitoring.gpu import GPUProbe
59 with GPUProbe() as probe:
60 if not probe.available:
61 return {}
62 snapshot = probe.read()
63 gpu = {
64 "vendor": probe.vendor,
65 "name": probe.gpu_name,
66 "backend": probe.backend,
67 }
68 if probe.memory_type:
69 gpu["memory_type"] = probe.memory_type
70 if probe.memory_bandwidth_mbps:
71 gpu["memory_bandwidth_gbps"] = round(
72 probe.memory_bandwidth_mbps / 1000, 1
73 )
74 if snapshot is not None:
75 gpu["memory_total_mb"] = round(snapshot.memory_total_bytes / (1024**2))
76 gpu["memory_used_mb"] = round(snapshot.memory_used_bytes / (1024**2))
77 return gpu
78 except Exception:
79 return {}
82def collect_hardware_info():
83 """Collect system hardware info for reproducibility, naming gaps in `incomplete`."""
84 info = {}
85 info["timestamp"] = datetime.now(UTC).isoformat()
87 # OS info
88 try:
89 import platform
91 info["os"] = platform.system()
92 info["os_release"] = platform.release()
93 info["os_version"] = platform.version()
94 info["architecture"] = platform.machine()
95 info["processor"] = platform.processor()
96 except Exception: # nosec B110 -- best-effort probe
97 pass
99 # CPU info from /proc/cpuinfo (Linux)
100 try:
101 with open("/proc/cpuinfo") as f:
102 cpuinfo = f.read()
103 cpus = [p for p in cpuinfo.split("\n\n") if p.strip()]
104 info["cpu_count"] = len(cpus)
105 for line in cpus[0].split("\n") if cpus else []:
106 if "model name" in line.lower():
107 info["cpu_model"] = line.split(":")[1].strip()
108 break
109 # Total cores per CPU (core id max + 1)
110 core_ids = set()
111 for cpu in cpus:
112 for line in cpu.split("\n"):
113 if "core id" in line.lower():
114 core_ids.add(line.split(":")[1].strip())
115 if core_ids:
116 info["cpu_physical_cores"] = len(core_ids)
117 # Thread(s) per core
118 for line in cpus[0].split("\n") if cpus else []:
119 if "siblings" in line.lower():
120 info["cpu_threads_per_core"] = int(line.split(":")[1].strip())
121 break
122 info["cpu_total_threads"] = info["cpu_count"]
123 except Exception: # nosec B110 -- best-effort probe
124 pass
126 # RAM
127 try:
128 with open("/proc/meminfo") as f:
129 mem = f.read()
130 for line in mem.split("\n"):
131 if line.startswith("MemTotal:"):
132 info["ram_total_kb"] = int(line.split()[1])
133 info["ram_total_gb"] = round(info["ram_total_kb"] / (1024**2), 1)
134 break
135 except Exception: # nosec B110 -- best-effort probe
136 pass
138 # Container info via cgroup
139 try:
140 with open("/proc/1/cgroup") as f:
141 cgroup = f.read()
142 info["in_container"] = (
143 "docker" in cgroup or "containerd" in cgroup or len(cgroup.splitlines()) > 0
144 )
145 except Exception:
146 info["in_container"] = None
148 # OLLAMA_HOST env
149 info["ollama_host"] = os.environ.get("OLLAMA_HOST", "default")
151 # GPU identity, whichever vendor is present.
152 gpu = collect_gpu_info()
153 if gpu:
154 info["gpu"] = gpu
156 # Portable fallback for what /proc could not provide (all of it on Windows and macOS)
157 try:
158 import platform as _pf
160 info.setdefault("os", _pf.system())
161 info.setdefault("os_release", _pf.release())
162 info.setdefault("architecture", _pf.machine())
163 if not info.get("processor"):
164 info["processor"] = _pf.processor() or None
165 except Exception: # nosec B110 -- best-effort probe
166 pass
167 try:
168 import psutil as _ps
170 if not info.get("cpu_physical_cores"):
171 info["cpu_physical_cores"] = _ps.cpu_count(logical=False)
172 if not info.get("cpu_total_threads"):
173 info["cpu_total_threads"] = _ps.cpu_count(logical=True)
174 if not info.get("ram_total_gb"):
175 info["ram_total_gb"] = round(_ps.virtual_memory().total / (1024**3), 1)
176 except Exception: # nosec B110 -- best-effort probe
177 pass
179 # Name the gaps: a benchmark whose host is unknown cannot be compared later.
180 expected = ("os", "architecture", "cpu_total_threads", "ram_total_gb")
181 missing = [k for k in expected if not info.get(k)]
182 if missing:
183 info["incomplete"] = missing
185 return info
188# Prompts of varying length
190SHORT_PROMPTS = [
191 "What is 2+2?",
192 "Say hello.",
193 "What is the capital of France?",
194 "Explain recursion in one sentence.",
195 "What is an API?",
196]
198MEDIUM_PROMPTS = [
199 "Write a Python function that computes the Fibonacci sequence using dynamic programming. Include a brief explanation of the time complexity.",
200 "Explain the difference between REST and GraphQL APIs. When would you choose one over the other? Provide concrete examples.",
201 "Write a bash script that monitors CPU and memory usage of a specific process every 5 seconds and logs the results to a CSV file.",
202]
204# Named backends; sibling tools import these. See docs/source/benchmark.rst § Named backends
207def _default_backends_file():
208 """Where the registry lives: LLM_BACKENDS, else the hub checkout's, else a local copy."""
209 env = os.environ.get("LLM_BACKENDS")
210 if env:
211 return env
212 here = os.path.dirname(os.path.abspath(__file__))
213 repo = os.path.dirname(os.path.dirname(here))
214 hub = os.path.join(
215 repo, "third_party", "ANTfrastructure", "linux", "llm-stack", "backends.json"
216 )
217 return hub if os.path.isfile(hub) else os.path.join(here, "backends.json")
220BACKENDS_FILE = _default_backends_file()
223def load_backends(path=None):
224 """Read backends.json; missing or malformed gives an empty registry, never raises."""
225 path = path or BACKENDS_FILE
226 try:
227 with open(path) as f:
228 data = json.load(f)
229 return data.get("backends", {}), data.get("default")
230 except FileNotFoundError:
231 return {}, None
232 except Exception as e:
233 print(
234 f" WARNING: could not read {path} ({e}); named backends unavailable",
235 file=sys.stderr,
236 )
237 return {}, None
240def resolve_backend(name=None, base_url=None, path=None):
241 """Return (base_url, default_model, source) following the order above."""
242 backends, default_name = load_backends(path)
244 if base_url:
245 return base_url.rstrip("/"), None, "--base-url"
247 env = os.environ.get("LLM_BASE_URL") or os.environ.get("OLLAMA_BASE_URL")
248 if env:
249 # A named backend still supplies its default model, if it matches.
250 entry = backends.get(name) if name else None
251 return env.rstrip("/"), (entry or {}).get("model"), "environment"
253 if name:
254 if name not in backends:
255 known = ", ".join(sorted(backends)) or "(none configured)"
256 raise SystemExit(f"unknown backend {name!r}. Known: {known}")
257 entry = backends[name]
258 return entry["base_url"].rstrip("/"), entry.get("model"), f"backend {name!r}"
260 if default_name and default_name in backends:
261 entry = backends[default_name]
262 return (
263 entry["base_url"].rstrip("/"),
264 entry.get("model"),
265 f"default backend {default_name!r}",
266 )
268 return "http://localhost:11434", None, "built-in default"
271def resolve_backend_entry(name=None, base_url=None, path=None):
272 """The whole backends.json entry; a bare --base-url gets {} so no API key rides along."""
273 backends, default_name = load_backends(path)
274 if name:
275 if name not in backends:
276 known = ", ".join(sorted(backends)) or "(none configured)"
277 raise SystemExit(f"unknown backend {name!r}. Known: {known}")
278 return dict(backends[name])
279 if base_url:
280 return {}
281 if default_name and default_name in backends:
282 return dict(backends[default_name])
283 return {}
286def print_backends(path=None):
287 backends, default_name = load_backends(path)
288 if not backends:
289 print(" No backends configured (backends.json missing or empty).")
290 return
291 print("\n Configured backends:")
292 for name in sorted(backends):
293 entry = backends[name]
294 mark = " (default)" if name == default_name else ""
295 print(f" {name}{mark}")
296 print(f" url: {entry['base_url']}")
297 if entry.get("model"):
298 print(f" model: {entry['model']}")
299 if entry.get("note"):
300 print(f" note: {entry['note']}")
301 print()
304LONG_PROMPTS = [
305 """Write a detailed technical blog post about building a production-ready LLM inference server. Cover the following aspects:
3061. Model serving frameworks and their trade-offs (Ollama, vLLM, TGI, Triton Inference Server)
3072. Hardware considerations: CPU vs GPU inference, memory requirements, quantization
3083. API design: OpenAI-compatible endpoints, streaming, batching
3094. Monitoring and observability: metrics to track, logging, alerting
3105. Deployment strategies: Docker Compose, Kubernetes, auto-scaling
3116. Security considerations: rate limiting, authentication, input sanitization
313For each section, provide practical recommendations based on real-world production experience.""",
314]
317def get_glances_data(endpoint):
318 """Fetch JSON data from the Glances REST API (v4, falling back to v3)."""
319 import requests
321 for api_ver in ("4", "3"):
322 try:
323 r = requests.get(f"{GLANCES_URL}/api/{api_ver}/{endpoint}", timeout=5)
324 r.raise_for_status()
325 return r.json()
326 except Exception: # nosec B112 -- tries the next API version
327 continue
328 return None
331def top_cpu_processes(limit=3):
332 """Busiest processes since the PREVIOUS call: the port owner may not be the worker."""
333 try:
334 import psutil
335 except Exception:
336 return []
337 procs = []
338 for p in psutil.process_iter(["name"]):
339 try:
340 pct = p.cpu_percent(None)
341 # pid 0 is Windows' "System Idle Process": 737 % of idle ranked first.
342 if pct > 0 and p.pid != 0:
343 procs.append(
344 {
345 "pid": p.pid,
346 "name": p.info.get("name") or "?",
347 "cpu_percent": round(pct, 1),
348 }
349 )
350 except Exception: # nosec B112 -- skips a process that ended mid-read
351 continue
352 procs.sort(key=lambda d: d["cpu_percent"], reverse=True)
353 return procs[:limit]
356def sample_resources_psutil():
357 """Sample CPU and RAM via psutil on the host."""
358 import psutil
360 cpu = psutil.cpu_percent(interval=0.1)
361 mem = psutil.virtual_memory()
362 return {
363 "cpu_percent": cpu,
364 "ram_percent": mem.percent,
365 "ram_used_gb": mem.used / (1024**3),
366 "ram_total_gb": mem.total / (1024**3),
367 }
370def sample_resources_glances():
371 """Sample CPU and RAM via the Glances REST API."""
372 cpu_data = get_glances_data("cpu")
373 mem_data = get_glances_data("mem")
374 if cpu_data is None or mem_data is None:
375 return None
376 return {
377 "cpu_percent": cpu_data.get("total", 0.0),
378 "ram_percent": mem_data.get("percent", 0.0),
379 "ram_used_gb": mem_data.get("used", 0) / (1024**3),
380 "ram_total_gb": mem_data.get("total", 0) / (1024**3),
381 }
384_gpu_probe = None # one probe per process: driver contexts are not cheap
385# Two first callers on two threads would each open a probe and leak one.
386_gpu_probe_lock = threading.Lock()
389def gpu_probe():
390 """The process-wide GPU probe, or None; False memoizes "none available"."""
391 global _gpu_probe
392 if _gpu_probe is None:
393 with _gpu_probe_lock:
394 if _gpu_probe is None:
395 try:
396 from orchestrant.monitoring.gpu import GPUProbe
398 probe = GPUProbe()
399 _gpu_probe = probe if probe.available else False
400 except Exception:
401 _gpu_probe = False
402 return _gpu_probe or None
405def sample_gpu_resources():
406 """Local GPU utilization, VRAM and power; {} when unavailable, and never raises."""
407 try:
408 probe = gpu_probe()
409 if probe is None:
410 return {}
411 snapshot = probe.read()
412 if snapshot is None:
413 return {}
414 return {
415 "gpu_utilization_percent": round(snapshot.utilization, 1),
416 "gpu_memory_used_gb": round(snapshot.memory_used_bytes / (1024**3), 2),
417 "gpu_power_watts": round(snapshot.power_watts, 1),
418 }
419 except Exception:
420 return {}
423def _get_json(url, entry=None, timeout=5):
424 """GET one JSON document with the backend entry's auth and headers."""
425 req = urllib.request.Request(url, headers=request_headers(entry))
426 try:
427 with urllib.request.urlopen(req, timeout=timeout) as r: # nosec B310 -- endpoint URL from config
428 return json.load(r)
429 except urllib.error.HTTPError as e:
430 # The error holds the response open until GC, and every caller only needs it raised.
431 e.close()
432 raise
435def list_models_via_api(base_url=None, entry=None):
436 """Every model id the endpoint lists: /v1/models, else Ollama's /api/tags."""
437 base = base_url or LLM_BASE_URL
438 try:
439 models = _get_json(f"{base}/v1/models", entry).get("data", [])
440 if models:
441 return [m["id"] for m in models]
442 except Exception: # nosec B110 -- best-effort probe
443 pass
444 # Ollama-native fallback: /api/tags lists what is actually pulled.
445 try:
446 tags = _get_json(f"{base}/api/tags", entry).get("models", [])
447 if tags:
448 return [t.get("name") or t.get("model", "unknown") for t in tags]
449 except Exception: # nosec B110 -- best-effort probe
450 pass
451 return []
454def detect_model_via_api(base_url=None, entry=None):
455 """The model an endpoint serves, when that can be known."""
456 models = list_models_via_api(base_url, entry)
457 # A listing is not a loaded model, and hot-loading a guess can crash an NPU lane.
458 if len(models) > 1:
459 shown = ", ".join(models[:6])
460 if len(models) > 6:
461 shown += f", ... ({len(models)} in all)"
462 raise SystemExit(
463 f"{base_url or LLM_BASE_URL} lists {len(models)} models and does not "
464 f"say which one is loaded: {shown}. Pass --model, or use a --backend "
465 f"that names one."
466 )
467 return models[0] if models else "unknown"
470def resolve_model(explicit, backend_model, entry, detect=None):
471 """--model, else the backend's default, else ask the endpoint (never a probe:false one)."""
472 if explicit or backend_model:
473 return explicit or backend_model
474 if not (entry or {}).get("probe", True):
475 raise SystemExit(
476 "this backend is marked probe:false, so /v1/models is not queried. "
477 'Pass --model, or give the entry a "model" field in backends.json.'
478 )
479 return (detect or detect_model_via_api)(entry=entry)
482def benchmark_chat(
483 model,
484 prompts,
485 *,
486 max_tokens=256,
487 temperature=0.0,
488 stream=False,
489 extra_params=None,
490 warmup=True,
491 base_url=None,
492 entry=None,
493 lane=None,
494 meter=None,
495 idle_seconds=5.0,
496):
497 """Yield timing and resource rows per prompt from the chat completions endpoint.
499 `lane` and `meter` work only when this process shares a host with the server.
500 """
501 endpoint = f"{base_url or LLM_BASE_URL}/v1/chat/completions"
503 # Warmup: one short request to load the model into memory
504 if warmup:
505 try:
506 wp_payload = {
507 "model": model,
508 "messages": [{"role": "user", "content": "Warmup"}],
509 "max_tokens": 1,
510 }
511 if extra_params:
512 wp_payload.update(extra_params)
513 with post_json(endpoint, wp_payload, entry=entry, timeout=120) as r:
514 r.raw.read()
515 except SystemExit:
516 raise # a missing API key is a setup error, not a warmup hiccup
517 except Exception: # nosec B110 -- best-effort warmup
518 pass
519 time.sleep(1)
521 idle_w = meter.idle_power(idle_seconds) if meter and meter.available else None
523 for i, prompt in enumerate(prompts):
524 payload = {
525 "model": model,
526 "messages": [{"role": "user", "content": prompt}],
527 "max_tokens": max_tokens,
528 "temperature": temperature,
529 "stream": stream,
530 }
531 if extra_params:
532 payload.update(extra_params)
533 # Request usage in streaming mode (Ollama supports this)
534 if stream:
535 payload["stream_options"] = {"include_usage": True}
537 # Measured over the request rather than sampled around it.
538 bracket = RequestBracket(
539 sample_resources, top_cpu_processes, lane, meter, idle_w
540 ).start()
541 start = time.monotonic()
543 try:
544 if stream:
545 with post_json(
546 endpoint, payload, entry=entry, stream=True, timeout=300
547 ) as response:
548 reply = read_stream(response.lines())
549 else:
550 with post_json(endpoint, payload, entry=entry, timeout=300) as r:
551 reply = from_body(r.json())
552 except Exception as e:
553 yield {
554 "prompt_index": i,
555 "prompt_preview": prompt[:60],
556 "error": str(e),
557 "latency_s": time.monotonic() - start,
558 }
559 continue
561 elapsed = time.monotonic() - start
562 bracket.stop()
564 usage, streamed_chunks = reply.usage, reply.chunks
565 prompt_tokens = usage.get("prompt_tokens", 0) if usage else 0
566 completion_tokens = usage.get("completion_tokens", 0) if usage else 0
567 total_tokens = usage.get("total_tokens", 0) if usage else 0
569 # Some servers ignore include_usage; counted content deltas are flagged as estimated.
570 tokens_estimated = False
571 if not completion_tokens and streamed_chunks:
572 completion_tokens = streamed_chunks
573 total_tokens = total_tokens or (prompt_tokens + completion_tokens)
574 tokens_estimated = True
576 tokens_per_sec = completion_tokens / elapsed if elapsed > 0 else 0.0
578 # Split prefill from decode: the first token of any kind, thinking included, ends it.
579 first = reply.first_token_at
580 ttft = (first - start) if first is not None else None
581 ttfa = reply.first_answer_at - start if reply.first_answer_at else None
582 prefill_tps = prompt_tokens / ttft if ttft and prompt_tokens else None
584 # A reasoning model can be fastest per token and slowest to a usable answer.
585 acct = accounting(reply, completion_tokens, max_tokens)
587 yield {
588 "prompt_index": i,
589 "prompt_preview": prompt[:60],
590 "prompt_tokens": prompt_tokens,
591 "completion_tokens": completion_tokens,
592 "total_tokens": total_tokens,
593 "tokens_estimated": tokens_estimated,
594 "tokens_per_sec": round(tokens_per_sec, 2),
595 # The metric to rank by; None when the budget ran out before an answer.
596 "wall_s_to_answer": round(elapsed, 2) if acct["answered"] else None,
597 "latency_s": round(elapsed, 2),
598 "ttft_s": round(ttft, 3) if ttft is not None else None,
599 "ttfa_s": round(ttfa, 3) if ttfa is not None else None,
600 **answers.decode_fields(elapsed, ttft, completion_tokens),
601 "prefill_tok_per_sec": round(prefill_tps, 1) if prefill_tps else None,
602 **acct,
603 "content_preview": (reply.content or reply.reasoning)[:80],
604 **bracket.fields(completion_tokens),
605 }
607 # A second idle baseline: idle draw drifts, so main() nets rows against the mean.
608 if idle_w is not None and meter is not None:
609 meter.idle_power(idle_seconds)
612def run_correctness_probe(
613 model, *, max_tokens=4000, extra_params=None, base_url=None, entry=None
614):
615 """Check the model still answers correctly at temperature 0; None if unreachable.
617 `max_tokens` is high because a reply cut off inside <think> scores WRONG.
618 """
619 endpoint = f"{base_url or LLM_BASE_URL}/v1/chat/completions"
620 items = []
621 for probe in CORRECTNESS_PROBES:
622 payload = {
623 "model": model,
624 "messages": [{"role": "user", "content": probe.prompt}],
625 "max_tokens": max_tokens,
626 "temperature": 0,
627 }
628 if extra_params:
629 payload.update(extra_params)
630 try:
631 with post_json(endpoint, payload, entry=entry, timeout=600) as r:
632 content = r.json()["choices"][0]["message"]["content"]
633 except Exception as e:
634 items.append(correctness.errored_item(probe, e))
635 continue
636 items.append(correctness.graded_item(probe, content))
637 return correctness.summarise(items)
640_sampler_warned = False
641# A Glances that did not answer is not asked again: each refused connect costs seconds.
642_glances_down = False
645def sample_resources():
646 """Unified resource sampler: Glances API → psutil (+ GPU) → zeros; never raises."""
647 global _sampler_warned, _glances_down
648 gpu = sample_gpu_resources()
649 result = None
650 try:
651 result = None if _glances_down else sample_resources_glances()
652 except Exception:
653 result = None # requests missing or Glances flaky: fall through to psutil
654 _glances_down = result is None
655 if result is None:
656 try:
657 result = sample_resources_psutil()
658 except Exception as e:
659 if not _sampler_warned:
660 _sampler_warned = True
661 print(
662 f" WARNING: resource sampling failed ({e!r}); "
663 "reporting zeros for CPU/RAM from now on",
664 file=sys.stderr,
665 )
666 result = {
667 "cpu_percent": 0.0,
668 "ram_percent": 0.0,
669 "ram_used_gb": 0.0,
670 "ram_total_gb": 0.0,
671 }
672 result.update(gpu)
673 return result
676def print_separator(char="━", width=80):
677 print(char * width)
680def print_table(results):
681 """Print benchmark results as a formatted table."""
682 if not results:
683 print("No results to display.")
684 return
686 has_gpu = any(
687 r.get("gpu_utilization_percent") is not None
688 for r in results
689 if "error" not in r
690 )
691 headers = ["#", "Prompt", "PT", "CT", "T/s", "TTFT", "Ans(s)", "CPU%", "RAM(GB)"]
692 col_widths = [3, 38, 5, 5, 7, 7, 8, 7, 8]
693 if has_gpu:
694 headers.append("GPU%")
695 col_widths.append(6)
697 def fmt_row(values):
698 return " ".join(f"{str(v)[:w]:{w}}{'':>1}" for v, w in zip(values, col_widths))
700 print_separator("─")
701 print(fmt_row(headers))
702 print_separator("─")
704 for r in results:
705 if "error" in r:
706 print(
707 f" {r['prompt_index']:<3} {r['prompt_preview'][:38]:<38} ERROR: {r['error'][:40]}"
708 )
709 continue
710 ttft = r.get("ttft_s")
711 values = [
712 r["prompt_index"],
713 r["prompt_preview"][:38],
714 r.get("prompt_tokens", "-"),
715 r.get("completion_tokens", "-"),
716 r.get("tokens_per_sec", "-"),
717 f"{ttft:.2f}" if ttft is not None else "-",
718 answers.answer_cell(r),
719 r.get("cpu_percent", "-"),
720 r.get("ram_used_gb", "-"),
721 ]
722 if has_gpu:
723 values.append(r.get("gpu_utilization_percent", "-"))
724 print(fmt_row(values))
726 print_separator("─")
727 print()
729 latencies = [
730 r["latency_s"] for r in results if "latency_s" in r and "error" not in r
731 ]
732 cpu_vals = [
733 r["cpu_percent"] for r in results if "cpu_percent" in r and "error" not in r
734 ]
735 ram_vals = [
736 r["ram_used_gb"] for r in results if "ram_used_gb" in r and "error" not in r
737 ]
738 speed = speed_summary.summarise(results)
740 if latencies:
741 errored = f", {speed['errored']} errored" if speed["errored"] else ""
742 print(f" Summary ({speed['requests']} requests{errored}):")
743 print(
744 f" Latency: {min(latencies):.2f}s / {sum(latencies) / len(latencies):.2f}s avg / {max(latencies):.2f}s max"
745 )
746 print(
747 f" CPU: {min(cpu_vals):.1f}% / {sum(cpu_vals) / len(cpu_vals):.1f}% avg / {max(cpu_vals):.1f}% max"
748 )
749 print(
750 f" RAM used: {min(ram_vals):.2f}GB / {sum(ram_vals) / len(ram_vals):.2f}GB avg / {max(ram_vals):.2f}GB max"
751 )
752 gpu_vals = [
753 r["gpu_utilization_percent"]
754 for r in results
755 if r.get("gpu_utilization_percent") is not None
756 ]
757 if gpu_vals:
758 print(
759 f" GPU util: {min(gpu_vals):.1f}% / {sum(gpu_vals) / len(gpu_vals):.1f}% avg / {max(gpu_vals):.1f}% max"
760 )
761 gpu_power_vals = [
762 r["gpu_power_watts"]
763 for r in results
764 if r.get("gpu_power_watts") is not None
765 ]
766 if gpu_power_vals:
767 print(
768 f" GPU power: {min(gpu_power_vals):.1f}W / {sum(gpu_power_vals) / len(gpu_power_vals):.1f}W avg / {max(gpu_power_vals):.1f}W max"
769 )
771 # speed_summary's figures, the ones `report` and the viewer print too.
772 for line in speed_summary.summary_lines(speed):
773 print(line)
775 # Who burned the CPU, measured and attributed.
776 for line in summary_lines(results):
777 print(line)
779 # Rank by time to a FINISHED answer, not by tok/s.
780 for line in answers.summary_lines(results):
781 print(line)
784def _parser():
785 """The speed lane's CLI surface; main() only acts on it."""
786 parser = argparse.ArgumentParser(
787 description="Benchmark any OpenAI-compatible LLM endpoint (--backend selects one)",
788 formatter_class=argparse.RawDescriptionHelpFormatter,
789 epilog=__doc__,
790 )
791 parser.add_argument("--model", default=None, help="Model id (else --backend's)")
792 parser.add_argument("--prompts", type=int, default=0, help="Prompts (0 = all)")
793 parser.add_argument(
794 "--max-tokens", type=int, default=256, help="Max tokens per response"
795 )
796 parser.add_argument(
797 "--temperature", type=float, default=0.0, help="Sampling temperature"
798 )
799 parser.add_argument("--stream", action="store_true", help="Use streaming responses")
800 parser.add_argument(
801 "--output", default=None, help="Write results as JSON to this file"
802 )
803 parser.add_argument("--no-warmup", action="store_true", help="Skip warmup request")
804 parser.add_argument(
805 "--extra-params",
806 default=None,
807 help='Extra JSON params for the request body (e.g. \'{"num_ctx":16000,"repeat_penalty":1.1}\')',
808 )
809 parser.add_argument(
810 "--load", default=None, help="Load and re-display results from a JSON file"
811 )
812 parser.add_argument(
813 "--backend",
814 default=None,
815 help="Named backend from backends.json (e.g. ollama, geniex-npu). "
816 "Supplies the base URL and a default model.",
817 )
818 parser.add_argument(
819 "--base-url",
820 default=None,
821 help="Explicit base URL; overrides --backend and the environment",
822 )
823 parser.add_argument(
824 "--list-backends",
825 action="store_true",
826 help="Show the configured backends and exit",
827 )
828 parser.add_argument(
829 "--correctness",
830 action="store_true",
831 help="LB1: also run the verifiable-answer probe (catches a model that is "
832 "fast but broken — speed metrics cannot)",
833 )
834 parser.add_argument(
835 "--correctness-only",
836 action="store_true",
837 help="Run ONLY the correctness probe and exit (quick health check; "
838 "the exit code reads its integrity items only)",
839 )
840 parser.add_argument(
841 "--correctness-max-tokens",
842 type=int,
843 default=4000,
844 help="Token budget per probe. Must be generous: a reasoning model "
845 "truncated mid-thought scores WRONG by design (a truncated run is "
846 "not a correct one). Measured: Qwen3-4B needs >900 for arithmetic "
847 "(default 4000)",
848 )
849 parser.add_argument(
850 "--no-energy",
851 action="store_true",
852 help="Do not read the Windows Energy Meter rails (on by default where present)",
853 )
854 parser.add_argument(
855 "--idle-seconds",
856 type=float,
857 default=5.0,
858 help="Idle baseline taken after the warmup and netted out of each "
859 "request's CPU-rail energy (default 5)",
860 )
861 return parser
864def main():
865 args = _parser().parse_args()
867 if args.list_backends:
868 print_backends()
869 return
871 # Rebinding the module global keeps every existing call site working.
872 global LLM_BASE_URL, OLLAMA_BASE_URL
873 LLM_BASE_URL, backend_model, source = resolve_backend(args.backend, args.base_url)
874 OLLAMA_BASE_URL = LLM_BASE_URL
875 entry = resolve_backend_entry(args.backend, args.base_url)
877 if args.load:
878 with open(args.load) as f:
879 data = json.load(f)
880 print(f"\n Loaded benchmark results from {args.load}")
881 print(
882 f" Model: {data.get('model', '?')} | Date: {data.get('timestamp', '?')}"
883 )
884 print()
885 print_table(data.get("results", []))
886 return
888 # A backend may name its own default model; an explicit --model still wins.
889 model = resolve_model(args.model, backend_model, entry)
891 if args.correctness_only:
892 print(f"\n Model: {model}")
893 print(f" API: {LLM_BASE_URL}/v1 (from {source})")
894 probe = run_correctness_probe(
895 model,
896 max_tokens=args.correctness_max_tokens,
897 extra_params=json.loads(args.extra_params) if args.extra_params else None,
898 entry=entry,
899 )
900 print_correctness(probe)
901 # Distinct exit codes: "model is wrong" vs "we cut it off" (integrity items only).
902 sys.exit(correctness.exit_code(probe))
904 print(f"\n Model: {model}")
905 print(f" API: {LLM_BASE_URL}/v1 (from {source})")
906 print(f" Glances: {GLANCES_URL}/api/4 (v3 fallback)")
907 print()
909 # Build the prompt list
910 all_prompts = SHORT_PROMPTS + MEDIUM_PROMPTS + LONG_PROMPTS
911 if args.prompts > 0:
912 all_prompts = all_prompts[: args.prompts]
914 print(
915 f" Running {len(all_prompts)} benchmark prompts (max_tokens={args.max_tokens}, stream={args.stream})"
916 )
917 print()
919 extra_params = json.loads(args.extra_params) if args.extra_params else None
921 # The start hash, time and host load every other tool records.
922 start = run_start(SPEED_TOOL_FILES, LLM_BASE_URL)
923 # Only meaningful on the lane's host; otherwise the report records why, not zeros.
924 lane, meter = open_meters(LLM_BASE_URL, energy=not args.no_energy)
926 # Crash-safe side file; .jsonl so the *.json globs never take it for a result.
927 partial_path = f"{args.output}.partial.jsonl" if args.output else None
928 if partial_path and os.path.exists(partial_path):
929 os.remove(partial_path) # stale leftover from an aborted run
931 results = []
932 interrupted = False
933 try:
934 for result in benchmark_chat(
935 model,
936 all_prompts,
937 max_tokens=args.max_tokens,
938 temperature=args.temperature,
939 stream=args.stream,
940 extra_params=extra_params,
941 warmup=not args.no_warmup,
942 entry=entry,
943 lane=lane,
944 meter=meter,
945 idle_seconds=args.idle_seconds,
946 ):
947 results.append(result)
948 if partial_path:
949 with open(partial_path, "a") as pf:
950 pf.write(json.dumps(result) + "\n")
951 except KeyboardInterrupt:
952 interrupted = True
953 print(f"\n Interrupted — {len(results)}/{len(all_prompts)} prompts completed.")
954 if partial_path:
955 print(f" Completed results preserved in {partial_path}")
957 meter.stop()
958 renet(results, meter)
959 print_table(results)
960 for line in energy_lines(meter):
961 print(line)
963 checked = None
964 if args.correctness and not interrupted:
965 checked = run_correctness_probe(
966 model,
967 max_tokens=args.correctness_max_tokens,
968 extra_params=extra_params,
969 entry=entry,
970 )
971 print_correctness(checked)
973 output = {
974 "timestamp": datetime.now(UTC).isoformat(),
975 "model": model,
976 "api_url": f"{LLM_BASE_URL}/v1",
977 "backend": args.backend,
978 "hardware": collect_hardware_info(),
979 "config": {
980 "max_tokens": args.max_tokens,
981 "temperature": args.temperature,
982 "stream": args.stream,
983 "extra_params": extra_params,
984 # Names only for header and key fields: a value could be the key itself.
985 "backend_entry": entry_config(entry),
986 "prompts_requested": len(all_prompts),
987 "prompts_completed": len([r for r in results if "error" not in r]),
988 },
989 "results": results,
990 "correctness": checked,
991 # Ties a lane-speed number to a runtime build and a tree.
992 "provenance": collect_or_error(
993 LLM_BASE_URL,
994 SPEED_TOOL_FILES,
995 start["tool_sha256"],
996 host_load=start["host_load"],
997 run_started_utc=start["started_utc"],
998 model=model,
999 ),
1000 "energy": energy_block(meter),
1001 }
1003 if args.output and not interrupted:
1004 # Atomic write: readers of *.json must never see a half-written file.
1005 tmp_path = f"{args.output}.tmp"
1006 with open(tmp_path, "w") as f:
1007 json.dump(output, f, indent=2)
1008 os.replace(tmp_path, args.output)
1009 if partial_path and os.path.exists(partial_path):
1010 os.remove(partial_path)
1011 print(f"\n Results written to {args.output}")
1013 if interrupted:
1014 sys.exit(130)
1017if __name__ == "__main__":
1018 main()