Coverage for orchestrant/benchmark/depth.py: 95%
134 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"""How fast does a lane decode once its context is deep?
3One streamed request after about --context-tokens of filler: the time to the
4first token, then the decode rate over each --window of generated tokens.
6 orchestrant-bench depth --backend geniex-cpu --model <id> --output cpu-9b-depth-8k.json
7"""
9from __future__ import annotations
11import argparse
12import functools
13import hashlib
14import json
15import sys
16import time
18from orchestrant.benchmark.answers import MIN_DECODE_WINDOW_S, delta_pieces
19from orchestrant.benchmark.client import http_error_detail, post_json
20from orchestrant.benchmark.contract import filler
23# The tracked traces were measured on exactly this prompt; change it and they stop comparing.
24FILLER_SEED = 7
25QUESTION = (
26 "Write a detailed technical blog post about the benefits and challenges of "
27 "running large language models locally."
28)
29# This file, and answers.py for what counts as a generated token.
30TOOL_FILES = ("depth.py", "answers.py")
33def depth_prompt(context_tokens):
34 """About `context_tokens` of the contract's fixed filler, then QUESTION."""
35 if not context_tokens:
36 return QUESTION
37 notes = filler(context_tokens, seed=FILLER_SEED)
38 return f"Background notes, for reference only:\n\n{notes}\n\n---\n\n{QUESTION}"
41def windows(stamps, size=256):
42 """The rate over each `size` generated tokens, cut as the scratch script cut them."""
43 out, last = [], len(stamps) - 1
44 for start in range(1, last, size):
45 end = min(start + size, last)
46 if end - start < size // 4:
47 break
48 seconds = stamps[end] - stamps[start]
49 burst = seconds < MIN_DECODE_WINDOW_S
50 rate = None if burst else round((end - start) / seconds, 2)
51 out.append({"tokens": f"{start}-{end}", "tok_per_s": rate})
52 return out
55def _error(exc):
56 """An HTTP error with its body, anything else by type: never just a status."""
57 detail = http_error_detail(exc)
58 if detail:
59 return f"HTTP {detail[0]}: {detail[1][:300]}"
60 return f"{type(exc).__name__}: {exc}"[:300]
63def warm(base_url, model, entry=None, timeout=3600):
64 """A spacer that waits for the model to load, unlike client.spacer -> {"seconds", "error"}."""
65 body = {
66 "model": model,
67 "messages": [{"role": "user", "content": "ok"}],
68 "max_tokens": 1,
69 }
70 t0, error = time.monotonic(), None
71 try:
72 url = f"{base_url}/v1/chat/completions"
73 with post_json(url, body, entry=entry, timeout=timeout) as r:
74 r.json()
75 except Exception as e: # recorded; the measured request reports its own
76 error = _error(e)
77 return {"seconds": round(time.monotonic() - t0, 2), "error": error}
80def _chunks(lines):
81 """The parsed chunks of an SSE stream, up to [DONE]; an in-stream server error raises."""
82 for line in lines:
83 if line.startswith("error:"):
84 raise RuntimeError(f"server error in stream: {line[6:].strip()[:200]}")
85 if not line.startswith("data:"):
86 continue
87 data = line[5:].strip()
88 if data == "[DONE]":
89 return
90 try:
91 chunk = json.loads(data)
92 except json.JSONDecodeError:
93 continue
94 if not isinstance(chunk, dict):
95 continue
96 if chunk.get("error"):
97 fault = json.dumps(chunk["error"])[:200]
98 raise RuntimeError(f"server error in stream: {fault}")
99 yield chunk
102def measure(base_url, model, context_tokens, max_tokens, *, entry=None, timeout=3600):
103 """One streamed reply after the filler -> (stamps, state); no generated token is an error."""
104 body = {
105 "model": model,
106 "messages": [{"role": "user", "content": depth_prompt(context_tokens)}],
107 "max_tokens": max_tokens,
108 "stream": True,
109 "stream_options": {"include_usage": True},
110 }
111 stamps, state = [], {"usage": None, "finish_reason": None, "error": None}
112 t0 = time.monotonic()
113 try:
114 url = f"{base_url}/v1/chat/completions"
115 with post_json(url, body, entry=entry, stream=True, timeout=timeout) as r:
116 for chunk in _chunks(r.lines()):
117 state["usage"] = chunk.get("usage") or state["usage"]
118 for choice in chunk.get("choices") or []:
119 finish = choice.get("finish_reason")
120 state["finish_reason"] = finish or state["finish_reason"]
121 if any(delta_pieces(choice.get("delta") or {})):
122 stamps.append(time.monotonic() - t0)
123 except Exception as e: # a partial trace is still evidence; the error says why
124 state["error"] = _error(e)
125 if not stamps and not state["error"]:
126 # A lane that ignored "stream" answers one JSON body; that is no clean trace.
127 finish = state["finish_reason"]
128 state["error"] = f"no generated token arrived (finish_reason={finish!r})"
129 return stamps, state
132def trace_row(model, context_tokens, window, stamps, state, warmup):
133 """One report row: the scratch script's six fields first, then what it lacked."""
134 return {
135 "model": model,
136 "context_tokens_requested": context_tokens,
137 "usage": state["usage"],
138 "ttft_s": round(stamps[0], 2) if stamps else None,
139 "tokens": len(stamps),
140 "windows": windows(stamps, window),
141 "finish_reason": state["finish_reason"],
142 "error": state["error"],
143 "warmup": warmup,
144 # The raw stamps, so a trace can be re-cut at another window size.
145 "delta_times_s": [round(s, 3) for s in stamps],
146 }
149def summary_line(row):
150 """The console line: prefill at depth, then the window rates."""
151 prompt = (row["usage"] or {}).get("prompt_tokens")
152 ttft = row["ttft_s"]
153 prefill = f" ({prompt / ttft:.1f} tok/s prefill)" if prompt and ttft else ""
154 rates = [w["tok_per_s"] for w in row["windows"]]
155 line = f" TTFT {ttft} s after {prompt} prompt tokens{prefill}; "
156 line += f"{row['tokens']} tokens, windows {rates} tok/s"
157 return line + (f"\n ERROR {row['error']}" if row["error"] else "")
160def _parser():
161 ap = argparse.ArgumentParser(
162 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
163 )
164 ap.add_argument("--backend", default=None)
165 ap.add_argument("--base-url", default=None)
166 ap.add_argument("--model", default=None)
167 ap.add_argument(
168 "--context-tokens",
169 type=int,
170 default=8000,
171 help="about this many tokens of filler before the question (default "
172 "8000: 7154-7159 prompt tokens on the Qwen3 tokenizers)",
173 )
174 ap.add_argument("--max-tokens", type=int, default=1024)
175 ap.add_argument(
176 "--window", type=int, default=256, help="tokens per rate window (default 256)"
177 )
178 ap.add_argument(
179 "--rest",
180 type=float,
181 default=30,
182 help="seconds between the warm-up and the measured request, spent "
183 "reading the host load (default 30)",
184 )
185 ap.add_argument(
186 "--timeout",
187 type=int,
188 default=3600,
189 help="per-request timeout in seconds (default 3600: Ollama's 9B at 4 "
190 "threads took 611.6 s to its first token at ~7.2k tokens)",
191 )
192 ap.add_argument("--output", default=None, help="write the report as JSON")
193 return ap
196def main():
197 from orchestrant.benchmark.client import entry_config, run_start, write_report
198 from orchestrant.benchmark.openai_api import (
199 detect_model_via_api,
200 resolve_backend,
201 resolve_backend_entry,
202 resolve_model,
203 )
205 ap = _parser()
206 args = ap.parse_args()
207 if args.window < 1:
208 ap.error("--window must be at least 1")
209 base_url, backend_model, _ = resolve_backend(args.backend, args.base_url)
210 entry = resolve_backend_entry(args.backend, args.base_url)
211 detect = functools.partial(detect_model_via_api, base_url)
212 model = resolve_model(args.model, backend_model, entry, detect)
213 print(f"\n Depth trace: {model} @ {base_url}, ~{args.context_tokens} tokens\n")
214 warmup = warm(base_url, model, entry, args.timeout)
215 # The rest is also the host-load window: the load just before the request slows a CPU lane.
216 rested = time.monotonic()
217 started = run_start(TOOL_FILES, base_url, seconds=args.rest)
218 time.sleep(max(0.0, args.rest - (time.monotonic() - rested)))
219 stamps, state = measure(
220 base_url,
221 model,
222 args.context_tokens,
223 args.max_tokens,
224 entry=entry,
225 timeout=args.timeout,
226 )
227 row = trace_row(model, args.context_tokens, args.window, stamps, state, warmup)
228 # Written before anything that merely prints (write_report's rule).
229 if args.output:
230 prompt = depth_prompt(args.context_tokens).encode()
231 config = {
232 "context_tokens": args.context_tokens,
233 "max_tokens": args.max_tokens,
234 "window": args.window,
235 "rest_s": args.rest,
236 "timeout_s": args.timeout,
237 "filler_seed": FILLER_SEED,
238 "prompt_sha256": hashlib.sha256(prompt).hexdigest(),
239 "backend_entry": entry_config(entry),
240 }
241 label = args.backend or model
242 write_report(
243 args.output,
244 "bench_depth",
245 config,
246 [{"label": label, **row}],
247 base_url,
248 TOOL_FILES,
249 run_start=started,
250 )
251 print(summary_line(row))
252 if args.output:
253 print(f"\n Report written to {args.output}")
254 return 1 if row["error"] else 0
257if __name__ == "__main__":
258 sys.exit(main())