|
| 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() |
0 commit comments