gdubicki commited on
Commit
d8bbe98
·
verified ·
1 Parent(s): 327350d

Add bench.py performance benchmark script

Browse files
Files changed (1) hide show
  1. bench.py +391 -0
bench.py ADDED
@@ -0,0 +1,391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ bench.py — performance benchmarks for the vLLM server running Qwen3-Coder-Next-NVFP4-GB10.
4
+
5
+ Measures:
6
+ - Time to first token (TTFT) via streaming
7
+ - Decode throughput (tok/s)
8
+ - Prefill throughput (prompt tok/s)
9
+ - Latency across prompt lengths: short / medium / long / max
10
+ - Concurrent request throughput (1, 4, 8, 16 parallel requests)
11
+ - Reasoning ON vs OFF overhead
12
+
13
+ Usage:
14
+ python3 bench.py
15
+ python3 bench.py --host 192.168.1.50
16
+ python3 bench.py --host localhost --port 8000 --runs 3
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import statistics
22
+ import sys
23
+ import threading
24
+ import time
25
+ from dataclasses import dataclass, field
26
+ from typing import Optional
27
+
28
+ import urllib.request
29
+ import urllib.error
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Config
33
+ # ---------------------------------------------------------------------------
34
+ parser = argparse.ArgumentParser()
35
+ parser.add_argument("--host", default="localhost")
36
+ parser.add_argument("--port", type=int, default=8000)
37
+ parser.add_argument("--runs", type=int, default=3, help="Runs per scenario (default: 3)")
38
+ parser.add_argument("--no-color", action="store_true")
39
+ args = parser.parse_args()
40
+
41
+ BASE_URL = f"http://{args.host}:{args.port}/v1"
42
+
43
+ if args.no_color or not sys.stdout.isatty():
44
+ GREEN = RED = YELLOW = CYAN = BOLD = NC = ""
45
+ else:
46
+ GREEN = "\033[0;32m"
47
+ RED = "\033[0;31m"
48
+ YELLOW = "\033[0;33m"
49
+ CYAN = "\033[0;36m"
50
+ BOLD = "\033[1m"
51
+ NC = "\033[0m"
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Helpers
55
+ # ---------------------------------------------------------------------------
56
+
57
+ def get_model_id() -> str:
58
+ req = urllib.request.Request(f"{BASE_URL}/models")
59
+ with urllib.request.urlopen(req, timeout=10) as r:
60
+ data = json.loads(r.read())
61
+ return data["data"][0]["id"]
62
+
63
+
64
+ def chat_stream(model: str, messages: list, max_tokens: int, enable_thinking: bool) -> tuple[float, float, int, int]:
65
+ """
66
+ Send a streaming chat completion request.
67
+ Returns: (ttft_s, total_s, prompt_tokens, completion_tokens)
68
+ """
69
+ payload = json.dumps({
70
+ "model": model,
71
+ "messages": messages,
72
+ "max_tokens": max_tokens,
73
+ "temperature": 0.1,
74
+ "stream": True,
75
+ "stream_options": {"include_usage": True},
76
+ "chat_template_kwargs": {"enable_thinking": enable_thinking},
77
+ }).encode()
78
+
79
+ req = urllib.request.Request(
80
+ f"{BASE_URL}/chat/completions",
81
+ data=payload,
82
+ headers={"Content-Type": "application/json"},
83
+ method="POST",
84
+ )
85
+
86
+ ttft = None
87
+ t0 = time.perf_counter()
88
+ prompt_tokens = 0
89
+ completion_tokens = 0
90
+
91
+ with urllib.request.urlopen(req, timeout=300) as resp:
92
+ for raw_line in resp:
93
+ line = raw_line.decode().strip()
94
+ if not line.startswith("data:"):
95
+ continue
96
+ chunk = line[5:].strip()
97
+ if chunk == "[DONE]":
98
+ break
99
+ try:
100
+ obj = json.loads(chunk)
101
+ except json.JSONDecodeError:
102
+ continue
103
+
104
+ # First token
105
+ if ttft is None:
106
+ choices = obj.get("choices", [])
107
+ if choices:
108
+ delta = choices[0].get("delta", {})
109
+ content = delta.get("content") or delta.get("reasoning_content")
110
+ if content:
111
+ ttft = time.perf_counter() - t0
112
+
113
+ # Usage (last chunk)
114
+ usage = obj.get("usage")
115
+ if usage:
116
+ prompt_tokens = usage.get("prompt_tokens", 0)
117
+ completion_tokens = usage.get("completion_tokens", 0)
118
+
119
+ total = time.perf_counter() - t0
120
+ if ttft is None:
121
+ ttft = total
122
+ return ttft, total, prompt_tokens, completion_tokens
123
+
124
+
125
+ @dataclass
126
+ class Result:
127
+ name: str
128
+ ttft_ms: list[float] = field(default_factory=list)
129
+ decode_tps: list[float] = field(default_factory=list)
130
+ prefill_tps: list[float] = field(default_factory=list)
131
+ total_s: list[float] = field(default_factory=list)
132
+ prompt_tokens: int = 0
133
+ completion_tokens: int = 0
134
+
135
+
136
+ def run_scenario(name: str, model: str, messages: list, max_tokens: int,
137
+ enable_thinking: bool = False, runs: int = 3) -> Result:
138
+ res = Result(name=name)
139
+ print(f" {CYAN}{name}{NC}", end="", flush=True)
140
+ for i in range(runs):
141
+ try:
142
+ ttft, total, pt, ct = chat_stream(model, messages, max_tokens, enable_thinking)
143
+ decode_time = total - ttft
144
+ res.ttft_ms.append(ttft * 1000)
145
+ res.decode_tps.append(ct / decode_time if decode_time > 0.01 else 0)
146
+ res.prefill_tps.append(pt / ttft if ttft > 0.01 else 0)
147
+ res.total_s.append(total)
148
+ res.prompt_tokens = pt
149
+ res.completion_tokens = ct
150
+ print(f" {GREEN}·{NC}", end="", flush=True)
151
+ except Exception as e:
152
+ print(f" {RED}✗{NC}", end="", flush=True)
153
+ print()
154
+ return res
155
+
156
+
157
+ def print_result(res: Result):
158
+ if not res.ttft_ms:
159
+ print(f" {RED}all runs failed{NC}")
160
+ return
161
+ ttft_med = statistics.median(res.ttft_ms)
162
+ dtps_med = statistics.median(res.decode_tps)
163
+ ptps_med = statistics.median(res.prefill_tps)
164
+ total_med = statistics.median(res.total_s)
165
+ print(f" prompt tokens : {res.prompt_tokens}")
166
+ print(f" completion tok : {res.completion_tokens}")
167
+ print(f" TTFT (median) : {BOLD}{ttft_med:.0f} ms{NC}")
168
+ print(f" decode (median) : {BOLD}{dtps_med:.1f} tok/s{NC}")
169
+ print(f" prefill (median): {ptps_med:.0f} tok/s")
170
+ print(f" total (median) : {total_med:.1f} s")
171
+
172
+
173
+ def run_concurrent(model: str, messages: list, max_tokens: int, concurrency: int,
174
+ enable_thinking: bool = False) -> tuple[float, float]:
175
+ """Fire `concurrency` requests simultaneously, return (wall_s, aggregate_tps)."""
176
+ results = [None] * concurrency
177
+ errors = [None] * concurrency
178
+
179
+ def worker(idx):
180
+ try:
181
+ _, total, pt, ct = chat_stream(model, messages, max_tokens, enable_thinking)
182
+ results[idx] = (total, ct)
183
+ except Exception as e:
184
+ errors[idx] = e
185
+
186
+ threads = [threading.Thread(target=worker, args=(i,)) for i in range(concurrency)]
187
+ t0 = time.perf_counter()
188
+ for t in threads:
189
+ t.start()
190
+ for t in threads:
191
+ t.join()
192
+ wall = time.perf_counter() - t0
193
+
194
+ total_tokens = sum(r[1] for r in results if r)
195
+ agg_tps = total_tokens / wall if wall > 0 else 0
196
+ return wall, agg_tps
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Prompts
201
+ # ---------------------------------------------------------------------------
202
+ SHORT_PROMPT = "Write a Python one-liner that reverses a string."
203
+
204
+ MEDIUM_PROMPT = (
205
+ "Write a Python class implementing a generic LRU cache with O(1) get and put. "
206
+ "Use OrderedDict. Include docstrings, type annotations, and a short usage example."
207
+ )
208
+
209
+ LONG_PROMPT = (
210
+ "You are a senior software engineer. Review the following Python code and provide "
211
+ "a detailed analysis covering: correctness, edge cases, performance, readability, "
212
+ "and security concerns. Suggest concrete improvements with code examples.\n\n"
213
+ "```python\n"
214
+ + "\n".join([
215
+ "import subprocess, os, json",
216
+ "from flask import Flask, request",
217
+ "",
218
+ "app = Flask(__name__)",
219
+ "",
220
+ "def run_query(user_input):",
221
+ " result = subprocess.run(",
222
+ " f'mysql -u root -ppassword mydb -e \"{user_input}\"',",
223
+ " shell=True, capture_output=True, text=True",
224
+ " )",
225
+ " return result.stdout",
226
+ "",
227
+ "@app.route('/query')",
228
+ "def query():",
229
+ " data = request.args.get('q', '')",
230
+ " output = run_query(data)",
231
+ " return json.dumps({'result': output, 'debug': os.environ})",
232
+ "",
233
+ "if __name__ == '__main__':",
234
+ " app.run(debug=True, host='0.0.0.0')",
235
+ ])
236
+ + "\n```"
237
+ )
238
+
239
+ # ~2000 token prompt via repeated context
240
+ CONTEXT_PROMPT = (
241
+ "You are given the following context about a distributed system architecture. "
242
+ "After reading it carefully, answer the questions at the end.\n\n"
243
+ + ("Context: " + "A microservices-based e-commerce platform consists of the following services: "
244
+ "UserService (authentication, profiles), ProductService (catalog, search), "
245
+ "OrderService (cart, checkout, order management), PaymentService (Stripe integration), "
246
+ "NotificationService (email/SMS), and AnalyticsService (event tracking). "
247
+ "All services communicate via gRPC internally and expose REST APIs externally. "
248
+ "A Redis cluster handles session data and caching. PostgreSQL with read replicas "
249
+ "serves as the primary database. Kafka handles async event streaming between services. "
250
+ "Kubernetes on AWS EKS manages deployment with HPA for auto-scaling. "
251
+ "A global CDN sits in front of the API gateway. ") * 12
252
+ + "\n\nQuestions:\n"
253
+ "1. What are the main single points of failure in this architecture?\n"
254
+ "2. How would you handle a PaymentService outage gracefully?\n"
255
+ "3. What observability stack would you recommend and why?\n"
256
+ "4. Suggest a strategy for zero-downtime database migrations.\n"
257
+ )
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # Main
262
+ # ---------------------------------------------------------------------------
263
+ def main():
264
+ print(f"\n{BOLD}{'='*60}{NC}")
265
+ print(f"{BOLD} vLLM Performance Benchmark{NC}")
266
+ print(f"{BOLD} {BASE_URL}{NC}")
267
+ print(f"{BOLD}{'='*60}{NC}\n")
268
+
269
+ # Check server
270
+ try:
271
+ model = get_model_id()
272
+ print(f"{GREEN}[OK]{NC} Server up. Model: {model}\n")
273
+ except Exception as e:
274
+ print(f"{RED}[FAIL]{NC} Cannot reach server: {e}")
275
+ sys.exit(1)
276
+
277
+ runs = args.runs
278
+ results = []
279
+
280
+ # -----------------------------------------------------------------------
281
+ # 1. Latency across prompt lengths
282
+ # -----------------------------------------------------------------------
283
+ print(f"{BOLD}1. Latency across prompt lengths (reasoning OFF){NC}")
284
+ print(f" ({runs} runs each, streaming, median reported)\n")
285
+
286
+ for name, prompt, max_tok in [
287
+ ("short (~10 prompt tok, 200 output)", SHORT_PROMPT, 200),
288
+ ("medium (~80 prompt tok, 500 output)", MEDIUM_PROMPT, 500),
289
+ ("long (~400 prompt tok, 800 output)", LONG_PROMPT, 800),
290
+ ("ctx (~2K prompt tok, 600 output)", CONTEXT_PROMPT, 600),
291
+ ]:
292
+ messages = [{"role": "user", "content": prompt}]
293
+ res = run_scenario(name, model, messages, max_tok, enable_thinking=False, runs=runs)
294
+ print_result(res)
295
+ results.append(res)
296
+ print()
297
+
298
+ # -----------------------------------------------------------------------
299
+ # 2. Reasoning ON vs OFF
300
+ # -----------------------------------------------------------------------
301
+ print(f"{BOLD}2. Reasoning ON vs OFF (medium prompt, 800 output tokens){NC}\n")
302
+
303
+ messages = [{"role": "user", "content": MEDIUM_PROMPT}]
304
+ for label, thinking in [("reasoning OFF", False), ("reasoning ON ", True)]:
305
+ res = run_scenario(label, model, messages, 800, enable_thinking=thinking, runs=runs)
306
+ print_result(res)
307
+ print()
308
+
309
+ # -----------------------------------------------------------------------
310
+ # 3. Concurrent requests
311
+ # -----------------------------------------------------------------------
312
+ print(f"{BOLD}3. Concurrent requests throughput (short prompt, 300 output tok){NC}\n")
313
+ messages = [{"role": "user", "content": SHORT_PROMPT}]
314
+
315
+ print(f" {'concurrency':<14} {'wall_s':>8} {'agg tok/s':>12}")
316
+ print(f" {'-'*36}")
317
+ for c in [1, 2, 4, 8, 16]:
318
+ wall, agg = run_concurrent(model, messages, 300, c, enable_thinking=False)
319
+ print(f" {c:<14} {wall:>8.1f} {agg:>12.1f}")
320
+ print()
321
+
322
+ # -----------------------------------------------------------------------
323
+ # 4. Tool calling smoke
324
+ # -----------------------------------------------------------------------
325
+ print(f"{BOLD}4. Tool calling latency{NC}\n")
326
+
327
+ tool_messages = [{"role": "user", "content": "What is the weather in Warsaw? Use the get_weather tool."}]
328
+ tool_payload = json.dumps({
329
+ "model": model,
330
+ "messages": tool_messages,
331
+ "max_tokens": 200,
332
+ "temperature": 0.1,
333
+ "tools": [{
334
+ "type": "function",
335
+ "function": {
336
+ "name": "get_weather",
337
+ "description": "Get current weather for a city",
338
+ "parameters": {
339
+ "type": "object",
340
+ "properties": {"city": {"type": "string"}},
341
+ "required": ["city"],
342
+ }
343
+ }
344
+ }],
345
+ "tool_choice": "auto",
346
+ "chat_template_kwargs": {"enable_thinking": False},
347
+ }).encode()
348
+
349
+ times = []
350
+ print(f" tool_call latency", end="", flush=True)
351
+ for _ in range(runs):
352
+ try:
353
+ req = urllib.request.Request(
354
+ f"{BASE_URL}/chat/completions",
355
+ data=tool_payload,
356
+ headers={"Content-Type": "application/json"},
357
+ method="POST",
358
+ )
359
+ t0 = time.perf_counter()
360
+ with urllib.request.urlopen(req, timeout=60) as resp:
361
+ data = json.loads(resp.read())
362
+ elapsed = time.perf_counter() - t0
363
+ tool_calls = data["choices"][0]["message"].get("tool_calls")
364
+ if tool_calls:
365
+ times.append(elapsed * 1000)
366
+ print(f" {GREEN}·{NC}", end="", flush=True)
367
+ else:
368
+ print(f" {YELLOW}?{NC}", end="", flush=True)
369
+ except Exception:
370
+ print(f" {RED}✗{NC}", end="", flush=True)
371
+ print()
372
+ if times:
373
+ print(f" latency (median): {BOLD}{statistics.median(times):.0f} ms{NC}")
374
+ print()
375
+
376
+ # -----------------------------------------------------------------------
377
+ # Summary
378
+ # -----------------------------------------------------------------------
379
+ print(f"{BOLD}{'='*60}{NC}")
380
+ print(f"{BOLD} Summary{NC}")
381
+ print(f"{BOLD}{'='*60}{NC}")
382
+ print(f" {'scenario':<40} {'TTFT ms':>8} {'tok/s':>8}")
383
+ print(f" {'-'*58}")
384
+ for res in results:
385
+ if res.ttft_ms:
386
+ print(f" {res.name:<40} {statistics.median(res.ttft_ms):>8.0f} {statistics.median(res.decode_tps):>8.1f}")
387
+ print()
388
+
389
+
390
+ if __name__ == "__main__":
391
+ main()