File size: 11,788 Bytes
76083ed | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 | #!/usr/bin/env python3
"""Single-stream benchmark for the DSv4.1 TP2 serve.
Per run: actual prompt/completion tokens (server usage), TTFT, prefill tok/s (prompt_tokens / TTFT),
decode tok/s excluding prefill ((completion_tokens - 1) / (t_last - t_first)), DSpark acceptance from /metrics
deltas, min MemAvailable and max GPU temp / clock on both nodes (sampled over ssh every 2 s).
Cold prefill: every request gets a fresh cache_salt and a random nonce at the start of the prompt.
./bench.py --ctx 0 8000 32000 --workload legal --reps 3 --max-tokens 1500 --out logs/bench.jsonl
"""
import argparse, json, os, random, re, statistics, subprocess, threading, time, urllib.request, uuid
URL = "http://localhost:8000"
MODEL = "DeepSeek-V4.1-Flash-1017-EXL3-Spark-TP2"
NODES = ["spark", "spark-b"]
# Workload prompts and the synthetic-record vocabulary are not shipped with the public source (prompt-bearing text
# stays outside the PLI Labs data boundary). DSV41_BENCH_SPEC_FILE names a JSON file with keys:
# workloads {name: instruction} for the names in WORKLOAD_NAMES
# first, last, places, verbs [str] synthetic-record vocabulary
# claims [str], each may use {n} and {m}
# paragraph str using {i} {d} {place} {a} {verb} {claim} {b} {dcn}
WORKLOAD_NAMES = ("prose", "legal", "summary", "reason", "synth", "extract")
_SPEC = None
def spec():
global _SPEC
if _SPEC is None:
path = os.environ.get("DSV41_BENCH_SPEC_FILE")
if not path:
raise SystemExit("set DSV41_BENCH_SPEC_FILE (JSON workload spec); prompts are not shipped")
with open(path, encoding="utf-8") as fh:
loaded = json.load(fh)
missing = [k for k in ("workloads", "first", "last", "places", "verbs", "claims", "paragraph") if k not in loaded]
if missing:
raise SystemExit(f"DSV41_BENCH_SPEC_FILE {path} lacks keys {missing}")
_SPEC = loaded
return _SPEC
def record_paragraph(rng, i):
s = spec()
d = f"20{rng.randint(22, 25)}-{rng.randint(1, 12):02d}-{rng.randint(1, 28):02d}"
a, b = f"{rng.choice(s['first'])} {rng.choice(s['last'])}", f"{rng.choice(s['first'])} {rng.choice(s['last'])}"
c = rng.choice(s["claims"]).format(n=rng.randint(1, 9), m=rng.randint(200, 25000))
return s["paragraph"].format(i=i, d=d, place=rng.choice(s["places"]), a=a, verb=rng.choice(s["verbs"]), claim=c,
b=b, dcn=rng.randint(100000, 999999))
def tok_count(text):
req = urllib.request.Request(URL + "/tokenize", data=json.dumps({"model": MODEL, "prompt": text}).encode(),
headers={"Content-Type": "application/json"})
return json.load(urllib.request.urlopen(req, timeout=600))["count"]
_DOCS = None
def build_real(target_tokens, seed):
"""Concatenate public CourtListener opinions + RECAP filings (data/*.jsonl) to target_tokens (exact via /tokenize)."""
global _DOCS
if _DOCS is None:
_DOCS = [json.loads(l) for f in ("data/opinions_cl.jsonl", "data/filings_recap.jsonl") for l in open(f)]
docs = _DOCS[:]; random.Random(seed).shuffle(docs)
parts, chars = [], 0
ratio = 3.3 # chars/token first guess; corrected below
for d in docs:
parts.append(f"=== {d.get('title', '')} ({d.get('court', '')}, {d.get('date', '')}) ===\n{d['text']}")
chars += len(parts[-1])
if chars / ratio >= target_tokens * 1.02:
n = tok_count("\n\n".join(parts)); ratio = chars / n
if n >= target_tokens:
break
text = "\n\n".join(parts)
while tok_count(text) > target_tokens: # trim to the target
text = text[: int(len(text) - (tok_count(text) - target_tokens) * ratio) - 50]
return text
def build_record(target_tokens, seed):
rng = random.Random(seed)
paras, i, approx = [], 1, 0
while approx < target_tokens:
p = record_paragraph(rng, i)
paras.append(p); approx += len(p) / 3.6; i += 1 # ~3.6 chars/token for this text (calibrated below)
return "\n".join(paras)
class Sampler(threading.Thread):
def __init__(self):
super().__init__(daemon=True); self.stop = threading.Event()
self.minavail = {n: 1e9 for n in NODES}; self.maxtemp = {n: 0 for n in NODES}
self.clk = {n: [] for n in NODES}; self.util = {n: [] for n in NODES}
def run(self):
while not self.stop.is_set():
for n in NODES:
try:
o = subprocess.run(["ssh", "-o", "BatchMode=yes", n,
"awk '/MemAvailable/{print $2}' /proc/meminfo; nvidia-smi --query-gpu=temperature.gpu,clocks.gr,utilization.gpu --format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=10).stdout.split("\n")
self.minavail[n] = min(self.minavail[n], int(o[0]) / 1048576)
t, c, u = [x.strip() for x in o[1].split(",")]
self.maxtemp[n] = max(self.maxtemp[n], int(t)); self.clk[n].append(int(c)); self.util[n].append(int(u))
except Exception:
pass
self.stop.wait(2)
def metrics():
try:
txt = urllib.request.urlopen(URL + "/metrics", timeout=5).read().decode()
except Exception:
return {}
out = {}
for k in ("spec_decode_num_accepted_tokens_total", "spec_decode_num_draft_tokens_total",
"spec_decode_num_drafts_total"):
vals = re.findall(rf"^vllm:{k}(?:{{[^}}]*}})? ([0-9.e+]+)$", txt, re.M)
out[k] = sum(float(v) for v in vals) if vals else None
return out
def run_one(prompt, max_tokens, thinking, temperature, top_p, seed=None):
body = {"model": MODEL, "messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens,
"temperature": temperature, "top_p": top_p, "stream": True,
"stream_options": {"include_usage": True, "continuous_usage_stats": False},
"chat_template_kwargs": {"thinking": thinking}, "cache_salt": uuid.uuid4().hex}
if seed is not None:
body["seed"] = seed
req = urllib.request.Request(URL + "/v1/chat/completions", data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
t0 = time.time(); t_first = t_last = None; usage = None; text = []; reasoning = []; finish = None
with urllib.request.urlopen(req, timeout=3600) as r:
for line in r:
line = line.decode().strip()
if not line.startswith("data: ") or line == "data: [DONE]":
continue
ev = json.loads(line[6:])
if ev.get("usage"):
usage = ev["usage"]
for ch in ev.get("choices", []):
d = ch.get("delta", {})
piece = (d.get("content") or "") + (d.get("reasoning_content") or d.get("reasoning") or "")
if piece:
now = time.time(); t_first = t_first or now; t_last = now
(reasoning if (d.get("reasoning_content") or d.get("reasoning")) else text).append(piece)
finish = ch.get("finish_reason") or finish
pt, ct = usage["prompt_tokens"], usage["completion_tokens"]
ttft = t_first - t0
dec = (ct - 1) / (t_last - t_first) if t_last and t_last > t_first else None
return {"prompt_tokens": pt, "completion_tokens": ct, "ttft_s": round(ttft, 2),
"prefill_tps": round(pt / ttft, 1), "decode_tps": round(dec, 2) if dec else None,
"total_s": round(time.time() - t0, 1), "finish": finish,
"cached_tokens": (usage.get("prompt_tokens_details") or {}).get("cached_tokens"),
"text": "".join(text), "reasoning": "".join(reasoning)}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--ctx", type=int, nargs="+", default=[0])
ap.add_argument("--workload", nargs="+", default=["legal"])
ap.add_argument("--reps", type=int, default=3)
ap.add_argument("--max-tokens", type=int, default=1500)
ap.add_argument("--thinking", action="store_true")
ap.add_argument("--temperature", type=float, default=1.0)
ap.add_argument("--top-p", type=float, default=1.0)
ap.add_argument("--out", default="logs/bench.jsonl")
ap.add_argument("--keep-text", action="store_true")
ap.add_argument("--filler", choices=["synthetic", "real"], default="synthetic")
a = ap.parse_args()
for ctx in a.ctx:
for wl in a.workload:
rows = []
for rep in range(a.reps):
nonce = f"Session {uuid.uuid4().hex[:12]}.\n"
if a.filler == "real" and ctx:
rec = build_real(ctx, seed=1000 * ctx + rep)
prompt = nonce + f"MATERIALS (public court documents):\n{rec}\n\nEND OF MATERIALS.\n\n" + spec()["workloads"][wl]
else:
rec = build_record(ctx, seed=1000 * ctx + rep) if ctx else ""
prompt = nonce + (f"CASE RECORD (fictional):\n{rec}\n\nEND OF RECORD.\n\n" if rec else "") + spec()["workloads"][wl]
m0 = metrics(); s = Sampler(); s.start()
try:
r = run_one(prompt, a.max_tokens, a.thinking, a.temperature, a.top_p)
err = None
except Exception as e:
r, err = {}, repr(e)
s.stop.set(); s.join(); m1 = metrics()
acc = None
if m0.get("spec_decode_num_accepted_tokens_total") is not None and m1.get("spec_decode_num_drafts_total"):
da = m1["spec_decode_num_accepted_tokens_total"] - m0["spec_decode_num_accepted_tokens_total"]
dd = m1["spec_decode_num_draft_tokens_total"] - m0["spec_decode_num_draft_tokens_total"]
dn = m1["spec_decode_num_drafts_total"] - m0["spec_decode_num_drafts_total"]
acc = {"accept_rate": round(da / dd, 3) if dd else None,
"tokens_per_round": round(1 + da / dn, 2) if dn else None}
row = {"ts": time.strftime("%FT%T"), "target_ctx": ctx, "workload": wl, "rep": rep, "filler": a.filler, "filler": a.filler,
"thinking": a.thinking, "temperature": a.temperature, "top_p": a.top_p, "error": err,
**{k: v for k, v in r.items() if k not in ("text", "reasoning")}, "spec": acc,
"min_avail_gib": {n: round(v, 2) for n, v in s.minavail.items()},
"max_temp_c": s.maxtemp,
"gpu_clock_med": {n: (statistics.median(v) if v else None) for n, v in s.clk.items()},
"gpu_util_med": {n: (statistics.median(v) if v else None) for n, v in s.util.items()},
"tail": r.get("text", "")[-300:]}
if a.keep_text:
row["text"], row["reasoning"] = r.get("text"), r.get("reasoning")
rows.append(row)
with open(a.out, "a") as f:
f.write(json.dumps(row) + "\n")
print(json.dumps({k: row[k] for k in ("target_ctx", "workload", "rep", "prompt_tokens", "completion_tokens",
"ttft_s", "prefill_tps", "decode_tps", "finish", "cached_tokens",
"spec", "min_avail_gib", "max_temp_c", "error") if k in row}), flush=True)
d = [x["decode_tps"] for x in rows if x.get("decode_tps")]
if d:
print(f"## ctx {ctx} {wl}: decode median {statistics.median(d):.1f} tok/s, range {min(d):.1f}-{max(d):.1f}, n={len(d)}", flush=True)
if __name__ == "__main__":
main()
|