openai_serving.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. #!/usr/bin/env python3
  2. """OpenAI-compatible serving matrix for a live DeepSeek-V4.1-Flash endpoint.
  3. Unlike benchmarks/matrix.py and benchmarks/sweep.py this talks to
  4. /v1/chat/completions (the public serving path), not the internal /generate
  5. port. It records wall-clock aggregate tok/s, finish reasons and live
  6. /v1/loads snapshots. Use decode_window.py for streaming TTFT.
  7. BASE_URL=http://127.0.0.1:8888 python3 benchmarks/openai_serving.py
  8. """
  9. from __future__ import annotations
  10. import argparse
  11. import concurrent.futures
  12. import json
  13. import os
  14. import statistics
  15. import time
  16. import urllib.error
  17. import urllib.request
  18. from datetime import datetime, timezone
  19. from pathlib import Path
  20. def env_url():
  21. return os.environ.get("BASE_URL", "http://127.0.0.1:8888").rstrip("/")
  22. def request_headers():
  23. headers = {"Content-Type": "application/json", "Accept": "application/json"}
  24. key = os.environ.get("API_KEY", "")
  25. if key and key.lower() not in ("none", "off", "dummy", "0"):
  26. headers["Authorization"] = "Bearer " + key
  27. return headers
  28. def request(url, payload=None, timeout=1800, method=None):
  29. data = None if payload is None else json.dumps(payload).encode()
  30. req = urllib.request.Request(
  31. url,
  32. data=data,
  33. method=method or ("POST" if data is not None else "GET"),
  34. headers=request_headers(),
  35. )
  36. with urllib.request.urlopen(req, timeout=timeout) as response:
  37. body = response.read()
  38. return response.status, json.loads(body) if body else {}
  39. def loads(base):
  40. try:
  41. _, payload = request(base + "/v1/loads", timeout=10)
  42. rows = payload.get("loads") or []
  43. return rows[0] if rows else payload
  44. except Exception as exc:
  45. return {"error": f"{type(exc).__name__}: {exc}"}
  46. def chat(base, messages, *, max_tokens, temperature=0, thinking=False, tools=None, extra=None, timeout=1800):
  47. payload = {
  48. "model": os.environ.get("SERVED_MODEL_NAME", "deepseek-v4.1-flash"),
  49. "messages": messages,
  50. "max_tokens": max_tokens,
  51. "temperature": temperature,
  52. "chat_template_kwargs": {"thinking": thinking},
  53. "stream": False,
  54. }
  55. if temperature:
  56. payload["top_p"] = 0.95
  57. if tools:
  58. payload["tools"] = tools
  59. payload["tool_choice"] = "auto"
  60. if extra:
  61. payload.update(extra)
  62. started = time.monotonic()
  63. status, body = request(base + "/v1/chat/completions", payload, timeout=timeout)
  64. elapsed = time.monotonic() - started
  65. choice = (body.get("choices") or [{}])[0]
  66. message = choice.get("message") or {}
  67. usage = body.get("usage") or {}
  68. return {
  69. "http": status,
  70. "seconds": round(elapsed, 3),
  71. "finish_reason": choice.get("finish_reason"),
  72. "content": message.get("content") or "",
  73. "reasoning": message.get("reasoning_content") or message.get("reasoning") or "",
  74. "tool_calls": message.get("tool_calls") or [],
  75. "prompt_tokens": usage.get("prompt_tokens"),
  76. "completion_tokens": usage.get("completion_tokens"),
  77. "reasoning_tokens": usage.get("reasoning_tokens") or 0,
  78. "total_tokens": usage.get("total_tokens"),
  79. }
  80. PROSE = [
  81. "a lighthouse keeper's last winter",
  82. "a city that only exists at night",
  83. "two rivals sharing a train compartment",
  84. "a violin found in a flooded cellar",
  85. "the first market day after a long war",
  86. "a cartographer who maps dreams",
  87. "a bakery run by retired sailors",
  88. "an orchard planted on a rooftop",
  89. ]
  90. def wave(base, concurrency, max_tokens, thinking=False):
  91. snap_before = loads(base)
  92. wall0 = time.monotonic()
  93. def one(i):
  94. return chat(
  95. base,
  96. [{"role": "user", "content": (
  97. f"Write a vivid short story of about {max_tokens} words about {PROSE[i % len(PROSE)]}. "
  98. "Prose only, no headings."
  99. )}],
  100. max_tokens=max_tokens,
  101. thinking=thinking,
  102. )
  103. with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as pool:
  104. rows = list(pool.map(one, range(concurrency)))
  105. wall = time.monotonic() - wall0
  106. snap_after = loads(base)
  107. completion = sum(r.get("completion_tokens") or 0 for r in rows)
  108. oks = sum(1 for r in rows if r.get("http") == 200)
  109. per = [(r["completion_tokens"] or 0) / r["seconds"] for r in rows if r.get("seconds")]
  110. return {
  111. "concurrency": concurrency,
  112. "max_tokens": max_tokens,
  113. "thinking": thinking,
  114. "wall_seconds": round(wall, 3),
  115. "successes": oks,
  116. "completion_tokens": completion,
  117. "aggregate_tok_s": round(completion / wall, 2) if wall else None,
  118. "mean_per_request_tok_s": round(statistics.mean(per), 2) if per else None,
  119. "min_per_request_tok_s": round(min(per), 2) if per else None,
  120. "finish_reasons": [r.get("finish_reason") for r in rows],
  121. "seconds": [r.get("seconds") for r in rows],
  122. "loads_before": snap_before,
  123. "loads_after": snap_after,
  124. "rows": rows,
  125. }
  126. def correctness(base):
  127. out = {}
  128. out["models"] = request(base + "/v1/models", timeout=10)[1]
  129. out["health_http"] = request(base + "/health", timeout=10)[0]
  130. out["arithmetic"] = chat(base, [{"role": "user", "content": "What is 19 + 23? Reply only with the number."}], max_tokens=32)
  131. out["thinking"] = chat(
  132. base,
  133. [{"role": "user", "content": "Reply with exactly OK99 and nothing else."}],
  134. max_tokens=128,
  135. thinking=True,
  136. )
  137. out["tools"] = chat(
  138. base,
  139. [{"role": "user", "content": "Call get_weather with city Vigo."}],
  140. max_tokens=128,
  141. tools=[{
  142. "type": "function",
  143. "function": {
  144. "name": "get_weather",
  145. "description": "Get weather",
  146. "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
  147. },
  148. }],
  149. )
  150. return out
  151. NEEDLE_WORDS = ["harbour", "lantern", "meadow", "copper", "violin", "orchard", "compass", "thistle"]
  152. def needle(base, target_tokens, concurrency=1, seed=100):
  153. """Approximate a needle-in-haystack of ~target_tokens prompt tokens."""
  154. def build(idx, reps):
  155. para = (
  156. f"Entry {idx}-{{i}}: the {NEEDLE_WORDS[idx % len(NEEDLE_WORDS)]} keeper noted "
  157. f"{{n}} crates by the gate before dusk. "
  158. )
  159. body = "".join(para.format(i=i, n=(i * 17 + idx) % 997) for i in range(reps))
  160. return body + f"\nThe secret code word is PELICAN-{idx}.\n" + body[:800] + "\nWhat is the secret code word? Answer with the code word only."
  161. # calibrate
  162. cal = chat(base, [{"role": "user", "content": build(1, 40)}], max_tokens=8)
  163. prompt = cal.get("prompt_tokens") or 200
  164. per = max(1.0, (prompt - 40) / 40.0)
  165. reps = max(10, int((target_tokens - 120) / per))
  166. wall0 = time.monotonic()
  167. with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as pool:
  168. rows = list(pool.map(
  169. lambda i: chat(base, [{"role": "user", "content": build(seed + i, reps)}], max_tokens=16, timeout=3600),
  170. range(concurrency),
  171. ))
  172. wall = time.monotonic() - wall0
  173. ok = 0
  174. for i, row in enumerate(rows):
  175. expected = f"PELICAN-{seed + i}"
  176. text = (row.get("content") or "").strip()
  177. row["expected"] = expected
  178. row["ok"] = expected == text
  179. ok += int(row["ok"])
  180. return {
  181. "target_tokens": target_tokens,
  182. "concurrency": concurrency,
  183. "calibrated_prompt_tokens": [r.get("prompt_tokens") for r in rows],
  184. "wall_seconds": round(wall, 3),
  185. "correct": ok,
  186. "rows": rows,
  187. }
  188. def main():
  189. parser = argparse.ArgumentParser()
  190. parser.add_argument("--out", default="")
  191. parser.add_argument("--skip-needles", action="store_true")
  192. parser.add_argument("--max-concurrency", type=int, default=8)
  193. args = parser.parse_args()
  194. if args.max_concurrency < 1:
  195. parser.error("--max-concurrency must be positive")
  196. base = env_url()
  197. stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
  198. folder = Path(args.out) if args.out else Path("docs/results") / f"openai-serving-{stamp}"
  199. folder.mkdir(parents=True, exist_ok=True)
  200. report = {
  201. "base_url": base,
  202. "captured_utc": stamp,
  203. "loads_idle": loads(base),
  204. "correctness": correctness(base),
  205. "decode": [],
  206. "needles": [],
  207. }
  208. (folder / "partial.json").write_text(json.dumps(report, indent=2))
  209. print("correctness done", flush=True)
  210. for conc in (1, 2, 4, 8):
  211. if conc > args.max_concurrency:
  212. break
  213. cell = wave(base, conc, 256, thinking=False)
  214. report["decode"].append(cell)
  215. (folder / "partial.json").write_text(json.dumps(report, indent=2))
  216. print(json.dumps({k: cell[k] for k in ("concurrency", "wall_seconds", "aggregate_tok_s", "successes")}), flush=True)
  217. if not args.skip_needles:
  218. for size, conc in ((32768, 1), (32768, 4), (131072, 1)):
  219. if conc > args.max_concurrency:
  220. continue
  221. cell = needle(base, size, concurrency=conc)
  222. report["needles"].append({k: v for k, v in cell.items() if k != "rows"})
  223. (folder / f"needle-{size}-c{conc}.json").write_text(json.dumps(cell, indent=2))
  224. (folder / "partial.json").write_text(json.dumps(report, indent=2))
  225. print(json.dumps({"needle": size, "c": conc, "correct": cell["correct"], "wall": cell["wall_seconds"], "prompt": cell["calibrated_prompt_tokens"]}), flush=True)
  226. (folder / "summary.json").write_text(json.dumps(report, indent=2))
  227. print(str(folder), flush=True)
  228. if __name__ == "__main__":
  229. main()