|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Price-performance benchmark for one encoder model served on CPU. |
| 3 | +
|
| 4 | +Sends the held-out messages to the model's chat endpoint from N concurrent |
| 5 | +clients (closed loop: each client sends the next message when the last one |
| 6 | +returns), at several concurrency levels. Before and after each level it reads |
| 7 | +the model pod's own /stats (process CPU seconds and requests served), so the |
| 8 | +cost is priced on the CPU the model actually used, not on what it requested: |
| 9 | +
|
| 10 | + CPU ms per request = CPU seconds used / requests served |
| 11 | + $ per 1M requests = CPU seconds per request x node $/hour / node vCPUs / 3600 x 1e6 |
| 12 | +
|
| 13 | +Runs in-cluster (scripts/run-bench-incluster.sh), next to the model, so a |
| 14 | +port-forward is not part of what is measured. |
| 15 | +""" |
| 16 | +import argparse |
| 17 | +import json |
| 18 | +import statistics |
| 19 | +import threading |
| 20 | +import time |
| 21 | + |
| 22 | +import httpx |
| 23 | + |
| 24 | + |
| 25 | +def pct(xs, p): |
| 26 | + xs = sorted(xs) |
| 27 | + return xs[min(len(xs) - 1, max(0, round(p * (len(xs) - 1))))] if xs else 0.0 |
| 28 | + |
| 29 | + |
| 30 | +def stats(client, base): |
| 31 | + return client.get(f"{base}/stats", timeout=10).json() |
| 32 | + |
| 33 | + |
| 34 | +def body(q): |
| 35 | + return {"model": "model", "messages": [{"role": "user", "content": q}], |
| 36 | + "max_tokens": 32, "temperature": 0.1} |
| 37 | + |
| 38 | + |
| 39 | +def run_level(base, queries, concurrency, duration): |
| 40 | + lat, errors, lock, nxt = [], [0], threading.Lock(), [0] |
| 41 | + stop_at = time.perf_counter() + duration |
| 42 | + |
| 43 | + def worker(): |
| 44 | + with httpx.Client(timeout=30) as c: |
| 45 | + while time.perf_counter() < stop_at: |
| 46 | + with lock: |
| 47 | + q = queries[nxt[0] % len(queries)] |
| 48 | + nxt[0] += 1 |
| 49 | + t0 = time.perf_counter() |
| 50 | + try: |
| 51 | + ok = c.post(f"{base}/v1/chat/completions", json=body(q)).status_code == 200 |
| 52 | + except Exception: |
| 53 | + ok = False |
| 54 | + ms = (time.perf_counter() - t0) * 1000 |
| 55 | + with lock: |
| 56 | + if ok: |
| 57 | + lat.append(ms) |
| 58 | + else: |
| 59 | + errors[0] += 1 |
| 60 | + |
| 61 | + with httpx.Client() as c: |
| 62 | + s0 = stats(c, base) |
| 63 | + t0 = time.perf_counter() |
| 64 | + threads = [threading.Thread(target=worker) for _ in range(concurrency)] |
| 65 | + for t in threads: |
| 66 | + t.start() |
| 67 | + for t in threads: |
| 68 | + t.join() |
| 69 | + wall = time.perf_counter() - t0 |
| 70 | + s1 = stats(c, base) |
| 71 | + served = max(1, s1["requests"] - s0["requests"]) |
| 72 | + cpu_s = s1["cpu_seconds"] - s0["cpu_seconds"] |
| 73 | + return {"concurrency": concurrency, "requests": len(lat), "errors": errors[0], |
| 74 | + "rps": len(lat) / wall, "p50_ms": pct(lat, 0.5), "p95_ms": pct(lat, 0.95), |
| 75 | + "cores_busy": cpu_s / wall, "cpu_ms_per_req": cpu_s * 1000 / served, |
| 76 | + "mean_ms": statistics.fmean(lat) if lat else 0.0} |
| 77 | + |
| 78 | + |
| 79 | +def main(): |
| 80 | + ap = argparse.ArgumentParser() |
| 81 | + ap.add_argument("--url", required=True, help="model base URL (no path)") |
| 82 | + ap.add_argument("--name", default="model") |
| 83 | + ap.add_argument("--queries", required=True, help="JSON list of messages") |
| 84 | + ap.add_argument("--levels", default="1,2,4,8") |
| 85 | + ap.add_argument("--duration", type=float, default=10) |
| 86 | + ap.add_argument("--instance-type", default="") |
| 87 | + ap.add_argument("--vcpus", type=float, default=0) |
| 88 | + ap.add_argument("--usd-per-hour", type=float, default=0) |
| 89 | + ap.add_argument("--cpu-request", type=float, default=0, help="the pod's CPU request") |
| 90 | + args = ap.parse_args() |
| 91 | + |
| 92 | + queries = json.load(open(args.queries)) |
| 93 | + with httpx.Client() as c: |
| 94 | + info = stats(c, args.url) |
| 95 | + for q in queries[:20]: # warm the inference path |
| 96 | + c.post(f"{args.url}/v1/chat/completions", json=body(q), timeout=30) |
| 97 | + |
| 98 | + usd_per_vcpu_s = (args.usd_per_hour / args.vcpus / 3600) if args.usd_per_hour and args.vcpus else 0 |
| 99 | + print(f"Model: {args.name} precision {info.get('precision') or '?'}, " |
| 100 | + f"{info.get('intra_op_threads') or 'default'} inference threads, CPU request {args.cpu_request:g}") |
| 101 | + if usd_per_vcpu_s: |
| 102 | + print(f"Node: {args.instance_type} ({args.vcpus:g} vCPUs), on-demand ${args.usd_per_hour:.4f}/hour " |
| 103 | + f"= ${args.usd_per_hour / args.vcpus:.4f} per vCPU-hour") |
| 104 | + print(f"{len(queries)} held-out messages, {args.duration:g} s per level\n") |
| 105 | + print(f" {'concurrent':>10} {'req/s':>7} {'p50 ms':>7} {'p95 ms':>7} {'cores busy':>10} " |
| 106 | + f"{'CPU ms/req':>10} {'$ per 1M':>9}") |
| 107 | + rows = [] |
| 108 | + for lvl in [int(x) for x in args.levels.split(",")]: |
| 109 | + r = run_level(args.url, queries, lvl, args.duration) |
| 110 | + r["usd_per_1m"] = r["cpu_ms_per_req"] / 1000 * usd_per_vcpu_s * 1e6 if usd_per_vcpu_s else None |
| 111 | + rows.append(r) |
| 112 | + usd = f"${r['usd_per_1m']:.2f}" if r["usd_per_1m"] is not None else "n/a" |
| 113 | + print(f" {lvl:>10} {r['rps']:>7.1f} {r['p50_ms']:>7.1f} {r['p95_ms']:>7.1f} " |
| 114 | + f"{r['cores_busy']:>10.2f} {r['cpu_ms_per_req']:>10.1f} {usd:>9}", flush=True) |
| 115 | + |
| 116 | + cpu_ms = statistics.median(r["cpu_ms_per_req"] for r in rows) |
| 117 | + one = rows[0] |
| 118 | + summary = {"name": args.name, "precision": info.get("precision"), |
| 119 | + "threads": info.get("intra_op_threads"), "instance_type": args.instance_type, |
| 120 | + "usd_per_hour": args.usd_per_hour, "vcpus": args.vcpus, |
| 121 | + "cpu_request": args.cpu_request, "p50_ms_single": one["p50_ms"], |
| 122 | + "p95_ms_single": one["p95_ms"], "cpu_ms_per_req": cpu_ms, |
| 123 | + "usd_per_1m": cpu_ms / 1000 * usd_per_vcpu_s * 1e6 if usd_per_vcpu_s else None, |
| 124 | + "max_rps": max(r["rps"] for r in rows), "levels": rows} |
| 125 | + if args.cpu_request: |
| 126 | + # What one pod sized at its request can sustain, priced on that slice. |
| 127 | + summary["per_pod_per_hour"] = args.cpu_request * 3600 / (cpu_ms / 1000) |
| 128 | + summary["pod_usd_per_hour"] = args.cpu_request * args.usd_per_hour / args.vcpus if args.vcpus else None |
| 129 | + print() |
| 130 | + line = (f"=== Bench [{args.name} {summary['precision']}]: p50 {one['p50_ms']:.0f} ms one at a time, " |
| 131 | + f"{cpu_ms:.0f} CPU ms per request") |
| 132 | + if summary["usd_per_1m"] is not None: |
| 133 | + line += f", ${summary['usd_per_1m']:.2f} per 1M requests" |
| 134 | + print(line + " ===") |
| 135 | + if summary.get("per_pod_per_hour"): |
| 136 | + print(f" one pod at its {args.cpu_request:g}-vCPU request: about {summary['per_pod_per_hour']:,.0f} " |
| 137 | + f"requests per hour" + (f" for ${summary['pod_usd_per_hour']:.3f}/hour" |
| 138 | + if summary.get("pod_usd_per_hour") else "")) |
| 139 | + print("===BENCH_JSON_BEGIN===") |
| 140 | + print(json.dumps(summary)) |
| 141 | + print("===BENCH_JSON_END===") |
| 142 | + |
| 143 | + |
| 144 | +if __name__ == "__main__": |
| 145 | + main() |
0 commit comments