bench.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. #!/usr/bin/env python3
  2. """Decode benchmark with repeated runs, warmups, run tags and error bars.
  3. bench.py run [--label L] [--series NAME] [--runs 16] [--batches 8] [--warmup 2] [--out FILE]
  4. bench.py compare A.json B.json
  5. run: every workload gets --warmup unrecorded runs, then its recorded runs. Recorded run i
  6. of a workload always sends the same prompt with the same run tag, both fixed by the series
  7. name, so two setups measured in one series can be paired run by run. The tag keeps a
  8. server from answering a repeat from its prefix cache.
  9. code, prose, structured one stream, 512 tokens, a different prompt each run (prompts.py)
  10. counting one stream, count to 200; any wrong line fails the run
  11. mixed4, mixed8 4 or 8 concurrent streams of code, prose and structured, one
  12. batch per run (--batches)
  13. Each run records its output tokens, time to first token, decode time (first token to last)
  14. and the speculative-decoding counters from /metrics. Those split decode speed into tokens
  15. accepted per verify step, which depends on how easy this output was to draft, and time per
  16. step. Time per step also grows with the drafts verified per step, which adaptive-k chooses,
  17. so both are reported.
  18. For each workload, run prints output tokens / decode time over all runs with a bootstrap
  19. 95% interval, the run-to-run spread, and the smallest difference that an unpaired
  20. comparison of this many runs would detect (95% confidence, 80% power). The spread includes
  21. the difference between prompts, which pairing removes, so a paired compare detects smaller
  22. differences than this. It flags outlier runs by modified Z-score (above 3.5).
  23. compare pairs the two files' runs by workload and index, and reports B / A as a geometric
  24. mean ratio with a bootstrap 95% interval and a two-sided Wilcoxon signed-rank p-value,
  25. Holm-corrected across workloads.
  26. """
  27. import argparse, hashlib, json, math, os, random, re, statistics, sys, threading, time, urllib.request
  28. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # gate/prompts.py
  29. from prompts import prompt_for
  30. BASE = os.environ.get("GATE_URL", "http://127.0.0.1:8002")
  31. MODEL = os.environ.get("GATE_MODEL", "glm53")
  32. SYSTEM = "Answer the request. Ignore the run tag."
  33. COUNT = "Count from 1 to 200, one number per line, digits only."
  34. SINGLE = {"code": 512, "prose": 512, "structured": 512, "counting": 768}
  35. MIXED = {"mixed4": 4, "mixed8": 8}
  36. BOOT = 2000
  37. Z95, Z80 = 1.96, 0.8416
  38. # Hangul and the replacement character: GLM-5.3 corruption has shown up as both.
  39. SUSPECT = re.compile("[가-힣�]")
  40. def run_tag(series, work, i):
  41. return hashlib.blake2b(f"{series}/{work}/{i}".encode(), digest_size=6).hexdigest()
  42. def metrics():
  43. raw = urllib.request.urlopen(BASE + "/metrics", timeout=30).read().decode()
  44. get = lambda k: sum(float(v) for v in re.findall(rf"^vllm:{k}\S*\s+([0-9.e+]+)$", raw, re.M))
  45. return (get("spec_decode_num_accepted_tokens_total"), get("spec_decode_num_drafts_total"),
  46. get("spec_decode_num_draft_tokens_total"))
  47. def stream(prompt, tag, max_tokens):
  48. """One streamed request: output tokens, send and first-token and last times, text."""
  49. body = json.dumps({"model": MODEL, "max_tokens": max_tokens, "temperature": 0, "stream": True,
  50. "chat_template_kwargs": {"thinking": False, "enable_thinking": False},
  51. "stream_options": {"include_usage": True},
  52. "messages": [{"role": "system", "content": SYSTEM},
  53. {"role": "user", "content": f"Run tag {tag}.\n\n{prompt}"}]}).encode()
  54. req = urllib.request.Request(BASE + "/v1/chat/completions", data=body, headers={"Content-Type": "application/json"})
  55. sent = time.monotonic()
  56. first, tokens, text = None, 0, []
  57. with urllib.request.urlopen(req, timeout=900) as r:
  58. for line in r:
  59. if not line.startswith(b"data: {"):
  60. continue
  61. d = json.loads(line[6:])
  62. if d.get("choices"):
  63. delta = d["choices"][0].get("delta", {})
  64. piece = delta.get("content") or delta.get("reasoning") or ""
  65. if piece and first is None:
  66. first = time.monotonic()
  67. text.append(delta.get("content") or "")
  68. if d.get("usage"):
  69. tokens = d["usage"]["completion_tokens"]
  70. return tokens, sent, first, time.monotonic(), "".join(text)
  71. def check(work, text):
  72. if SUSPECT.search(text):
  73. return "suspect characters"
  74. if work == "counting" and [l.strip() for l in text.strip().split("\n")] != [str(k) for k in range(1, 201)]:
  75. return "wrong count"
  76. return "" if text.strip() else "empty"
  77. def run_single(work, series, i, warm=False):
  78. tag = run_tag(series, work, f"warm{i}" if warm else i)
  79. prompt = COUNT if work == "counting" else prompt_for(work, i)
  80. a0, d0, k0 = metrics()
  81. tokens, sent, first, end, text = stream(prompt, tag, SINGLE[work])
  82. a1, d1, k1 = metrics()
  83. decode = end - first if first else float("nan")
  84. steps = d1 - d0 or max(tokens - 1, 1) # no speculative decoding: one token per step
  85. return {"work": work, "i": i, "tag": tag, "tokens": tokens, "ttft": first - sent if first else None,
  86. "decode_s": decode, "tps": tokens / decode if decode > 0 else 0.0,
  87. "tok_per_step": 1 + (a1 - a0) / steps if d1 > d0 else 1.0, "ms_per_step": 1000 * decode / steps,
  88. "drafts_per_step": (k1 - k0) / steps if d1 > d0 else 0.0, "problem": check(work, text)}
  89. def run_mixed(work, series, b, warm=False):
  90. n = MIXED[work]
  91. jobs = [(prompt_for("mixed", b * n + s), run_tag(series, work, f"warm{b}.{s}" if warm else f"{b}.{s}")) for s in range(n)]
  92. out = [None] * n
  93. def go(s):
  94. out[s] = stream(jobs[s][0], jobs[s][1], 512)
  95. a0, d0, k0 = metrics()
  96. ts = [threading.Thread(target=go, args=(s,)) for s in range(n)]
  97. for t in ts: t.start()
  98. for t in ts: t.join()
  99. a1, d1, k1 = metrics()
  100. t0 = min(o[2] for o in out if o[2]); t1 = max(o[3] for o in out)
  101. tokens = sum(o[0] for o in out)
  102. problems = [p for p in (check("mixed", o[4]) for o in out) if p]
  103. return {"work": work, "i": b, "tag": ",".join(j[1] for j in jobs), "tokens": tokens,
  104. "ttft": statistics.median(o[2] - o[1] for o in out if o[2]), "decode_s": t1 - t0, "tps": tokens / (t1 - t0),
  105. "per_stream": statistics.median(o[0] / (o[3] - o[2]) for o in out if o[2]),
  106. "tok_per_step": 1 + (a1 - a0) / (d1 - d0) if d1 > d0 else 1.0,
  107. "drafts_per_step": (k1 - k0) / (d1 - d0) if d1 > d0 else 0.0, "problem": "; ".join(problems)}
  108. def bootstrap(xs, stat, rng):
  109. vals = sorted(stat([rng.choice(xs) for _ in xs]) for _ in range(BOOT))
  110. return vals[int(0.025 * BOOT)], vals[int(0.975 * BOOT) - 1]
  111. def modified_z(xs):
  112. med = statistics.median(xs)
  113. mad = statistics.median(abs(x - med) for x in xs)
  114. return [0.0 if mad == 0 else 0.6745 * (x - med) / mad for x in xs]
  115. def summarize(runs):
  116. rng = random.Random(0)
  117. rate = lambda rs: sum(r["tokens"] for r in rs) / sum(r["decode_s"] for r in rs)
  118. rates = [r["tps"] for r in runs]
  119. cv = statistics.stdev(rates) / statistics.mean(rates) if len(rates) > 1 else float("nan")
  120. lo, hi = bootstrap(runs, rate, rng)
  121. return {"runs": len(runs), "tps": rate(runs), "ci": [lo, hi], "cv": cv,
  122. "detects": (Z95 + Z80) * math.sqrt(2) * cv / math.sqrt(len(runs)),
  123. "tok_per_step": statistics.median(r["tok_per_step"] for r in runs),
  124. "drafts_per_step": statistics.median(r["drafts_per_step"] for r in runs),
  125. "ms_per_step": statistics.median(r["ms_per_step"] for r in runs) if "ms_per_step" in runs[0] else None,
  126. "ttft_ms": 1000 * statistics.median(r["ttft"] for r in runs if r["ttft"] is not None),
  127. "outliers": [r["i"] for r, z in zip(runs, modified_z(rates)) if abs(z) > 3.5],
  128. "problems": sum(1 for r in runs if r["problem"])}
  129. def cmd_run(a):
  130. works = [w for w in a.workloads.split(",")]
  131. stream("hi", "warm", 4)
  132. res = {"label": a.label, "series": a.series, "base": BASE, "model": MODEL,
  133. "started": time.strftime("%Y-%m-%dT%H:%M:%S"), "runs": [], "summary": {}}
  134. for w in works:
  135. run, count = (run_single, a.runs) if w in SINGLE else (run_mixed, a.batches)
  136. for j in range(a.warmup):
  137. run(w, a.series, j, warm=True)
  138. rs = []
  139. for i in range(count):
  140. rs.append(run(w, a.series, i))
  141. if rs[-1]["problem"]:
  142. print(f" {w} run {i}: {rs[-1]['problem']}", flush=True)
  143. res["runs"] += rs
  144. s = res["summary"][w] = summarize(rs)
  145. step = f"{s['ms_per_step']:6.1f}" if s["ms_per_step"] else " -"
  146. print(f"{w:<11}{s['runs']:>5} {s['tps']:7.1f} [{s['ci'][0]:6.1f}, {s['ci'][1]:6.1f}] {100 * s['cv']:5.1f}%"
  147. f" {100 * s['detects']:5.1f}% {s['tok_per_step']:5.2f} {s['drafts_per_step']:5.2f} {step} {s['ttft_ms']:6.0f}"
  148. f" {s['problems']} failed" + (f", outliers {s['outliers']}" if s["outliers"] else ""), flush=True)
  149. with open(a.out, "w") as f:
  150. json.dump(res, f, indent=1)
  151. def wilcoxon(ds):
  152. """Two-sided exact Wilcoxon signed-rank p-value; zero differences are dropped."""
  153. ds = [d for d in ds if d != 0]
  154. n = len(ds)
  155. if n == 0:
  156. return 1.0
  157. order = sorted(range(n), key=lambda k: abs(ds[k]))
  158. ranks = [0] * n
  159. k = 0
  160. while k < n: # ties share the average rank; doubled ranks stay integers
  161. j = k
  162. while j + 1 < n and abs(ds[order[j + 1]]) == abs(ds[order[k]]):
  163. j += 1
  164. for m in range(k, j + 1):
  165. ranks[order[m]] = k + j + 2
  166. k = j + 1
  167. w = sum(r for r, d in zip(ranks, ds) if d > 0)
  168. counts = {0: 1} # number of sign patterns giving each positive-rank sum
  169. for r in ranks:
  170. nxt = dict(counts)
  171. for s, c in counts.items():
  172. nxt[s + r] = nxt.get(s + r, 0) + c
  173. counts = nxt
  174. total = 2 ** n
  175. below = sum(c for s, c in counts.items() if s <= w) / total
  176. above = sum(c for s, c in counts.items() if s >= w) / total
  177. return min(1.0, 2 * min(below, above))
  178. def holm(ps):
  179. order = sorted(range(len(ps)), key=lambda k: ps[k])
  180. adj, worst = [0.0] * len(ps), 0.0
  181. for rank, k in enumerate(order):
  182. worst = max(worst, min(1.0, (len(ps) - rank) * ps[k]))
  183. adj[k] = worst
  184. return adj
  185. def cmd_compare(a):
  186. A, B = (json.load(open(p)) for p in (a.a, a.b))
  187. if A["series"] != B["series"]:
  188. sys.exit("the files belong to different series, so their runs cannot be paired")
  189. rng = random.Random(0)
  190. rows = []
  191. for w in A["summary"]:
  192. if w not in B["summary"]:
  193. continue
  194. ra = {r["i"]: r for r in A["runs"] if r["work"] == w and not r["problem"]}
  195. rb = {r["i"]: r for r in B["runs"] if r["work"] == w and not r["problem"]}
  196. idx = sorted(set(ra) & set(rb))
  197. ratio = lambda key: [math.log(rb[i][key] / ra[i][key]) for i in idx if rb[i].get(key) and ra[i].get(key)]
  198. ds = ratio("tps")
  199. if len(ds) < 2:
  200. print(f"{w:<11}{len(ds):>5} too few paired runs")
  201. continue
  202. lo, hi = bootstrap(ds, statistics.mean, rng)
  203. rows.append([w, len(idx), math.exp(statistics.mean(ds)), math.exp(lo), math.exp(hi), wilcoxon(ds),
  204. math.exp(statistics.mean(ratio("tok_per_step"))),
  205. math.exp(statistics.mean(ratio("ms_per_step"))) if ra[idx[0]].get("ms_per_step") else None])
  206. for row, p in zip(rows, holm([r[5] for r in rows])):
  207. w, n, g, lo, hi, _, step_ratio, ms = row
  208. verdict = "differs" if p < 0.05 else "no detectable difference"
  209. step = f"{ms:6.3f}" if ms else " -"
  210. print(f"{w:<11}{n:>5} {g:6.3f} [{lo:6.3f}, {hi:6.3f}] p {p:6.4f} {step_ratio:6.3f} {step} {verdict}")
  211. def main():
  212. ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
  213. sub = ap.add_subparsers(dest="cmd", required=True)
  214. r = sub.add_parser("run")
  215. r.add_argument("--label", default="")
  216. r.add_argument("--series", default="glm53-bench-1")
  217. r.add_argument("--runs", type=int, default=16)
  218. r.add_argument("--batches", type=int, default=8)
  219. r.add_argument("--warmup", type=int, default=2)
  220. r.add_argument("--workloads", default="code,prose,structured,counting,mixed4,mixed8")
  221. r.add_argument("--out", default="bench.json")
  222. c = sub.add_parser("compare")
  223. c.add_argument("a")
  224. c.add_argument("b")
  225. a = ap.parse_args()
  226. if a.cmd == "run":
  227. print(f"{'workload':<11} runs tok/s 95% interval spread detects tok/step drafts/step ms/step TTFT ms")
  228. cmd_run(a)
  229. else:
  230. print(f"{'workload':<11} pairs B / A 95% interval p (Holm) tok/step ms/step")
  231. cmd_compare(a)
  232. if __name__ == "__main__":
  233. main()