Coverage for orchestrant/benchmark/lanes.py: 86%

207 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-18 00:48 +0000

1#!/usr/bin/env python3 

2"""Concurrency benchmarks across one or more serving endpoints. 

3 

4--batching: does ONE server overlap concurrent requests? 

5--lanes: do several servers ADD UP, or fight each other? 

6 

7Usage: 

8 # does this server batch? 

9 python3 bench_lanes.py --batching --backend ollama 

10 

11 # do these lanes add up? (names come from backends.json) 

12 python3 bench_lanes.py --lanes geniex-npu geniex-cpu 

13 

14 # ...or spell an endpoint out in full 

15 python3 bench_lanes.py --lanes \ 

16 npu=http://127.0.0.1:18181,model=qualcomm/Qwen3-4B-Instruct-2507:W4A16 

17""" 

18 

19import argparse 

20import json 

21import random 

22import threading 

23import time 

24import urllib.request 

25 

26from orchestrant.benchmark.answers import delta_pieces 

27 

28 

29DEFAULT_PROMPT = ( 

30 "Write a Python function that merges two sorted lists. Explain briefly." 

31) 

32 

33 

34def stream_once(base_url, model, prompt, max_tokens=256, timeout=900, deadline=900): 

35 """One streaming request; returns a timing dict and never raises. 

36 

37 `timeout` applies per socket read; `deadline` bounds the whole request (`gave_up`). 

38 """ 

39 body = json.dumps( 

40 { 

41 "model": model, 

42 "stream": True, 

43 "max_tokens": max_tokens, 

44 "temperature": 0, 

45 "messages": [{"role": "user", "content": prompt}], 

46 } 

47 ).encode() 

48 req = urllib.request.Request( 

49 f"{base_url}/v1/chat/completions", 

50 data=body, 

51 headers={"Content-Type": "application/json"}, 

52 ) 

53 

54 started = time.monotonic() 

55 ttft = None 

56 tokens = 0 

57 gave_up = False 

58 try: 

59 with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310 -- endpoint URL from config 

60 for raw in resp: 

61 if deadline and time.monotonic() - started > deadline: 

62 gave_up = True 

63 break 

64 line = raw.decode("utf-8", "replace").strip() 

65 # The space after "data:" is optional per the SSE spec. 

66 if not line.startswith("data:"): 

67 continue 

68 payload = line[5:].lstrip() 

69 if payload == "[DONE]": 

70 break 

71 try: 

72 chunk = json.loads(payload) 

73 except json.JSONDecodeError: 

74 continue 

75 choices = chunk.get("choices") or [] 

76 # Thinking is output too: uncounted, TTFT would read as the whole thought. 

77 if choices and any(delta_pieces(choices[0].get("delta") or {})): 

78 if ttft is None: 

79 ttft = time.monotonic() - started 

80 tokens += 1 

81 except Exception as e: 

82 return { 

83 "error": f"{type(e).__name__}: {e}", 

84 "wall_s": time.monotonic() - started, 

85 } 

86 

87 wall = time.monotonic() - started 

88 decode_window = wall - (ttft or 0) 

89 return { 

90 "ttft_s": round(ttft, 3) if ttft is not None else None, 

91 "gave_up": gave_up, 

92 "tokens": tokens, 

93 "wall_s": round(wall, 2), 

94 "decode_tok_per_sec": round((tokens - 1) / decode_window, 2) 

95 if decode_window > 0 and tokens > 1 

96 else 0.0, 

97 } 

98 

99 

100def _secs(value, width=6): 

101 """Format a possibly-missing duration: a lane past its context returns 200 and no tokens.""" 

102 return ( 

103 f"{value:{width}.2f}" 

104 if isinstance(value, (int, float)) 

105 else f"{'n/a':>{width}}" 

106 ) 

107 

108 

109def _fresh(prompt): 

110 """The prompt with a nonce: a repeated prompt is served from the runtime's cache.""" 

111 return f"{prompt} (request {random.SystemRandom().randrange(10**6)})" 

112 

113 

114def run_parallel(jobs): 

115 """Run (label, fn) jobs at the same time; return {label: result}.""" 

116 out = {} 

117 lock = threading.Lock() 

118 

119 def work(label, fn): 

120 result = fn() 

121 with lock: 

122 out[label] = result 

123 

124 threads = [threading.Thread(target=work, args=(lbl, fn)) for lbl, fn in jobs] 

125 wall_start = time.monotonic() 

126 for t in threads: 

127 t.start() 

128 for t in threads: 

129 t.join() 

130 return out, time.monotonic() - wall_start 

131 

132 

133def probe_batching(base_url, model, prompt, max_tokens): 

134 """Decide whether one endpoint overlaps two concurrent requests.""" 

135 print(f"\n Batching probe: two concurrent requests to {base_url}") 

136 results, wall = run_parallel( 

137 [ 

138 (f"req{i}", (lambda: stream_once(base_url, model, prompt, max_tokens))) 

139 for i in range(2) 

140 ] 

141 ) 

142 

143 bad = [k for k, v in results.items() if "error" in v] 

144 if bad: 

145 for k in bad: 

146 print(f" {k}: ERROR {results[k]['error']}") 

147 return None 

148 

149 if any(r.get("ttft_s") is None for r in results.values()): 

150 print( 

151 " VERDICT: inconclusive — a request returned no tokens at all " 

152 "(past a context limit?), so there is no first-token time to compare" 

153 ) 

154 return { 

155 "verdict": "inconclusive", 

156 "serialised": None, 

157 "wall_s": round(wall, 2), 

158 "requests": results, 

159 } 

160 ordered = sorted(results.values(), key=lambda r: r["ttft_s"]) 

161 first, second = ordered[0], ordered[1] 

162 for name, r in sorted(results.items()): 

163 print( 

164 f" {name}: ttft={_secs(r['ttft_s'])}s " 

165 f"{_secs(r['decode_tok_per_sec'])} tok/s wall={_secs(r['wall_s'])}s" 

166 ) 

167 

168 # A second TTFT near the first's wall time means the server ran them in turn. 

169 serialised = (second["ttft_s"] or 0) >= first["wall_s"] * 0.8 

170 verdict = "SERIALISED (no batching)" if serialised else "OVERLAPPED (batching)" 

171 print(f" wall for both: {wall:.2f}s") 

172 print(f" VERDICT: {verdict}") 

173 if serialised: 

174 print(" -> extra throughput needs MORE SERVERS, not more clients.") 

175 return { 

176 "verdict": verdict, 

177 "serialised": serialised, 

178 "wall_s": round(wall, 2), 

179 "requests": results, 

180 } 

181 

182 

183def _serving(lanes): 

184 """Each lane's build, serve flags and model files, before measuring; {name: runtime}.""" 

185 from orchestrant.benchmark.provenance import lane_runtimes, runtime_label 

186 

187 runtimes = lane_runtimes(lanes) 

188 print("\n Serving:") 

189 for name, runtime in runtimes.items(): 

190 print(f" {name:10s} {runtime_label(runtime)}") 

191 return runtimes 

192 

193 

194def run_lanes(lanes, prompt, max_tokens, sequential_baseline=True): 

195 """Per-lane and aggregate throughput when lanes run together.""" 

196 report = {"lanes": {}, "baseline": {}, "runtimes": _serving(lanes)} 

197 

198 if sequential_baseline: 

199 print("\n Baseline — each lane alone:") 

200 for name, (url, model) in lanes.items(): 

201 r = stream_once(url, model, _fresh(prompt), max_tokens) 

202 report["baseline"][name] = r 

203 if "error" in r: 

204 print(f" {name:10s} ERROR {r['error']}") 

205 else: 

206 print( 

207 f" {name:10s} {_secs(r['decode_tok_per_sec'])} tok/s " 

208 f"ttft={_secs(r['ttft_s'])}s" 

209 ) 

210 

211 print("\n Together — all lanes at once:") 

212 results, wall = run_parallel( 

213 [ 

214 ( 

215 name, 

216 (lambda u=url, m=model: stream_once(u, m, _fresh(prompt), max_tokens)), 

217 ) 

218 for name, (url, model) in lanes.items() 

219 ] 

220 ) 

221 report["lanes"] = results 

222 

223 total, tokens = 0.0, 0 

224 for name in lanes: 

225 r = results[name] 

226 if "error" in r: 

227 print(f" {name:10s} ERROR {r['error']}") 

228 continue 

229 total += r["decode_tok_per_sec"] 

230 tokens += r["tokens"] 

231 base = report["baseline"].get(name, {}).get("decode_tok_per_sec") 

232 delta = "" 

233 if base: 

234 change = 100 * (r["decode_tok_per_sec"] - base) / base 

235 delta = f" ({change:+.0f}% vs alone)" 

236 print( 

237 f" {name:10s} {_secs(r['decode_tok_per_sec'])} tok/s " 

238 f"ttft={_secs(r['ttft_s'])}s{delta}" 

239 ) 

240 

241 best_alone = max( 

242 (v.get("decode_tok_per_sec", 0) for v in report["baseline"].values()), default=0 

243 ) 

244 # Tokens over the joint wall: a per-lane sum counts an early finisher's solo seconds. 

245 delivered = tokens / wall if wall > 0 else 0.0 

246 print( 

247 f"\n AGGREGATE: {total:.1f} tok/s summed over {len(lanes)} lanes; " 

248 f"delivered {delivered:.1f} tok/s ({tokens} tokens in the {wall:.1f}s wall)" 

249 ) 

250 if best_alone: 

251 print( 

252 f" Best single lane alone: {best_alone:.1f} tok/s " 

253 f"-> {delivered / best_alone:.2f}x delivered by running lanes together " 

254 f"({total / best_alone:.2f}x by the sum of rates)" 

255 ) 

256 print( 

257 " NOTE: aggregate only materialises with that many CONCURRENT " 

258 "requests.\n One agent waiting for one answer still sees a " 

259 "single lane's speed." 

260 ) 

261 report["aggregate_tok_per_sec"] = round(total, 2) 

262 report["delivered_tok_per_sec"] = round(delivered, 2) 

263 report["wall_s"] = round(wall, 2) 

264 return report 

265 

266 

267def sweep_nctx(base_url_template, model, values, prompt, max_tokens=256): 

268 """Does --nctx change anything but memory? No CLI flag reaches this; call it from a script.""" 

269 print("\n nctx sweep (restart the lane between values):", flush=True) 

270 rows = [] 

271 for value in values: 

272 r = stream_once(base_url_template, model, prompt, max_tokens) 

273 if "error" in r: 

274 print(f" nctx={value:6d} ERROR {r['error'][:50]}", flush=True) 

275 rows.append({"nctx": value, "error": r["error"][:120]}) 

276 continue 

277 print( 

278 f" nctx={value:6d} {r['decode_tok_per_sec']:6.2f} tok/s " 

279 f"ttft={_secs(r['ttft_s'])}s", 

280 flush=True, 

281 ) 

282 rows.append({"nctx": value, **r}) 

283 return rows 

284 

285 

286def parse_lane(spec): 

287 """Parse 'name=URL,model=MODEL' into (name, url, model).""" 

288 try: 

289 head, model_part = spec.split(",model=", 1) 

290 name, url = head.split("=", 1) 

291 except ValueError: 

292 raise argparse.ArgumentTypeError( 

293 f"bad lane {spec!r}; expected name=URL,model=MODEL" 

294 ) 

295 return name.strip(), url.strip().rstrip("/"), model_part.strip() 

296 

297 

298def resolve_lane(spec, path=None): 

299 """Accept a full 'name=URL,model=MODEL' spec or a bare backend name from `path`'s registry.""" 

300 if "=" in spec: 

301 return parse_lane(spec) 

302 

303 from orchestrant.benchmark.openai_api import load_backends 

304 

305 backends, _ = load_backends(path) 

306 if spec not in backends: 

307 known = ", ".join(sorted(backends)) or "(none configured)" 

308 raise argparse.ArgumentTypeError( 

309 f"unknown backend {spec!r}. Known: {known}. " 

310 "Or give a full spec: name=URL,model=MODEL" 

311 ) 

312 entry = backends[spec] 

313 model = entry.get("model") 

314 if not model: 

315 raise argparse.ArgumentTypeError( 

316 f"backend {spec!r} has no default model in backends.json; " 

317 f"use {spec}={entry['base_url']},model=<model> instead" 

318 ) 

319 return spec, entry["base_url"].rstrip("/"), model 

320 

321 

322def build_reports(batching=None, batching_endpoint=None, lane_run=None, lanes=None): 

323 """Shape the probes' output as the shared envelope's reports[] rows; none is a score.""" 

324 reports = [] 

325 if batching_endpoint is not None: 

326 url, model = batching_endpoint 

327 row = {"label": "batching", "model": model, "base_url": url} 

328 row.update(batching or {"verdict": "error", "serialised": None, "requests": {}}) 

329 reports.append(row) 

330 if lane_run is not None: 

331 for name, (url, model) in (lanes or {}).items(): 

332 together = lane_run["lanes"].get(name, {}) 

333 reports.append( 

334 { 

335 "label": name, 

336 "model": model, 

337 "base_url": url, 

338 "alone": lane_run["baseline"].get(name), 

339 "together": together, 

340 "tok_per_sec": together.get("decode_tok_per_sec"), 

341 "runtime": (lane_run.get("runtimes") or {}).get(name), 

342 } 

343 ) 

344 reports.append( 

345 { 

346 "label": "aggregate", 

347 "model": None, 

348 "base_url": None, 

349 # Not sorted(lanes or {}): Cython 3.3's sorted() optimisation crashes on it. 

350 "lanes": sorted(lanes) if lanes else [], 

351 # bench_compare judges tokens over the joint wall; older reports carry the sum. 

352 "tok_per_sec": lane_run.get( 

353 "delivered_tok_per_sec", lane_run["aggregate_tok_per_sec"] 

354 ), 

355 "summed_tok_per_sec": lane_run["aggregate_tok_per_sec"], 

356 "wall_s": lane_run["wall_s"], 

357 } 

358 ) 

359 return reports 

360 

361 

362def main(): 

363 ap = argparse.ArgumentParser( 

364 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter 

365 ) 

366 ap.add_argument( 

367 "--batching", 

368 action="store_true", 

369 help="LB5: does one endpoint overlap concurrent requests?", 

370 ) 

371 ap.add_argument( 

372 "--lanes", 

373 nargs="+", 

374 metavar="BACKEND|name=URL,model=MODEL", 

375 help="LB4: drive these endpoints simultaneously. Either a " 

376 "backend name from backends.json (e.g. geniex-npu) or " 

377 "a full name=URL,model=MODEL spec.", 

378 ) 

379 ap.add_argument( 

380 "--base-url", 

381 "--endpoint", 

382 dest="base_url", 

383 default=None, 

384 help="Endpoint URL for --batching (overrides --backend). " 

385 "--endpoint is kept as an alias: it was the original " 

386 "spelling here while both sibling tools used " 

387 "--base-url, and an operator scripting the suite had " 

388 "to remember which tool wanted which.", 

389 ) 

390 ap.add_argument( 

391 "--backend", 

392 default=None, 

393 help="Named backend from backends.json for --batching", 

394 ) 

395 ap.add_argument("--model", default=None, help="Model for --batching") 

396 ap.add_argument("--prompt", default=DEFAULT_PROMPT) 

397 ap.add_argument("--max-tokens", type=int, default=256) 

398 ap.add_argument( 

399 "--no-baseline", 

400 action="store_true", 

401 help="Skip the one-lane-at-a-time baseline", 

402 ) 

403 ap.add_argument("--output", default=None, help="Write the report as JSON") 

404 args = ap.parse_args() 

405 

406 if not args.batching and not args.lanes: 

407 ap.error("nothing to do: pass --batching and/or --lanes") 

408 

409 from orchestrant.benchmark.client import run_start, write_report 

410 

411 batching = endpoint = lane_run = lanes = None 

412 

413 # Resolved first, so the start record can subtract the measured lane from the host load. 

414 if args.batching: 

415 from orchestrant.benchmark.openai_api import ( 

416 detect_model_via_api, 

417 resolve_backend, 

418 ) 

419 

420 url, backend_model, source = resolve_backend(args.backend, args.base_url) 

421 model = args.model or backend_model or detect_model_via_api(url) 

422 print(f" Endpoint: {url} (from {source})") 

423 endpoint = (url, model) 

424 if args.lanes: 

425 lanes = { 

426 name: (url, model) for name, url, model in map(resolve_lane, args.lanes) 

427 } 

428 base_url = endpoint[0] if endpoint else next(iter((lanes or {}).values()))[0] 

429 tool_files = ("lanes.py", "answers.py") 

430 run = run_start(tool_files, base_url) 

431 

432 if endpoint: 

433 batching = probe_batching(*endpoint, args.prompt, args.max_tokens) 

434 if lanes: 

435 lane_run = run_lanes( 

436 lanes, 

437 args.prompt, 

438 args.max_tokens, 

439 sequential_baseline=not args.no_baseline, 

440 ) 

441 

442 print() 

443 if args.output: 

444 # The shared envelope, so bench_report labels it and bench_compare can diff it. 

445 write_report( 

446 args.output, 

447 "bench_lanes", 

448 { 

449 "prompt": args.prompt, 

450 "max_tokens": args.max_tokens, 

451 "baseline": not args.no_baseline, 

452 }, 

453 build_reports(batching, endpoint, lane_run, lanes), 

454 base_url, 

455 tool_files, 

456 run_start=run, 

457 ) 

458 print(f" Report written to {args.output}") 

459 

460 

461if __name__ == "__main__": 

462 main()