self-test.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. #!/usr/bin/env python3
  2. """Prove the model actually works before the node reports itself as serving.
  3. This exists because of a specific failure mode: a TP/shard/quantisation mismatch
  4. loads CLEANLY, binds the API, reports healthy, and then serves fluent nonsense.
  5. Nothing upstream of a real generation catches it. So the head asks a handful of
  6. questions with unambiguous answers and refuses to advertise itself until they
  7. come back right.
  8. The MTP acceptance rate is the second signal and the more sensitive one: a
  9. correctly loaded model runs ~85-97%, while corrupted weights collapse to ~50%
  10. because the drafter and the target no longer agree. It moves long before the
  11. answers become visibly wrong, so it catches partial corruption that a
  12. capital-city question would sail straight through.
  13. Exit 0 = healthy. Exit 1 = the model is up but wrong.
  14. """
  15. import argparse
  16. import json
  17. import re
  18. import sys
  19. import time
  20. import urllib.error
  21. import urllib.request
  22. # Answers must be unambiguous, single-token-ish, and not sensitive to phrasing.
  23. # Each entry is (prompt, accepted substrings) -- matched case-insensitively
  24. # against the FINAL answer only, never the reasoning trace, which will happily
  25. # mention the right answer while the answer itself is wrong.
  26. CHECKS = [
  27. ("What is the capital of France? Answer with one word only.", ["paris"]),
  28. ("What is 12 + 7? Answer with just the number.", ["19"]),
  29. ("Complete with one word: the opposite of hot is", ["cold"]),
  30. ]
  31. # Health is judged on ACCEPTANCE LENGTH, not the per-token rate.
  32. #
  33. # With k draft tokens per step, length ranges 1..(k+1) and per-token rate falls
  34. # naturally as k grows -- later positions are harder to predict. Measured on
  35. # Qwen3.8: k=1 gives rate 92% / length 2.0, while k=7 gives rate 57% / length
  36. # 5.0. The second is 2.5x better despite the far worse-looking rate, and a
  37. # rate-based threshold flagged it as broken. Length near 1.0 is the real
  38. # corruption signal: it means nothing is being accepted at all.
  39. MIN_ACCEPTANCE_LENGTH_FRACTION = 0.25 # of the theoretical max (k+1)
  40. # Probes are retried only for timeouts, which on a busy box mean queueing
  41. # rather than breakage. Three tries at the default 300s bounds the gate at
  42. # ~15 minutes worst case, which still beats reporting a healthy model broken.
  43. RETRIES = 3
  44. RETRY_SLEEP_S = 10
  45. def _is_timeout(e: BaseException) -> bool:
  46. """A read timeout arrives bare; a connect timeout arrives wrapped."""
  47. return isinstance(e, TimeoutError) or (
  48. isinstance(e, urllib.error.URLError)
  49. and isinstance(getattr(e, "reason", None), (TimeoutError, OSError))
  50. and "timed out" in str(e.reason).lower()
  51. )
  52. def post(base, model, prompt, timeout):
  53. body = json.dumps({
  54. "model": model,
  55. "messages": [{"role": "user", "content": prompt}],
  56. # Generous: this is a reasoning model, and a budget too small yields an
  57. # empty `content` with everything spent in `reasoning_content` -- which
  58. # looks exactly like a failure but is not one.
  59. "max_tokens": 800,
  60. "temperature": 0,
  61. }).encode()
  62. req = urllib.request.Request(
  63. f"{base}/v1/chat/completions", data=body,
  64. headers={"Content-Type": "application/json"})
  65. with urllib.request.urlopen(req, timeout=timeout) as r:
  66. return json.loads(r.read().decode())
  67. def acceptance(base):
  68. """(per-token rate, acceptance length, k) or (None, None, None) if unused.
  69. Acceptance length is the mean tokens emitted per verification step, which is
  70. what actually drives throughput. k is inferred from the per-position buckets.
  71. """
  72. try:
  73. with urllib.request.urlopen(f"{base}/metrics", timeout=10) as r:
  74. text = r.read().decode()
  75. except Exception:
  76. return None
  77. vals = {}
  78. for line in text.splitlines():
  79. for key in ("spec_decode_num_draft_tokens_total",
  80. "spec_decode_num_accepted_tokens_total"):
  81. if line.startswith(f"vllm:{key}{{"):
  82. try:
  83. vals[key] = float(line.rsplit(" ", 1)[1])
  84. except (IndexError, ValueError):
  85. pass
  86. d = vals.get("spec_decode_num_draft_tokens_total", 0.0)
  87. a = vals.get("spec_decode_num_accepted_tokens_total", 0.0)
  88. if d <= 0:
  89. return None, None, None
  90. # Positions are 0..k-1, so the bucket count gives k directly.
  91. k = len(re.findall(r'spec_decode_num_accepted_tokens_per_pos_total\{[^}]*position="', text))
  92. steps = d / k if k else d
  93. length = 1 + (a / steps) if steps else 1.0
  94. return a / d, length, k
  95. def wait_for_api(base, deadline):
  96. while time.time() < deadline:
  97. try:
  98. with urllib.request.urlopen(f"{base}/v1/models", timeout=5) as r:
  99. if r.status == 200:
  100. return True
  101. except Exception:
  102. pass
  103. time.sleep(5)
  104. return False
  105. def main():
  106. ap = argparse.ArgumentParser()
  107. ap.add_argument("--base", default="http://127.0.0.1:8001")
  108. ap.add_argument("--model", default="ds4-flash")
  109. ap.add_argument("--wait", type=int, default=2400,
  110. help="seconds to wait for the API to bind")
  111. ap.add_argument("--timeout", type=int, default=300,
  112. help="per-request timeout")
  113. a = ap.parse_args()
  114. if not wait_for_api(a.base, time.time() + a.wait):
  115. print(f"self-test: API never bound within {a.wait}s", flush=True)
  116. return 1
  117. failures = []
  118. for prompt, expect in CHECKS:
  119. # A timeout is not a wrong answer. This gate exists to catch a model
  120. # that loads cleanly and serves fluent nonsense, and a request that
  121. # never came back says nothing about that. On a box already carrying
  122. # traffic a probe can simply queue behind someone else's long prefill
  123. # -- observed 2026-08-25, where two probes answered correctly, MTP
  124. # measured 88.7%, and the third timed out purely from contention.
  125. # Retry timeouts; let every other error fail on the first try.
  126. answer = None
  127. for attempt in range(1, RETRIES + 1):
  128. try:
  129. d = post(a.base, a.model, prompt, a.timeout)
  130. msg = d["choices"][0]["message"]
  131. answer = (msg.get("content") or "").strip()
  132. break
  133. except Exception as e:
  134. if not _is_timeout(e):
  135. failures.append(
  136. f"{prompt!r} -> request failed: {type(e).__name__}: {e}")
  137. break
  138. if attempt < RETRIES:
  139. print(f"self-test: retry {prompt[:44]!r} after timeout "
  140. f"({attempt}/{RETRIES})", flush=True)
  141. time.sleep(RETRY_SLEEP_S)
  142. continue
  143. failures.append(
  144. f"{prompt!r} -> timed out {RETRIES}x at {a.timeout}s each; "
  145. f"the server is up but too busy to answer, which is not the "
  146. f"same as wrong")
  147. if answer is None:
  148. continue
  149. if any(e in answer.lower() for e in expect):
  150. print(f"self-test: ok {prompt[:44]!r} -> {answer[:40]!r}", flush=True)
  151. else:
  152. failures.append(f"{prompt!r} -> got {answer[:80]!r}, expected one of {expect}")
  153. print(f"self-test: FAIL {prompt[:44]!r} -> {answer[:60]!r}", flush=True)
  154. acc, length, k = acceptance(a.base)
  155. if acc is None:
  156. print("self-test: speculative decoding not in use (no drafts produced)",
  157. flush=True)
  158. else:
  159. floor = (k + 1) * MIN_ACCEPTANCE_LENGTH_FRACTION if k else 0
  160. print(f"self-test: acceptance length {length:.2f} of max {k + 1} "
  161. f"(per-token rate {acc:.1%}, k={k})", flush=True)
  162. if k and length < floor:
  163. failures.append(
  164. f"acceptance length {length:.2f} is below {floor:.2f} "
  165. f"({MIN_ACCEPTANCE_LENGTH_FRACTION:.0%} of the max {k + 1}) -- "
  166. "near 1.0 means drafts are never accepted, the signature of "
  167. "corrupted or mismatched weights")
  168. if failures:
  169. print("\nself-test FAILED:", flush=True)
  170. for f in failures:
  171. print(f" - {f}", flush=True)
  172. return 1
  173. print("self-test: PASSED", flush=True)
  174. return 0
  175. if __name__ == "__main__":
  176. sys.exit(main())