Skip to content

Commit 39a96ab

Browse files
author
Awni Hannun
authored
Add a server benchmark for continuous batching (ml-explore#728)
1 parent 43082fe commit 39a96ab

2 files changed

Lines changed: 353 additions & 1 deletion

File tree

benchmarks/server_benchmark.py

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
"""
2+
Spin up the local server:
3+
4+
mlx_lm.server
5+
6+
Then run the benchmark:
7+
8+
python server_benchmark.py --concurrency 4
9+
"""
10+
11+
import argparse
12+
import asyncio
13+
import json
14+
import math
15+
import time
16+
from collections import defaultdict
17+
from itertools import cycle
18+
from typing import Any, Dict, List, Optional, Tuple
19+
20+
import aiohttp
21+
from tqdm import tqdm
22+
23+
# Default prompts if no file is provided
24+
DEFAULT_PROMPTS = [
25+
"Explain quantum computing in simple terms.",
26+
"What are the main differences between Python and JavaScript?",
27+
"Describe the process of photosynthesis in plants.",
28+
"How does a neural network learn from data?",
29+
"What is the significance of the Turing test in AI?",
30+
"Explain the concept of blockchain technology.",
31+
"What causes seasons on Earth?",
32+
"How do vaccines work in the human body?",
33+
"Describe the water cycle and its importance.",
34+
"What is the theory of relativity proposed by Einstein?",
35+
"How do electric cars help reduce carbon emissions?",
36+
"What are the key features of a market economy?",
37+
"Explain how DNA replication works in cells.",
38+
"What is machine learning and its real-world applications?",
39+
"Describe the structure and function of the human heart.",
40+
]
41+
42+
43+
def tokens_per_second(tokens):
44+
start = math.floor(tokens[0])
45+
stop = math.ceil(tokens[-1])
46+
n_bins = int(stop - start) * 10
47+
bins = [0] * n_bins
48+
for t in tokens:
49+
bins[int(n_bins * (t - start) / (stop - start))] += 1
50+
51+
result = []
52+
53+
ms = 0
54+
cnt = 0
55+
for i, b in enumerate(bins):
56+
ms += b
57+
if cnt == 10:
58+
ms -= bins[i - 10]
59+
else:
60+
cnt += 1
61+
62+
result.append(10 * ms / cnt)
63+
64+
times = [start]
65+
while times[-1] < stop:
66+
times.append(times[-1] + 0.1)
67+
68+
return times, result
69+
70+
71+
def plot_generation(times, tokens_per_sec, start=None, interval=1.0, width=50):
72+
c = "█"
73+
start = start or times[0]
74+
stop = times[-1]
75+
76+
bar_times = [start]
77+
while bar_times[-1] < stop:
78+
bar_times.append(bar_times[-1] + interval)
79+
80+
bar_values = [[] for _ in bar_times]
81+
bar_idx = 0
82+
83+
for t, v in zip(times, tokens_per_sec):
84+
while t > bar_times[bar_idx] + interval:
85+
bar_idx += 1
86+
bar_values[bar_idx].append(v)
87+
88+
bar_values = [sum(v) / len(v) if v else 0 for v in bar_values]
89+
m = max(bar_values)
90+
91+
for t, v in zip(bar_times, bar_values):
92+
t = t - start
93+
b = c * int(v * width / m)
94+
print(f"{t:3.2f} {b} ({v})")
95+
96+
97+
def percentile(data, percent):
98+
if not data:
99+
return 0
100+
data = sorted(data)
101+
k = (len(data) - 1) * percent / 100
102+
f = math.floor(k)
103+
c = math.ceil(k)
104+
return (
105+
data[int(f)]
106+
if f == c
107+
else data[int(f)] + (data[int(c)] - data[int(f)]) * (k - f)
108+
)
109+
110+
111+
def median(data):
112+
return percentile(data, 50)
113+
114+
115+
async def make_request(
116+
session: aiohttp.ClientSession,
117+
url: str,
118+
api_key: str,
119+
model: str,
120+
prompt: str,
121+
max_tokens: int,
122+
) -> Tuple[bool, float, list]:
123+
"""
124+
Make a single streaming API request and return
125+
126+
- whether the request succeeded
127+
- the request start time
128+
- the time of every generated token
129+
"""
130+
payload = {
131+
"model": model,
132+
"messages": [{"role": "user", "content": prompt}],
133+
"max_tokens": max_tokens,
134+
"stream": True,
135+
}
136+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
137+
138+
start_time = time.perf_counter()
139+
tokens = []
140+
141+
try:
142+
async with session.post(url, json=payload, headers=headers) as response:
143+
if response.status != 200:
144+
error_body = await response.text()
145+
print(f"Error {response.status}: {error_body}")
146+
return (False, 0, [])
147+
148+
# Process streaming response
149+
async for chunk in response.content:
150+
if chunk:
151+
chunk_str = chunk.decode("utf-8").strip()
152+
if chunk_str.startswith("data:"):
153+
data_str = chunk_str[5:].strip()
154+
if data_str == "[DONE]":
155+
break
156+
157+
try:
158+
data = json.loads(data_str)
159+
if choices := data.get("choices", False):
160+
if choices[0].get("finish_reason") != "length":
161+
tokens.append(time.perf_counter())
162+
except json.JSONDecodeError:
163+
continue
164+
165+
return (bool(tokens), start_time, tokens)
166+
167+
except Exception as e:
168+
print(f"Request failed: {str(e)}")
169+
return (False, 0, [])
170+
171+
172+
async def run_benchmark(
173+
url: str,
174+
api_key: str,
175+
model: str,
176+
max_tokens: int,
177+
concurrency: int,
178+
total_requests: int,
179+
prompts: List[str],
180+
) -> Dict[str, Any]:
181+
prompt_cycle = cycle(prompts)
182+
semaphore = asyncio.Semaphore(concurrency)
183+
results = []
184+
request_times = []
185+
bar = tqdm(total=total_requests)
186+
187+
async def worker():
188+
async with semaphore:
189+
prompt = next(prompt_cycle)
190+
result = await make_request(
191+
session, url, api_key, model, prompt, max_tokens
192+
)
193+
bar.update(1)
194+
return result
195+
196+
async with aiohttp.ClientSession() as session:
197+
tasks = []
198+
for _ in range(total_requests):
199+
task = asyncio.create_task(worker())
200+
tasks.append(task)
201+
await asyncio.sleep(0.01) # Stagger requests slightly
202+
203+
for task in tasks:
204+
result = await task
205+
results.append(result)
206+
bar.close()
207+
208+
successful_requests = [r for r in results if r[0]]
209+
total_tokens = sum(len(r[2]) for r in successful_requests)
210+
211+
# Gather all the tokens generated with their corresponding timestamps
212+
all_tokens = []
213+
for r in successful_requests:
214+
all_tokens.extend(r[2])
215+
all_tokens.sort()
216+
full_generation = tokens_per_second(all_tokens)
217+
start = min(r[1] for r in successful_requests)
218+
219+
# Aggregate metrics
220+
metrics = {
221+
"total_requests": total_requests,
222+
"successful_requests": len(successful_requests),
223+
"failed_requests": total_requests - len(successful_requests),
224+
"total_tokens": total_tokens,
225+
"total_time": all_tokens[-1] - start,
226+
"aggregate_tokens_per_sec": median(full_generation[1]),
227+
"per_request": [],
228+
"start": start,
229+
"full_generation": full_generation,
230+
}
231+
232+
# Per-request metrics
233+
for i, (_, start, tokens) in enumerate(successful_requests):
234+
metrics["per_request"].append(
235+
{
236+
"request_id": i + 1,
237+
"time_to_first_token": tokens[0] - start,
238+
"total_time": tokens[-1] - start,
239+
"tokens_received": len(tokens),
240+
"tokens_per_sec": median(tokens_per_second(tokens)[1]),
241+
}
242+
)
243+
244+
# Calculate percentiles
245+
ttft_values = [m["time_to_first_token"] for m in metrics["per_request"]]
246+
tps_values = [m["tokens_per_sec"] for m in metrics["per_request"]]
247+
248+
metrics["aggregate_metrics"] = {
249+
"time_to_first_token": {
250+
"min": min(ttft_values) if ttft_values else 0,
251+
"max": max(ttft_values) if ttft_values else 0,
252+
"avg": sum(ttft_values) / len(ttft_values) if ttft_values else 0,
253+
"p95": percentile(ttft_values, 95) if ttft_values else 0,
254+
},
255+
"tokens_per_sec": {
256+
"min": min(tps_values) if tps_values else 0,
257+
"max": max(tps_values) if tps_values else 0,
258+
"avg": sum(tps_values) / len(tps_values) if tps_values else 0,
259+
"p95": percentile(tps_values, 95) if tps_values else 0,
260+
},
261+
}
262+
263+
return metrics
264+
265+
266+
def main():
267+
parser = argparse.ArgumentParser(description="LLM API Benchmark Tool")
268+
parser.add_argument(
269+
"--url",
270+
default="http://localhost:8080/v1/chat/completions",
271+
help="Chat completions API endpoint URL",
272+
)
273+
parser.add_argument("--api-key", default="none", help="API key")
274+
parser.add_argument("--model", default="default_model", help="Model name")
275+
parser.add_argument(
276+
"--max-tokens", type=int, default=100, help="Max tokens to generate"
277+
)
278+
parser.add_argument(
279+
"--concurrency", type=int, default=1, help="Number of concurrent requests"
280+
)
281+
parser.add_argument(
282+
"--total-requests", type=int, default=10, help="Total requests to make"
283+
)
284+
parser.add_argument("--prompt-file", help="File containing prompts (one per line)")
285+
parser.add_argument("--output", help="Output file for results (JSON format)")
286+
287+
args = parser.parse_args()
288+
289+
# Load prompts
290+
if args.prompt_file:
291+
with open(args.prompt_file, "r") as f:
292+
prompts = [line.strip() for line in f if line.strip()]
293+
else:
294+
prompts = DEFAULT_PROMPTS
295+
296+
print(
297+
f"Starting benchmark with {args.concurrency} concurrency and {args.total_requests} total requests..."
298+
)
299+
start_time = time.perf_counter()
300+
301+
# Run benchmark
302+
results = asyncio.run(
303+
run_benchmark(
304+
url=args.url,
305+
api_key=args.api_key,
306+
model=args.model,
307+
max_tokens=args.max_tokens,
308+
concurrency=args.concurrency,
309+
total_requests=args.total_requests,
310+
prompts=prompts,
311+
)
312+
)
313+
314+
duration = time.perf_counter() - start_time
315+
print(f"\nBenchmark completed in {duration:.2f} seconds")
316+
print(
317+
f"Successful requests: {results['successful_requests']}/{args.total_requests}"
318+
)
319+
print(f"Total tokens generated: {results['total_tokens']}")
320+
print(f"Aggregate tokens/sec: {results['aggregate_tokens_per_sec']:.2f}")
321+
322+
# Print summary
323+
if results["successful_requests"] > 0:
324+
ttft = results["aggregate_metrics"]["time_to_first_token"]
325+
tps = results["aggregate_metrics"]["tokens_per_sec"]
326+
327+
print("\nTime to First Token (seconds):")
328+
print(
329+
f" Min: {ttft['min']:.4f} | Max: {ttft['max']:.4f} | Avg: {ttft['avg']:.4f} | P95: {ttft['p95']:.4f}"
330+
)
331+
332+
print("\nTokens per Second (per request):")
333+
print(
334+
f" Min: {tps['min']:.2f} | Max: {tps['max']:.2f} | Avg: {tps['avg']:.2f} | P95: {tps['p95']:.2f}"
335+
)
336+
337+
print()
338+
plot_generation(*results["full_generation"], results["start"])
339+
340+
# Save results
341+
if args.output:
342+
with open(args.output, "w") as f:
343+
json.dump(results, f, indent=2)
344+
print(f"\nResults saved to {args.output}")
345+
346+
347+
if __name__ == "__main__":
348+
main()

mlx_lm/server.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1548,7 +1548,11 @@ def run(
15481548
"it only implements basic security checks."
15491549
)
15501550
logging.info(f"Starting httpd at {host} on port {port}...")
1551-
httpd.serve_forever()
1551+
try:
1552+
httpd.serve_forever()
1553+
except KeyboardInterrupt:
1554+
httpd.shutdown()
1555+
response_generator.stop_and_join()
15521556

15531557

15541558
def main():

0 commit comments

Comments
 (0)