prefill.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. #!/usr/bin/env python3
  2. """Profile any OpenAI-compatible endpoint: TTFT, prefill and decode across
  3. prompt sizes, plus how to make the model think.
  4. Answers the two questions every newly booted model raises -- what is it worth at
  5. each size, and which request shape turns reasoning on -- without assuming
  6. anything about the engine. Run it on a node against a quiet box.
  7. Three traps this avoids, each of which has produced a wrong number here:
  8. Repeated prompts measure the prefix cache, not prefill. A 200k probe that took
  9. 198s cold came back in ~6s warm. Every prompt is gibberish drawn from a per-run
  10. seed, so nothing shares a prefix with the cache or with another size.
  11. Counting stream events instead of tokens undercounts decode by the acceptance
  12. length. Speculative decoding packs several accepted tokens into one SSE event, so
  13. events/second read 12.7 where the tokens were arriving at 63/s. Decode here comes
  14. from `usage.completion_tokens`.
  15. The reasoning field is spelled `reasoning_content` by SGLang and `reasoning` by
  16. vLLM. Reading one and finding the other empty looks exactly like a parser
  17. discarding the trace: a short answer, an empty reasoning field, and a token count
  18. far larger than the answer. Both are checked, and the message keys are printed
  19. when neither holds text.
  20. """
  21. import argparse
  22. import json
  23. import random
  24. import sys
  25. import time
  26. import urllib.error
  27. import urllib.request
  28. VOWEL, CONS = "aeiou", "bcdfghjklmnprstvwz"
  29. REASONING_KEYS = ("reasoning_content", "reasoning")
  30. # Request shapes that ask for thinking. Engines disagree, and several shapes a
  31. # client is likely to try are accepted and silently dropped as unknown fields,
  32. # so the only way to know is to send each one and look.
  33. THINKING_SHAPES = [
  34. ("(bare, engine default)", {}),
  35. ("chat_template_kwargs thinking", {"chat_template_kwargs": {"thinking": True}}),
  36. ("chat_template_kwargs enable_thinking",
  37. {"chat_template_kwargs": {"enable_thinking": True}}),
  38. ("reasoning_effort high", {"reasoning_effort": "high"}),
  39. ("reasoning {enabled:true}", {"reasoning": {"enabled": True}}),
  40. ("thinking {type:enabled}", {"thinking": {"type": "enabled"}}),
  41. ("enable_thinking (top level)", {"enable_thinking": True}),
  42. ]
  43. EFFORTS = ["none", "low", "medium", "high", "xhigh", "max"]
  44. def words(n, seed):
  45. """Gibberish with no shared prefix, so a prompt cannot be served from cache."""
  46. r = random.Random(seed)
  47. out = []
  48. for _ in range(n):
  49. k = r.randint(2, 4)
  50. out.append("".join(r.choice(CONS) + r.choice(VOWEL) for _ in range(k)))
  51. return " ".join(out)
  52. def reasoning_of(message):
  53. """The reasoning text and the key it came under, or ("", None)."""
  54. for key in REASONING_KEYS:
  55. text = message.get(key)
  56. if text:
  57. return text, key
  58. return "", None
  59. def chat(base, model, prompt, max_tokens, stream=True, extra=None, timeout=1800):
  60. """One turn. Returns (elapsed, ttft, usage, message, error).
  61. ttft is the first token of either channel: a thinking model emits reasoning
  62. first, and timing to the first `content` delta would report the whole trace
  63. as latency.
  64. """
  65. body = {
  66. "model": model,
  67. "messages": [{"role": "user", "content": prompt}],
  68. "max_tokens": max_tokens,
  69. "temperature": 0,
  70. "stream": stream,
  71. **({"stream_options": {"include_usage": True}} if stream else {}),
  72. **(extra or {}),
  73. }
  74. req = urllib.request.Request(
  75. base.rstrip("/") + "/chat/completions",
  76. data=json.dumps(body).encode(),
  77. headers={"Content-Type": "application/json"},
  78. )
  79. t0 = time.time()
  80. ttft, usage, message = None, None, {}
  81. try:
  82. with urllib.request.urlopen(req, timeout=timeout) as r:
  83. if not stream:
  84. d = json.loads(r.read().decode())
  85. return (time.time() - t0, None, d.get("usage"),
  86. d["choices"][0]["message"], None)
  87. parts, reasoning_parts, finish = [], [], None
  88. # Keep the key the server actually used: normalising it here would
  89. # hide the engine difference this column exists to show.
  90. wire_key = None
  91. for raw in r:
  92. line = raw.decode().strip()
  93. if not line.startswith("data: "):
  94. continue
  95. chunk = line[6:]
  96. if chunk == "[DONE]":
  97. break
  98. d = json.loads(chunk)
  99. if d.get("usage"):
  100. usage = d["usage"]
  101. choices = d.get("choices") or []
  102. if not choices:
  103. continue
  104. delta = choices[0].get("delta") or {}
  105. finish = choices[0].get("finish_reason") or finish
  106. text = delta.get("content")
  107. think, key = reasoning_of(delta)
  108. if key:
  109. wire_key = key
  110. if text:
  111. parts.append(text)
  112. if think:
  113. reasoning_parts.append(think)
  114. if ttft is None and (text or think):
  115. ttft = time.time() - t0
  116. message = {"content": "".join(parts), "finish_reason": finish}
  117. if reasoning_parts:
  118. message[wire_key] = "".join(reasoning_parts)
  119. except urllib.error.HTTPError as e:
  120. return time.time() - t0, None, None, {}, f"HTTP {e.code} {e.read()[:120]!r}"
  121. except Exception as e: # a timeout or a dropped stream is a result, not a crash
  122. return time.time() - t0, None, None, {}, f"{type(e).__name__}: {e}"
  123. return time.time() - t0, ttft, usage, message, None
  124. def models(base):
  125. with urllib.request.urlopen(base.rstrip("/") + "/models", timeout=60) as r:
  126. return json.loads(r.read())["data"]
  127. def parse_size(text):
  128. text = text.strip().lower()
  129. mult = {"k": 1000, "m": 1000000}
  130. return int(float(text[:-1]) * mult[text[-1]]) if text[-1] in mult else int(text)
  131. def size_sweep(base, model, sizes, seed, out_tokens, tpw, window):
  132. print(f"\nsize sweep (first touch, uncacheable, one at a time, "
  133. f"{out_tokens} output tokens)")
  134. print(f"{'prompt_tok':>11} {'ttft_s':>8} {'prefill_t/s':>12} "
  135. f"{'decode_t/s':>11} {'out_tok':>8} {'reasoning':>10} finish")
  136. for i, target in enumerate(sizes):
  137. if window and target >= window:
  138. print(f"{target:>11} {'skipped':>8} past the model's window ({window})")
  139. continue
  140. # Seeding by the word count too: two runs whose calibrations differ
  141. # would otherwise share every word up to the shorter length, and the
  142. # longer one then prefills partly from the prefix cache.
  143. n = int(target / tpw)
  144. prompt = words(n, seed * 7919 + i + n * 104729)
  145. elapsed, ttft, usage, message, error = chat(
  146. base, model, prompt, out_tokens)
  147. if error:
  148. print(f"{target:>11} {'-':>8} {error}")
  149. continue
  150. got = usage["prompt_tokens"] if usage else 0
  151. completion = usage.get("completion_tokens", 0) if usage else 0
  152. # Decode excludes the first token, whose cost is the prefill.
  153. decode = (completion - 1) / (elapsed - ttft) if ttft and completion > 1 else 0
  154. prefill = got / ttft if ttft else 0
  155. think, _ = reasoning_of(message)
  156. print(f"{got:>11} {ttft:>8.2f} {prefill:>12.0f} {decode:>11.1f} "
  157. f"{completion:>8} {len(think):>10} {message.get('finish_reason')}")
  158. def thinking_probe(base, model):
  159. print("\nthinking shapes (a shape the engine does not know is dropped silently)")
  160. print(f" {'request':<38} {'thinking':<9} {'field':<18} answer")
  161. working = []
  162. for label, extra in THINKING_SHAPES:
  163. _, _, usage, message, error = chat(
  164. base, model, "What is 17*23? Number only.", 600, extra=extra, timeout=300)
  165. if error:
  166. print(f" {label:<38} {'-':<9} {'-':<18} {error}")
  167. continue
  168. think, key = reasoning_of(message)
  169. on = bool(think)
  170. if on:
  171. working.append((label, extra))
  172. answer = (message.get("content") or "").strip().replace("\n", " ")[:18]
  173. print(f" {label:<38} {'ON' if on else 'off':<9} {key or '-':<18} {answer!r}")
  174. # Content empty with tokens spent is the shape that looks like a bug: the
  175. # trace never closed, so there is no answer yet.
  176. if not answer and usage and usage.get("completion_tokens"):
  177. print(f" {'':<38} {'':<9} {'':<18} "
  178. f"^ empty answer, {usage['completion_tokens']} tokens spent: "
  179. f"the trace did not close inside max_tokens")
  180. if not on and not key:
  181. _, _, _, full, _ = chat(base, model, "hi", 16, extra=extra,
  182. stream=False, timeout=300)
  183. if full:
  184. print(f" {'':<38} keys: {sorted(full)}")
  185. return working
  186. def effort_sweep(base, model, shape):
  187. label, extra = shape
  188. print(f"\neffort levels, sent alongside {label}")
  189. print(f" {'effort':<10} {'reasoning_tok':>14} {'out_tok':>8} answer")
  190. for effort in EFFORTS:
  191. _, _, usage, message, error = chat(
  192. base, model, "What is 17*23? Number only.", 1200,
  193. extra={**extra, "reasoning_effort": effort}, timeout=300)
  194. if error:
  195. print(f" {effort:<10} {'-':>14} {'-':>8} {error}")
  196. continue
  197. think, _ = reasoning_of(message)
  198. completion = usage.get("completion_tokens", 0) if usage else 0
  199. answer = (message.get("content") or "").strip().replace("\n", " ")[:18]
  200. print(f" {effort:<10} {len(think):>14} {completion:>8} {answer!r}")
  201. def main():
  202. ap = argparse.ArgumentParser()
  203. ap.add_argument("--base", default="http://127.0.0.1:8002/v1",
  204. help="OpenAI base, e.g. http://127.0.0.1:8002/v1")
  205. ap.add_argument("--model", help="defaults to the first the endpoint lists")
  206. ap.add_argument("--sizes", default="1k,8k,32k,128k",
  207. help="prompt sizes in tokens, comma separated")
  208. ap.add_argument("--out-tokens", type=int, default=128)
  209. ap.add_argument("--seed", type=int, default=1)
  210. ap.add_argument("--calibrate-words", type=int, default=2000)
  211. ap.add_argument("--skip-sizes", action="store_true")
  212. ap.add_argument("--skip-thinking", action="store_true")
  213. a = ap.parse_args()
  214. listed = models(a.base)
  215. model = a.model or listed[0]["id"]
  216. window = next((m.get("max_model_len") for m in listed if m["id"] == model), None)
  217. print(f"model: {model} window: {window or 'not reported'} base: {a.base}")
  218. print(f"endpoint lists: {' '.join(m['id'] for m in listed)}")
  219. # Calibrate rather than guess: tokens per word depends on the tokenizer, and
  220. # this gibberish is not English.
  221. cal = words(a.calibrate_words, a.seed * 104729)
  222. _, _, usage, _, error = chat(a.base, model, cal, 1, stream=False, timeout=600)
  223. if error or not usage:
  224. sys.exit(f"calibration failed: {error}")
  225. tpw = usage["prompt_tokens"] / a.calibrate_words
  226. print(f"calibration: {a.calibrate_words} words -> {usage['prompt_tokens']} "
  227. f"tokens ({tpw:.2f} tok/word)")
  228. if not a.skip_sizes:
  229. size_sweep(a.base, model, [parse_size(s) for s in a.sizes.split(",")],
  230. a.seed, a.out_tokens, tpw, window)
  231. if not a.skip_thinking:
  232. working = thinking_probe(a.base, model)
  233. if working:
  234. effort_sweep(a.base, model, working[0])
  235. else:
  236. print("\nno shape turned thinking on: either the model does not "
  237. "reason or the server has it off")
  238. if __name__ == "__main__":
  239. main()