main
py 2,435 lines 90.6 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 r"""Benchmark online serving throughput.
4
5 On the server side, run one of the following commands
6 to launch the vLLM OpenAI API server:
7 vllm serve <your_model> <engine arguments>
8
9 On the client side, run:
10 vllm bench serve \
11 --backend <backend or endpoint type. Default 'openai'> \
12 --label <benchmark result label. Default using backend> \
13 --model <your_model. Optional, defaults to first model from server> \
14 --dataset-name <dataset_name. Default 'random'> \
15 --input-len <general input length. Optional, maps to dataset-specific args> \
16 --output-len <general output length. Optional, maps to dataset-specific args> \
17 --request-rate <request_rate. Default inf> \
18 --num-prompts <num_prompts. Default 1000>
19 """
20
21 import argparse
22 import asyncio
23 import contextlib
24 import importlib.util
25 import json
26 import math
27 import os
28 import random
29 import shutil
30 import ssl
31 import time
32 import uuid
33 import warnings
34 from collections.abc import AsyncGenerator, Iterable, Iterator
35 from dataclasses import dataclass, replace
36 from datetime import datetime
37 from enum import Enum
38 from pathlib import Path
39 from typing import Any, Literal
40
41 import aiohttp
42 import numpy as np
43 from tqdm.asyncio import tqdm
44
45 from vllm.benchmarks.datasets import SampleRequest, add_dataset_parser, get_samples
46 from vllm.benchmarks.lib.endpoint_request_func import (
47 ASYNC_REQUEST_FUNCS,
48 OPENAI_COMPATIBLE_BACKENDS,
49 POOLING_BACKENDS,
50 RequestFuncInput,
51 RequestFuncOutput,
52 )
53 from vllm.benchmarks.lib.ready_checker import wait_for_endpoint
54 from vllm.benchmarks.lib.utils import (
55 convert_to_pytorch_benchmark_format,
56 redact_sensitive_namespace,
57 write_to_json,
58 )
59 from vllm.tokenizers import TokenizerLike, get_tokenizer
60 from vllm.utils.argparse_utils import FlexibleArgumentParser
61 from vllm.utils.gc_utils import freeze_gc_heap
62 from vllm.utils.network_utils import join_host_port
63
64 MILLISECONDS_TO_SECONDS_CONVERSION = 1000
65 _SENSITIVE_ARG_FIELDS = ("header",)
66
67
68 def _merge_overrides(base: dict | None, override: dict | None) -> dict | None:
69 """Shallow merge; per-request wins. Returns None if both are empty."""
70 if not base and not override:
71 return None
72 return {**(base or {}), **(override or {})}
73
74
75 TERM_PLOTLIB_AVAILABLE = (importlib.util.find_spec("termplotlib") is not None) and (
76 shutil.which("gnuplot") is not None
77 )
78
79
80 async def _align_prompts_to_server_tokenizer(
81 base_url: str,
82 model_id: str,
83 input_requests: list[SampleRequest],
84 ssl_context: ssl.SSLContext | bool | None = None,
85 ) -> list[SampleRequest]:
86 """Re-align prompts if local/server tokenizers disagree."""
87 if not input_requests or not isinstance(input_requests[0].prompt, str):
88 return input_requests
89
90 tok_url = f"{base_url}/tokenize"
91 detok_url = f"{base_url}/detokenize"
92 connector = aiohttp.TCPConnector(ssl=ssl_context)
93
94 async with aiohttp.ClientSession(connector=connector) as session:
95 sem = asyncio.Semaphore(64)
96
97 async def _tokenize(prompt: str) -> list[int]:
98 async with (
99 sem,
100 session.post(
101 tok_url,
102 json={
103 "model": model_id,
104 "prompt": prompt,
105 "add_special_tokens": False,
106 },
107 ) as r,
108 ):
109 r.raise_for_status()
110 return (await r.json())["tokens"]
111
112 async def _detokenize(tokens: list[int]) -> str:
113 async with (
114 sem,
115 session.post(
116 detok_url, json={"model": model_id, "tokens": tokens}
117 ) as r,
118 ):
119 r.raise_for_status()
120 return (await r.json())["prompt"]
121
122 try:
123 first_tokens = await _tokenize(input_requests[0].prompt)
124 except Exception:
125 print("WARNING: /tokenize unavailable, skipping alignment.")
126 return input_requests
127
128 expected = input_requests[0].prompt_len
129 if len(first_tokens) == expected:
130 return input_requests
131
132 print(
133 f"WARNING: tokenizer mismatch "
134 f"(server={len(first_tokens)}, expected={expected}), "
135 f"re-aligning prompts."
136 )
137
138 async def _fix_one(req: SampleRequest) -> SampleRequest:
139 assert isinstance(req.prompt, str)
140 tokens = await _tokenize(req.prompt)
141 if len(tokens) <= req.prompt_len:
142 return req
143 corrected = await _detokenize(tokens[: req.prompt_len])
144 return replace(req, prompt=corrected, prompt_len=req.prompt_len)
145
146 results = await asyncio.gather(
147 *[_fix_one(r) for r in input_requests], return_exceptions=True
148 )
149 return [
150 res if not isinstance(res, BaseException) else orig
151 for orig, res in zip(input_requests, results)
152 ]
153
154
155 async def get_first_model_from_server(
156 base_url: str,
157 headers: dict | None = None,
158 ssl_context: ssl.SSLContext | bool | None = None,
159 ) -> tuple[str, str]:
160 """Fetch the first model from the server's /v1/models endpoint."""
161 models_url = f"{base_url}/v1/models"
162 connector = aiohttp.TCPConnector(ssl=ssl_context)
163 async with aiohttp.ClientSession(connector=connector) as session:
164 try:
165 async with session.get(models_url, headers=headers) as response:
166 response.raise_for_status()
167 data = await response.json()
168 if "data" in data and len(data["data"]) > 0:
169 return data["data"][0]["id"], data["data"][0]["root"]
170 else:
171 raise ValueError(
172 f"No models found on the server at {base_url}. "
173 "Make sure the server is running and has models loaded."
174 )
175 except (aiohttp.ClientError, json.JSONDecodeError) as e:
176 raise RuntimeError(
177 f"Failed to fetch models from server at {models_url}. "
178 "Check that:\n"
179 "1. The server is running\n"
180 "2. The server URL is correct\n"
181 f"Error: {e}"
182 ) from e
183
184
185 @dataclass
186 class SpecDecodeMetrics:
187 """Speculative decoding metrics from the server's Prometheus endpoint."""
188
189 num_drafts: int
190 num_draft_tokens: int
191 num_accepted_tokens: int
192 accepted_per_pos: dict[int, int]
193
194
195 async def fetch_spec_decode_metrics(
196 base_url: str, session: aiohttp.ClientSession
197 ) -> SpecDecodeMetrics | None:
198 """Fetch speculative decoding metrics from the server's Prometheus endpoint.
199
200 Returns None if speculative decoding is not enabled or metrics are not available.
201 """
202 metrics_url = f"{base_url}/metrics"
203 try:
204 async with session.get(metrics_url) as response:
205 if response.status != 200:
206 return None
207 text = await response.text()
208
209 num_drafts = 0
210 num_draft_tokens = 0
211 num_accepted_tokens = 0
212 accepted_per_pos: dict[int, int] = {}
213 found_spec_decode = False
214
215 for line in text.split("\n"):
216 line = line.strip()
217 if not line or line.startswith("#"):
218 continue
219
220 if line.startswith("vllm:spec_decode"):
221 # Extract metric name (before labels) to avoid matching
222 # substrings inside label values.
223 parts = line.split(None, 1)
224 metric_name = parts[0].split("{")[0]
225 if not metric_name.endswith("_total"):
226 continue
227 found_spec_decode = True
228 with contextlib.suppress(ValueError):
229 if "num_drafts" in metric_name:
230 num_drafts += int(float(parts[-1]))
231 elif "num_draft_tokens" in metric_name:
232 num_draft_tokens += int(float(parts[-1]))
233 elif "num_accepted_tokens_per_pos" in metric_name:
234 pos_label = 'position="'
235 if pos_label in line:
236 start = line.index(pos_label) + len(pos_label)
237 end = line.index('"', start)
238 pos = int(line[start:end])
239 val = int(float(parts[-1]))
240 accepted_per_pos[pos] = (
241 accepted_per_pos.get(pos, 0) + val
242 )
243 elif "num_accepted_tokens" in metric_name:
244 num_accepted_tokens += int(float(parts[-1]))
245
246 if not found_spec_decode:
247 return None
248
249 return SpecDecodeMetrics(
250 num_drafts=num_drafts,
251 num_draft_tokens=num_draft_tokens,
252 num_accepted_tokens=num_accepted_tokens,
253 accepted_per_pos=accepted_per_pos,
254 )
255 except (aiohttp.ClientError, asyncio.TimeoutError):
256 return None
257
258
259 @dataclass
260 class DiffusionMetrics:
261 """Diffusion (dLLM) decoding metrics from the server's Prometheus endpoint."""
262
263 num_denoising_steps: int
264 num_canvas_positions: int
265 num_committed_tokens: int
266
267
268 async def fetch_diffusion_metrics(
269 base_url: str, session: aiohttp.ClientSession
270 ) -> DiffusionMetrics | None:
271 """Fetch diffusion decoding metrics from the server's Prometheus endpoint.
272
273 Returns None if the model is not a diffusion model or metrics are not
274 available.
275 """
276 metrics_url = f"{base_url}/metrics"
277 try:
278 async with session.get(metrics_url) as response:
279 if response.status != 200:
280 return None
281 text = await response.text()
282
283 num_denoising_steps = 0
284 num_canvas_positions = 0
285 num_committed_tokens = 0
286 found_diffusion = False
287
288 for line in text.split("\n"):
289 line = line.strip()
290 if not line or line.startswith("#"):
291 continue
292
293 if line.startswith("vllm:diffusion"):
294 # Extract metric name (before labels) to avoid matching
295 # substrings inside label values.
296 parts = line.split(None, 1)
297 metric_name = parts[0].split("{")[0]
298 if not metric_name.endswith("_total"):
299 continue
300 found_diffusion = True
301 with contextlib.suppress(ValueError):
302 if "num_denoising_steps" in metric_name:
303 num_denoising_steps += int(float(parts[-1]))
304 elif "num_canvas_positions" in metric_name:
305 num_canvas_positions += int(float(parts[-1]))
306 elif "num_committed_tokens" in metric_name:
307 num_committed_tokens += int(float(parts[-1]))
308
309 if not found_diffusion:
310 return None
311
312 return DiffusionMetrics(
313 num_denoising_steps=num_denoising_steps,
314 num_canvas_positions=num_canvas_positions,
315 num_committed_tokens=num_committed_tokens,
316 )
317 except (aiohttp.ClientError, asyncio.TimeoutError):
318 return None
319
320
321 class TaskType(Enum):
322 GENERATION = "generation"
323 POOLING = "pooling"
324
325
326 @dataclass
327 class BenchmarkMetrics:
328 completed: int
329 failed: int
330 total_input: int
331 total_output: int
332 request_throughput: float
333 request_goodput: float
334 output_throughput: float
335 total_token_throughput: float
336 mean_ttft_ms: float
337 median_ttft_ms: float
338 std_ttft_ms: float
339 percentiles_ttft_ms: list[tuple[float, float]]
340 mean_tpot_ms: float
341 median_tpot_ms: float
342 std_tpot_ms: float
343 percentiles_tpot_ms: list[tuple[float, float]]
344 mean_itl_ms: float
345 median_itl_ms: float
346 std_itl_ms: float
347 percentiles_itl_ms: list[tuple[float, float]]
348 # E2EL stands for end-to-end latency per request.
349 # It is the time taken on the client side from sending
350 # a request to receiving a complete response.
351 mean_e2el_ms: float
352 median_e2el_ms: float
353 std_e2el_ms: float
354 percentiles_e2el_ms: list[tuple[float, float]]
355 # Max output tokens per second and concurrent requests at that peak
356 max_output_tokens_per_s: float
357 max_concurrent_requests: int
358 rtfx: float = 0.0 # Inverse Real-Time Factor for ASR benchmarks
359
360
361 @dataclass
362 class EmbedBenchmarkMetrics:
363 completed: int
364 failed: int
365 total_input: int
366 total_input_sequences: int
367 request_throughput: float
368 input_sequence_throughput: float
369 total_token_throughput: float
370 mean_e2el_ms: float
371 std_e2el_ms: float
372 median_e2el_ms: float
373 percentiles_e2el_ms: list[tuple[float, float]]
374
375
376 def _get_current_request_rate(
377 ramp_up_strategy: Literal["linear", "exponential"] | None,
378 ramp_up_start_rps: int | None,
379 ramp_up_end_rps: int | None,
380 request_index: int,
381 total_requests: int,
382 request_rate: float,
383 ) -> float:
384 if (
385 ramp_up_strategy
386 and ramp_up_start_rps is not None
387 and ramp_up_end_rps is not None
388 ):
389 progress = request_index / max(total_requests - 1, 1)
390 if ramp_up_strategy == "linear":
391 increase = (ramp_up_end_rps - ramp_up_start_rps) * progress
392 return ramp_up_start_rps + increase
393 elif ramp_up_strategy == "exponential":
394 ratio = ramp_up_end_rps / ramp_up_start_rps
395 return ramp_up_start_rps * (ratio**progress)
396 else:
397 raise ValueError(f"Unknown ramp-up strategy: {ramp_up_strategy}")
398 return request_rate
399
400
401 async def get_request(
402 input_requests: list[SampleRequest],
403 request_rate: float,
404 burstiness: float = 1.0,
405 ramp_up_strategy: Literal["linear", "exponential"] | None = None,
406 ramp_up_start_rps: int | None = None,
407 ramp_up_end_rps: int | None = None,
408 self_timed: bool = False,
409 ) -> AsyncGenerator[tuple[SampleRequest, float], None]:
410 """Asynchronously generates requests at a specified rate
411 with OPTIONAL burstiness and OPTIONAL ramp-up strategy.
412
413 Args:
414 input_requests:
415 A list of input requests, each represented as a SampleRequest.
416 self_timed:
417 If True, the requests carry their own arrival timing and no
418 request rate, burstiness or ramp-up is applied.
419 request_rate:
420 The rate at which requests are generated (requests/s).
421 burstiness (optional):
422 The burstiness factor of the request generation.
423 Only takes effect when request_rate is not inf.
424 Default value is 1, which follows a Poisson process.
425 Otherwise, the request intervals follow a gamma distribution.
426 A lower burstiness value (0 < burstiness < 1) results
427 in more bursty requests, while a higher burstiness value
428 (burstiness > 1) results in a more uniform arrival of requests.
429 ramp_up_strategy (optional):
430 The ramp-up strategy. Can be "linear" or "exponential".
431 If None, uses constant request rate (specified by request_rate).
432 ramp_up_start_rps (optional):
433 The starting request rate for ramp-up.
434 ramp_up_end_rps (optional):
435 The ending request rate for ramp-up.
436
437 """
438 assert burstiness > 0, (
439 f"A positive burstiness factor is expected, but given {burstiness}."
440 )
441 # Convert to list to get length for ramp-up calculations
442 if isinstance(input_requests, Iterable) and not isinstance(input_requests, list):
443 input_requests = list(input_requests)
444
445 total_requests = len(input_requests)
446 assert total_requests > 0, "No requests provided."
447
448 # Precompute delays among requests to minimize request send laggings
449 request_rates: list[float] = []
450 delay_ts: list[float] = []
451
452 # if the traces have timing info then:
453 if not self_timed:
454 for request_index, request in enumerate(input_requests):
455 current_request_rate = _get_current_request_rate(
456 ramp_up_strategy,
457 ramp_up_start_rps,
458 ramp_up_end_rps,
459 request_index,
460 total_requests,
461 request_rate,
462 )
463 assert current_request_rate > 0.0, (
464 f"Obtained non-positive request rate {current_request_rate}."
465 )
466 request_rates.append(current_request_rate)
467 if current_request_rate == float("inf"):
468 delay_ts.append(0)
469 elif burstiness == float("inf"):
470 # when burstiness tends to infinity, the delay time becomes constant
471 # and tends to the inverse of the request rate
472 delay_ts.append(1.0 / current_request_rate)
473 else:
474 theta = 1.0 / (current_request_rate * burstiness)
475
476 # Sample the request interval from the gamma distribution.
477 # If burstiness is 1, it follows exponential distribution.
478 delay_ts.append(np.random.gamma(shape=burstiness, scale=theta))
479
480 # Calculate the cumulative delay time from the first sent out requests.
481 for i in range(1, len(delay_ts)):
482 delay_ts[i] += delay_ts[i - 1]
483 if ramp_up_strategy is None and delay_ts[-1] != 0:
484 # When ramp_up_strategy is not set, we assume the request rate is fixed
485 # and all requests should be sent in target_total_delay_s, the following
486 # logic would re-scale delay time to ensure the final delay_ts
487 # align with target_total_delay_s.
488 #
489 # NOTE: If we simply accumulate the random delta values
490 # from the gamma distribution, their sum would have 1-2% gap
491 # from target_total_delay_s. The purpose of the following logic is to
492 # close the gap for stabilizing the throughput data
493 # from different random seeds.
494 target_total_delay_s = total_requests / request_rate
495 normalize_factor = target_total_delay_s / delay_ts[-1]
496 delay_ts = [delay * normalize_factor for delay in delay_ts]
497 else:
498 for request_index, request in enumerate(input_requests):
499 # this is cumulative running ts, from which sleep is calculated later
500 if request.timestamp is not None:
501 delay_ts.append(request.timestamp)
502 else:
503 delay_ts.append(0.0)
504 # TODO: there is no notion of RPS here, may be we can calculate
505 # from the trace.
506 request_rates.append(0.0)
507
508 start_ts = time.time()
509 for request_index, request in enumerate(input_requests):
510 if delay_ts[request_index] > 0:
511 current_ts = time.time()
512 sleep_interval_s = start_ts + delay_ts[request_index] - current_ts
513 if sleep_interval_s > 0:
514 await asyncio.sleep(sleep_interval_s)
515 yield request, request_rates[request_index]
516
517
518 def calculate_metrics_for_embeddings(
519 outputs: list[RequestFuncOutput],
520 dur_s: float,
521 selected_percentiles: list[float],
522 ) -> EmbedBenchmarkMetrics:
523 """Calculate the metrics for the embedding requests.
524
525 Args:
526 outputs: The outputs of the requests.
527 dur_s: The duration of the benchmark.
528 selected_percentiles: The percentiles to select.
529
530 Returns:
531 The calculated benchmark metrics.
532
533 """
534 total_input = 0
535 total_input_sequences = 0
536 completed = 0
537 failed = 0
538 e2els: list[float] = []
539 for i in range(len(outputs)):
540 if outputs[i].success:
541 e2els.append(outputs[i].latency)
542 completed += 1
543 total_input += outputs[i].prompt_len
544 total_input_sequences += outputs[i].num_input_sequences
545 else:
546 failed += 1
547
548 if completed == 0:
549 warnings.warn(
550 "All requests failed. This is likely due to a misconfiguration "
551 "on the benchmark arguments.",
552 stacklevel=2,
553 )
554 metrics = EmbedBenchmarkMetrics(
555 completed=completed,
556 failed=failed,
557 total_input=total_input,
558 total_input_sequences=total_input_sequences,
559 request_throughput=completed / dur_s,
560 input_sequence_throughput=total_input_sequences / dur_s,
561 total_token_throughput=total_input / dur_s,
562 mean_e2el_ms=np.mean(e2els or 0) * 1000,
563 std_e2el_ms=np.std(e2els or 0) * 1000,
564 median_e2el_ms=np.median(e2els or 0) * 1000,
565 percentiles_e2el_ms=[
566 (p, np.percentile(e2els or 0, p) * 1000) for p in selected_percentiles
567 ],
568 )
569 return metrics
570
571
572 def calculate_metrics(
573 input_requests: list[SampleRequest],
574 outputs: list[RequestFuncOutput],
575 dur_s: float,
576 tokenizer: TokenizerLike | None,
577 selected_percentiles: list[float],
578 goodput_config_dict: dict[str, float],
579 ) -> tuple[BenchmarkMetrics, list[int]]:
580 """Calculate the metrics for the benchmark.
581
582 Args:
583 input_requests: The input requests.
584 outputs: The outputs of the requests.
585 dur_s: The duration of the benchmark.
586 tokenizer: The tokenizer to use.
587 selected_percentiles: The percentiles to select.
588 goodput_config_dict: The goodput configuration.
589
590 Returns:
591 A tuple of the benchmark metrics and the actual output lengths.
592
593 """
594 actual_output_lens: list[int] = []
595 total_input = 0
596 completed = 0
597 good_completed = 0
598 itls: list[float] = []
599 tpots: list[float] = []
600 all_tpots: list[float] = []
601 ttfts: list[float] = []
602 e2els: list[float] = []
603 input_audio_duration = 0.0
604 for i in range(len(outputs)):
605 if outputs[i].success:
606 output_len = outputs[i].output_tokens
607
608 if not output_len:
609 if tokenizer is None:
610 output_len = 1
611 else:
612 # We use the tokenizer to count the number of output tokens
613 # for some serving backends instead of looking at
614 # len(outputs[i].itl) since multiple output tokens may be
615 # bundled together
616 # Note : this may inflate the output token count slightly
617 output_len = len(
618 tokenizer(
619 outputs[i].generated_text, add_special_tokens=False
620 ).input_ids
621 )
622 actual_output_lens.append(output_len)
623 total_input += outputs[i].prompt_len
624 tpot = 0.0
625 if output_len > 1:
626 latency_minus_ttft = outputs[i].latency - outputs[i].ttft
627 tpot = latency_minus_ttft / (output_len - 1)
628 tpots.append(tpot)
629 # Note: if output_len <= 1, we regard tpot as 0 for goodput
630 all_tpots.append(tpot)
631 itls += outputs[i].itl
632 ttfts.append(outputs[i].ttft)
633 e2els.append(outputs[i].latency)
634 input_audio_duration += outputs[i].input_audio_duration
635 completed += 1
636 else:
637 actual_output_lens.append(0)
638
639 if goodput_config_dict:
640 valid_metrics = []
641 slo_values = []
642
643 if "ttft" in goodput_config_dict:
644 valid_metrics.append(ttfts)
645 slo_values.append(
646 goodput_config_dict["ttft"] / MILLISECONDS_TO_SECONDS_CONVERSION
647 )
648 if "tpot" in goodput_config_dict:
649 valid_metrics.append(all_tpots)
650 slo_values.append(
651 goodput_config_dict["tpot"] / MILLISECONDS_TO_SECONDS_CONVERSION
652 )
653 if "e2el" in goodput_config_dict:
654 valid_metrics.append(e2els)
655 slo_values.append(
656 goodput_config_dict["e2el"] / MILLISECONDS_TO_SECONDS_CONVERSION
657 )
658
659 for req_metric in zip(*valid_metrics):
660 is_good_req = all([s >= r for s, r in zip(slo_values, req_metric)])
661 if is_good_req:
662 good_completed += 1
663
664 if completed == 0:
665 warnings.warn(
666 "All requests failed. This is likely due to a misconfiguration "
667 "on the benchmark arguments.",
668 stacklevel=2,
669 )
670
671 # Calculate max output tokens per second metric
672 max_output_tokens_per_s = 0.0
673 max_concurrent_requests = 0
674
675 # Find the time range across all successful requests
676 successful_outputs = [output for output in outputs if output.success]
677 failed_outputs = [output for output in outputs if not output.success]
678
679 if len(failed_outputs) > 0:
680 print("Failed requests during benchmark run detected (capping to 10):")
681 for i, err in enumerate(failed_outputs[:10]):
682 print(f"Error {i}: {err.error}")
683
684 if successful_outputs:
685 min_start_time = min(output.start_time for output in successful_outputs)
686 max_end_time = max(
687 output.start_time + output.latency for output in successful_outputs
688 )
689
690 # Create second buckets (ceiling to ensure we capture all time)
691 duration_seconds = int(np.ceil(max_end_time - min_start_time)) + 1
692 tokens_per_second = np.zeros(duration_seconds)
693 concurrent_requests_per_second = np.zeros(duration_seconds)
694
695 for i, output in enumerate(successful_outputs):
696 # Calculate token generation timestamp using
697 # start_time, ttft, and itl
698 token_times = [output.start_time + output.ttft]
699 current_time = token_times[0]
700 for itl_value in output.itl:
701 current_time += itl_value
702 token_times.append(current_time)
703
704 # Add tokens to second buckets
705 for token_time in token_times:
706 second_bucket = int(token_time - min_start_time)
707 if 0 <= second_bucket < duration_seconds:
708 tokens_per_second[second_bucket] += 1
709
710 # Track concurrent requests for each second this request was active
711 request_start_second = int(output.start_time - min_start_time)
712 request_end_second = int(
713 (output.start_time + output.latency) - min_start_time
714 )
715
716 for second in range(request_start_second, request_end_second + 1):
717 concurrent_requests_per_second[second] += 1
718
719 # Find the maximum tokens per second and corresponding
720 # concurrent requests
721 if len(tokens_per_second) > 0:
722 max_output_tokens_per_s = float(np.max(tokens_per_second))
723 max_concurrent_requests = int(np.max(concurrent_requests_per_second))
724
725 if TERM_PLOTLIB_AVAILABLE:
726 import termplotlib as tpl
727
728 fig = tpl.figure()
729 fig.plot(
730 np.arange(len(tokens_per_second)),
731 tokens_per_second,
732 title="Output tokens per second",
733 )
734 fig.plot(
735 np.arange(len(concurrent_requests_per_second)),
736 concurrent_requests_per_second,
737 title="Concurrent requests per second",
738 )
739 fig.show()
740 else:
741 print("tip: install termplotlib and gnuplot to plot the metrics")
742
743 metrics = BenchmarkMetrics(
744 completed=completed,
745 failed=len(failed_outputs),
746 total_input=total_input,
747 total_output=sum(actual_output_lens),
748 request_throughput=completed / dur_s,
749 request_goodput=good_completed / dur_s,
750 output_throughput=sum(actual_output_lens) / dur_s,
751 total_token_throughput=(total_input + sum(actual_output_lens)) / dur_s,
752 mean_ttft_ms=np.mean(ttfts or 0)
753 * 1000, # ttfts is empty if streaming is not supported by the endpoint
754 std_ttft_ms=np.std(ttfts or 0) * 1000,
755 median_ttft_ms=np.median(ttfts or 0) * 1000,
756 percentiles_ttft_ms=[
757 (p, np.percentile(ttfts or 0, p) * 1000) for p in selected_percentiles
758 ],
759 mean_tpot_ms=np.mean(tpots or 0) * 1000,
760 std_tpot_ms=np.std(tpots or 0) * 1000,
761 median_tpot_ms=np.median(tpots or 0) * 1000,
762 percentiles_tpot_ms=[
763 (p, np.percentile(tpots or 0, p) * 1000) for p in selected_percentiles
764 ],
765 mean_itl_ms=np.mean(itls or 0) * 1000,
766 std_itl_ms=np.std(itls or 0) * 1000,
767 median_itl_ms=np.median(itls or 0) * 1000,
768 percentiles_itl_ms=[
769 (p, np.percentile(itls or 0, p) * 1000) for p in selected_percentiles
770 ],
771 mean_e2el_ms=np.mean(e2els or 0) * 1000,
772 std_e2el_ms=np.std(e2els or 0) * 1000,
773 median_e2el_ms=np.median(e2els or 0) * 1000,
774 percentiles_e2el_ms=[
775 (p, np.percentile(e2els or 0, p) * 1000) for p in selected_percentiles
776 ],
777 max_output_tokens_per_s=max_output_tokens_per_s,
778 max_concurrent_requests=max_concurrent_requests,
779 rtfx=input_audio_duration / dur_s,
780 )
781
782 return metrics, actual_output_lens
783
784
785 async def benchmark(
786 task_type: TaskType,
787 endpoint_type: str,
788 api_url: str,
789 base_url: str,
790 model_id: str,
791 model_name: str,
792 tokenizer: TokenizerLike | None,
793 input_requests: list[SampleRequest],
794 logprobs: int | None,
795 request_rate: float,
796 burstiness: float,
797 disable_tqdm: bool,
798 num_warmups: int,
799 profile: bool,
800 selected_percentile_metrics: list[str],
801 selected_percentiles: list[float],
802 ignore_eos: bool,
803 goodput_config_dict: dict[str, float],
804 max_concurrency: int | None,
805 lora_modules: Iterable[str] | None,
806 extra_headers: dict | None,
807 extra_body: dict | None,
808 lora_assignment: Literal["random", "round-robin"] = "random",
809 ramp_up_strategy: Literal["linear", "exponential"] | None = None,
810 ramp_up_start_rps: int | None = None,
811 ramp_up_end_rps: int | None = None,
812 ready_check_timeout_sec: int = 600,
813 ssl_context: ssl.SSLContext | bool | None = None,
814 self_timed: bool = False,
815 probe_request_rate: float = 0.0,
816 ):
817 try:
818 request_func = ASYNC_REQUEST_FUNCS[endpoint_type]
819 except KeyError:
820 raise ValueError(f"Unknown backend: {endpoint_type}") from None
821
822 # Reuses connections across requests to reduce TLS handshake overhead.
823 # Use ssl_context if provided, otherwise default to True for https URLs
824 ssl_setting = ssl_context if ssl_context is not None else ("https://" in api_url)
825 connector = aiohttp.TCPConnector(
826 limit=max_concurrency or 0,
827 limit_per_host=max_concurrency or 0,
828 ttl_dns_cache=300,
829 use_dns_cache=True,
830 keepalive_timeout=60,
831 enable_cleanup_closed=True,
832 force_close=False,
833 ssl=ssl_setting,
834 )
835
836 session = aiohttp.ClientSession(
837 connector=connector,
838 trust_env=True,
839 timeout=aiohttp.ClientTimeout(total=6 * 60 * 60),
840 )
841
842 print("Starting initial single prompt test run...")
843 test_prompt, test_prompt_len, test_output_len, test_mm_content = (
844 input_requests[0].prompt,
845 input_requests[0].prompt_len,
846 input_requests[0].expected_output_len,
847 input_requests[0].multi_modal_data,
848 )
849 test_extra_body = _merge_overrides(extra_body, input_requests[0].request_overrides)
850 test_chat_messages = input_requests[0].chat_messages
851
852 assert (
853 test_mm_content is None
854 or isinstance(test_mm_content, dict)
855 or (
856 isinstance(test_mm_content, list)
857 and all(isinstance(item, dict) for item in test_mm_content)
858 )
859 ), "multi_modal_data must be a dict or list[dict]"
860 test_input = RequestFuncInput(
861 model=model_id,
862 model_name=model_name,
863 prompt=test_prompt,
864 api_url=api_url,
865 prompt_len=test_prompt_len,
866 output_len=test_output_len,
867 logprobs=logprobs,
868 multi_modal_content=test_mm_content,
869 ignore_eos=ignore_eos,
870 extra_headers=extra_headers,
871 extra_body=test_extra_body,
872 chat_messages=test_chat_messages,
873 )
874
875 if ready_check_timeout_sec > 0:
876 test_output = await wait_for_endpoint(
877 request_func,
878 test_input,
879 session,
880 timeout_seconds=ready_check_timeout_sec,
881 )
882 if not test_output.success:
883 raise ValueError(
884 "Initial test run failed - Please make sure benchmark "
885 "arguments are correctly specified. "
886 f"Error: {test_output.error}"
887 )
888 else:
889 print("Initial test run completed.")
890 else:
891 print("Skipping endpoint ready check.")
892
893 if num_warmups > 0:
894 print(f"Warming up with {num_warmups} requests...")
895 warmup_pbar = None if disable_tqdm else tqdm(total=num_warmups)
896 warmup_semaphore = (
897 asyncio.Semaphore(max_concurrency)
898 if max_concurrency
899 else contextlib.nullcontext()
900 )
901 warmup_tasks = []
902
903 async def warmup_limited_request_func():
904 async with warmup_semaphore:
905 return await request_func(
906 request_func_input=test_input, session=session, pbar=warmup_pbar
907 )
908
909 for _ in range(num_warmups):
910 request_task = asyncio.create_task(warmup_limited_request_func())
911 warmup_tasks.append(request_task)
912 _ = await asyncio.gather(*warmup_tasks)
913
914 if warmup_pbar is not None:
915 warmup_pbar.close()
916 print("Warmup run completed.")
917
918 print("Starting main benchmark run...")
919
920 lora_modules_iter: Iterator[str] | None = None
921 if lora_modules:
922 lora_modules_list = list(lora_modules)
923 if lora_assignment == "round-robin":
924 # Deterministic round-robin assignment across requests.
925 lora_modules_iter = iter(
926 [
927 lora_modules_list[i % len(lora_modules_list)]
928 for i in range(len(input_requests))
929 ]
930 )
931 else:
932 # For each input request, choose a LoRA module at random.
933 lora_modules_iter = iter(
934 [random.choice(lora_modules_list) for _ in range(len(input_requests))]
935 )
936
937 if profile:
938 print("Starting profiler...")
939 profile_input = RequestFuncInput(
940 model=model_id,
941 model_name=model_name,
942 prompt=test_prompt,
943 api_url=base_url + "/start_profile",
944 prompt_len=test_prompt_len,
945 output_len=test_output_len,
946 logprobs=logprobs,
947 multi_modal_content=test_mm_content,
948 ignore_eos=ignore_eos,
949 extra_headers=extra_headers,
950 extra_body=test_extra_body,
951 chat_messages=test_chat_messages,
952 )
953 profile_output = await request_func(
954 request_func_input=profile_input, session=session
955 )
956 if profile_output.success:
957 print("Profiler started")
958
959 distribution = "Poisson process" if burstiness == 1.0 else "Gamma distribution"
960 if not self_timed:
961 if ramp_up_strategy is not None:
962 print(f"Traffic ramp-up strategy: {ramp_up_strategy}.")
963 print(
964 f"Will increase RPS from {ramp_up_start_rps} to "
965 f"{ramp_up_end_rps} RPS over the duration of the benchmark."
966 )
967 else:
968 print(f"Traffic request rate: {request_rate}")
969
970 print(f"Burstiness factor: {burstiness} ({distribution})")
971 print(f"Maximum request concurrency: {max_concurrency}")
972 else:
973 print("Self timing is set, using the timestamps from the trace file.")
974
975 spec_decode_metrics_before = await fetch_spec_decode_metrics(base_url, session)
976 diffusion_metrics_before = await fetch_diffusion_metrics(base_url, session)
977
978 pbar = None if disable_tqdm else tqdm(total=len(input_requests))
979
980 semaphore = (
981 asyncio.Semaphore(max_concurrency)
982 if max_concurrency
983 else contextlib.nullcontext()
984 )
985
986 async def limited_request_func(
987 request_func_input, session, pbar, request_arrival_time
988 ):
989 async with semaphore:
990 output = await request_func(
991 request_func_input=request_func_input, session=session, pbar=pbar
992 )
993 # Preserve start_time as the time the request was sent. It is used by
994 # throughput calculations; record client-side semaphore delay separately.
995 output.client_queue_time = output.start_time - request_arrival_time
996 return output
997
998 probe_outputs: list[RequestFuncOutput] = []
999 probe_stop = asyncio.Event()
1000
1001 async def probe_loop():
1002 probe_input = replace(
1003 test_input,
1004 prompt="Hi",
1005 prompt_len=1,
1006 output_len=1,
1007 multi_modal_content=None,
1008 chat_messages=None,
1009 )
1010 interval = 1 / probe_request_rate
1011 while not probe_stop.is_set():
1012 probe_outputs.append(
1013 await request_func(request_func_input=probe_input, session=session)
1014 )
1015 await asyncio.sleep(interval)
1016
1017 probe_task: asyncio.Task | None = None
1018 if probe_request_rate > 0:
1019 print(f"Probe request rate: {probe_request_rate} req/s")
1020 probe_task = asyncio.create_task(probe_loop())
1021
1022 benchmark_start_time = time.perf_counter()
1023 tasks: list[asyncio.Task] = []
1024
1025 rps_change_events = []
1026 last_int_rps = -1
1027 if ramp_up_strategy is not None and ramp_up_start_rps is not None:
1028 last_int_rps = ramp_up_start_rps
1029 rps_change_events.append(
1030 {
1031 "rps": last_int_rps,
1032 "timestamp": datetime.now().isoformat(),
1033 }
1034 )
1035
1036 async for request, current_request_rate in get_request(
1037 input_requests,
1038 request_rate,
1039 burstiness,
1040 ramp_up_strategy,
1041 ramp_up_start_rps,
1042 ramp_up_end_rps,
1043 self_timed,
1044 ):
1045 if ramp_up_strategy is not None:
1046 current_int_rps = int(current_request_rate)
1047 if current_int_rps > last_int_rps:
1048 timestamp = datetime.now().isoformat()
1049 for rps_val in range(last_int_rps + 1, current_int_rps + 1):
1050 rps_change_events.append({"rps": rps_val, "timestamp": timestamp})
1051 last_int_rps = current_int_rps
1052 prompt, prompt_len, output_len, mm_content, request_id = (
1053 request.prompt,
1054 request.prompt_len,
1055 request.expected_output_len,
1056 request.multi_modal_data,
1057 request.request_id,
1058 )
1059 per_request_extra_body = _merge_overrides(extra_body, request.request_overrides)
1060 req_model_id, req_model_name = model_id, model_name
1061 if lora_modules_iter:
1062 req_lora_module = next(lora_modules_iter)
1063 req_model_id, req_model_name = req_lora_module, req_lora_module
1064
1065 mm_content_typed: dict[str, Any] | list[dict[str, Any]] | None = None
1066 if isinstance(mm_content, (dict, list)):
1067 mm_content_typed = mm_content
1068
1069 request_func_input = RequestFuncInput(
1070 model=req_model_id,
1071 model_name=req_model_name,
1072 prompt=prompt,
1073 api_url=api_url,
1074 prompt_len=prompt_len,
1075 output_len=output_len or 0,
1076 logprobs=logprobs,
1077 multi_modal_content=mm_content_typed,
1078 ignore_eos=ignore_eos,
1079 extra_headers=extra_headers,
1080 extra_body=per_request_extra_body,
1081 request_id=request_id,
1082 chat_messages=request.chat_messages,
1083 )
1084 request_arrival_time = time.perf_counter()
1085 tasks.append(
1086 asyncio.create_task(
1087 limited_request_func(
1088 request_func_input, session, pbar, request_arrival_time
1089 )
1090 )
1091 )
1092 outputs: list[RequestFuncOutput] = await asyncio.gather(*tasks)
1093
1094 if probe_task is not None:
1095 probe_stop.set()
1096 await probe_task
1097
1098 if pbar is not None:
1099 pbar.close()
1100
1101 benchmark_duration = time.perf_counter() - benchmark_start_time
1102
1103 spec_decode_metrics_after = await fetch_spec_decode_metrics(base_url, session)
1104 spec_decode_stats: dict[str, Any] | None = None
1105 if spec_decode_metrics_before is not None and spec_decode_metrics_after is not None:
1106 delta_drafts = (
1107 spec_decode_metrics_after.num_drafts - spec_decode_metrics_before.num_drafts
1108 )
1109 delta_draft_tokens = (
1110 spec_decode_metrics_after.num_draft_tokens
1111 - spec_decode_metrics_before.num_draft_tokens
1112 )
1113 delta_accepted = (
1114 spec_decode_metrics_after.num_accepted_tokens
1115 - spec_decode_metrics_before.num_accepted_tokens
1116 )
1117 per_pos_rates: list[float] = []
1118 if delta_drafts > 0:
1119 positions = sorted(
1120 set(spec_decode_metrics_before.accepted_per_pos.keys())
1121 | set(spec_decode_metrics_after.accepted_per_pos.keys())
1122 )
1123 for pos in positions:
1124 before_val = spec_decode_metrics_before.accepted_per_pos.get(pos, 0)
1125 after_val = spec_decode_metrics_after.accepted_per_pos.get(
1126 pos, before_val
1127 )
1128 delta_pos = after_val - before_val
1129 per_pos_rates.append(delta_pos / delta_drafts)
1130
1131 if delta_draft_tokens > 0:
1132 acceptance_rate = (delta_accepted / delta_draft_tokens) * 100
1133 acceptance_length = (
1134 1 + delta_accepted / delta_drafts if delta_drafts > 0 else 0.0
1135 )
1136 spec_decode_stats = {
1137 "num_drafts": delta_drafts,
1138 "draft_tokens": delta_draft_tokens,
1139 "accepted_tokens": delta_accepted,
1140 "acceptance_rate": acceptance_rate,
1141 "acceptance_length": acceptance_length,
1142 "per_position_acceptance_rates": per_pos_rates,
1143 }
1144
1145 diffusion_metrics_after = await fetch_diffusion_metrics(base_url, session)
1146 diffusion_stats: dict[str, Any] | None = None
1147 if diffusion_metrics_before is not None and diffusion_metrics_after is not None:
1148 delta_steps = (
1149 diffusion_metrics_after.num_denoising_steps
1150 - diffusion_metrics_before.num_denoising_steps
1151 )
1152 delta_positions = (
1153 diffusion_metrics_after.num_canvas_positions
1154 - diffusion_metrics_before.num_canvas_positions
1155 )
1156 delta_committed = (
1157 diffusion_metrics_after.num_committed_tokens
1158 - diffusion_metrics_before.num_committed_tokens
1159 )
1160 if delta_steps > 0 and delta_committed > 0:
1161 block_size = delta_positions / delta_steps # canvas length (CL)
1162 num_canvases = delta_committed / block_size # = number of commit steps
1163 denoising_steps = delta_steps - num_canvases # exclude commit steps
1164 diffusion_stats = {
1165 "denoising_steps": denoising_steps,
1166 "canvas_positions": delta_positions,
1167 "committed_tokens": delta_committed,
1168 "committed_throughput": delta_committed / benchmark_duration,
1169 "steps_per_canvas": denoising_steps / num_canvases,
1170 "committed_per_step": delta_committed / denoising_steps,
1171 }
1172
1173 metrics: BenchmarkMetrics | EmbedBenchmarkMetrics
1174 actual_output_lens: list[int] | int
1175 if task_type == TaskType.GENERATION:
1176 metrics, actual_output_lens = calculate_metrics(
1177 input_requests=input_requests,
1178 outputs=outputs,
1179 dur_s=benchmark_duration,
1180 tokenizer=tokenizer,
1181 selected_percentiles=selected_percentiles,
1182 goodput_config_dict=goodput_config_dict,
1183 )
1184 else:
1185 metrics = calculate_metrics_for_embeddings(
1186 outputs=outputs,
1187 dur_s=benchmark_duration,
1188 selected_percentiles=selected_percentiles,
1189 )
1190 actual_output_lens = 0
1191
1192 print("{s:{c}^{n}}".format(s=" Serving Benchmark Result ", n=50, c="="))
1193 print("{:<40} {:<10}".format("Successful requests:", metrics.completed))
1194 print("{:<40} {:<10}".format("Failed requests:", metrics.failed))
1195 if max_concurrency is not None:
1196 print("{:<40} {:<10}".format("Maximum request concurrency:", max_concurrency))
1197 if request_rate != float("inf"):
1198 print("{:<40} {:<10.2f}".format("Request rate configured (RPS):", request_rate))
1199 print("{:<40} {:<10.2f}".format("Benchmark duration (s):", benchmark_duration))
1200 print("{:<40} {:<10}".format("Total input tokens:", metrics.total_input))
1201 if isinstance(metrics, BenchmarkMetrics) and tokenizer:
1202 print("{:<40} {:<10}".format("Total generated tokens:", metrics.total_output))
1203 print(
1204 "{:<40} {:<10.2f}".format(
1205 "Request throughput (req/s):", metrics.request_throughput
1206 )
1207 )
1208 if isinstance(metrics, EmbedBenchmarkMetrics):
1209 print(
1210 "{:<40} {:<10.2f}".format(
1211 "Input throughput (inputs/s):", metrics.input_sequence_throughput
1212 )
1213 )
1214 if goodput_config_dict and isinstance(metrics, BenchmarkMetrics):
1215 print(
1216 "{:<40} {:<10.2f}".format(
1217 "Request goodput (req/s):", metrics.request_goodput
1218 )
1219 )
1220 if isinstance(metrics, BenchmarkMetrics):
1221 if tokenizer:
1222 print(
1223 "{:<40} {:<10.2f}".format(
1224 "Output token throughput (tok/s):", metrics.output_throughput
1225 )
1226 )
1227 print(
1228 "{:<40} {:<10.2f}".format(
1229 "Peak output token throughput (tok/s):",
1230 metrics.max_output_tokens_per_s,
1231 )
1232 )
1233 print(
1234 "{:<40} {:<10.2f}".format(
1235 "Peak concurrent requests:", metrics.max_concurrent_requests
1236 )
1237 )
1238 if metrics.rtfx > 0.0:
1239 print(
1240 "{:<40} {:<10.2f}".format(
1241 "RTFx (Inverse Real-Time Factor):", metrics.rtfx
1242 )
1243 )
1244 if tokenizer:
1245 print(
1246 "{:<40} {:<10.2f}".format(
1247 "Total token throughput (tok/s):", metrics.total_token_throughput
1248 )
1249 )
1250
1251 probe_stats: dict[str, Any] | None = None
1252 if probe_task is not None:
1253 probe_lats = [o.latency for o in probe_outputs if o.success]
1254 if probe_lats:
1255 probe_stats = {
1256 "probe_completed": len(probe_lats),
1257 "probe_failed": len(probe_outputs) - len(probe_lats),
1258 "probe_median_e2el_ms": float(np.median(probe_lats)) * 1000,
1259 "probe_p99_e2el_ms": float(np.percentile(probe_lats, 99)) * 1000,
1260 "probe_max_e2el_ms": float(max(probe_lats)) * 1000,
1261 }
1262 print("{s:{c}^{n}}".format(s="Probe Requests", n=50, c="-"))
1263 print(
1264 "{:<40} {:<10}".format(
1265 "Probe requests completed:", probe_stats["probe_completed"]
1266 )
1267 )
1268 print(
1269 "{:<40} {:<10}".format(
1270 "Probe requests failed:", probe_stats["probe_failed"]
1271 )
1272 )
1273 print(
1274 "{:<40} {:<10.2f}".format(
1275 "Median probe E2EL (ms):", probe_stats["probe_median_e2el_ms"]
1276 )
1277 )
1278 print(
1279 "{:<40} {:<10.2f}".format(
1280 "P99 probe E2EL (ms):", probe_stats["probe_p99_e2el_ms"]
1281 )
1282 )
1283 print(
1284 "{:<40} {:<10.2f}".format(
1285 "Max probe E2EL (ms):", probe_stats["probe_max_e2el_ms"]
1286 )
1287 )
1288
1289 result: dict[str, Any]
1290 if isinstance(metrics, BenchmarkMetrics):
1291 result = {
1292 "duration": benchmark_duration,
1293 "completed": metrics.completed,
1294 "failed": metrics.failed,
1295 "total_input_tokens": metrics.total_input,
1296 "total_output_tokens": metrics.total_output,
1297 "request_throughput": metrics.request_throughput,
1298 "request_goodput": metrics.request_goodput if goodput_config_dict else None,
1299 "output_throughput": metrics.output_throughput,
1300 "total_token_throughput": metrics.total_token_throughput,
1301 "input_lens": [output.prompt_len for output in outputs],
1302 "output_lens": actual_output_lens,
1303 "ttfts": [output.ttft for output in outputs],
1304 "itls": [output.itl for output in outputs],
1305 "latencies": [output.latency for output in outputs],
1306 "start_times": [output.start_time for output in outputs],
1307 "queue_times": [output.client_queue_time for output in outputs],
1308 "generated_texts": [output.generated_text for output in outputs],
1309 "errors": [output.error for output in outputs],
1310 "max_output_tokens_per_s": metrics.max_output_tokens_per_s,
1311 "max_concurrent_requests": metrics.max_concurrent_requests,
1312 "rtfx": metrics.rtfx,
1313 }
1314 else:
1315 result = {
1316 "duration": benchmark_duration,
1317 "completed": metrics.completed,
1318 "total_input_tokens": metrics.total_input,
1319 "total_input_sequences": metrics.total_input_sequences,
1320 "request_throughput": metrics.request_throughput,
1321 "input_sequence_throughput": metrics.input_sequence_throughput,
1322 "total_token_throughput": metrics.total_token_throughput,
1323 "input_lens": [output.prompt_len for output in outputs],
1324 "latencies": [output.latency for output in outputs],
1325 "queue_times": [output.client_queue_time for output in outputs],
1326 "errors": [output.error for output in outputs],
1327 }
1328
1329 queue_times: list[float] | None = None
1330 e2els_including_queue: list[float] | None = None
1331 if max_concurrency is not None:
1332 queue_times = [output.client_queue_time for output in outputs if output.success]
1333 if not math.isinf(request_rate):
1334 e2els_including_queue = [
1335 output.latency + output.client_queue_time
1336 for output in outputs
1337 if output.success
1338 ]
1339
1340 if probe_stats is not None:
1341 result.update(probe_stats)
1342
1343 if rps_change_events:
1344 result["rps_change_events"] = rps_change_events
1345
1346 if spec_decode_stats is not None:
1347 result["spec_decode_acceptance_rate"] = spec_decode_stats["acceptance_rate"]
1348 result["spec_decode_acceptance_length"] = spec_decode_stats["acceptance_length"]
1349 result["spec_decode_num_drafts"] = int(spec_decode_stats["num_drafts"])
1350 result["spec_decode_draft_tokens"] = int(spec_decode_stats["draft_tokens"])
1351 result["spec_decode_accepted_tokens"] = int(
1352 spec_decode_stats["accepted_tokens"]
1353 )
1354 result["spec_decode_per_position_acceptance_rates"] = spec_decode_stats.get(
1355 "per_position_acceptance_rates", []
1356 )
1357
1358 if diffusion_stats is not None:
1359 result["diffusion_committed_throughput"] = diffusion_stats[
1360 "committed_throughput"
1361 ]
1362 result["diffusion_steps_per_canvas"] = diffusion_stats["steps_per_canvas"]
1363 result["diffusion_committed_per_step"] = diffusion_stats["committed_per_step"]
1364 result["diffusion_committed_tokens"] = int(diffusion_stats["committed_tokens"])
1365 result["diffusion_denoising_steps"] = int(diffusion_stats["denoising_steps"])
1366 result["diffusion_canvas_positions"] = int(diffusion_stats["canvas_positions"])
1367
1368 def process_one_metric(
1369 # E.g., "ttft"
1370 metric_attribute_name: str,
1371 # E.g., "TTFT"
1372 metric_name: str,
1373 # E.g., "Time to First Token"
1374 metric_header: str,
1375 values: list[float] | None = None,
1376 ):
1377 # This function prints and adds statistics of the specified
1378 # metric.
1379 if metric_attribute_name not in selected_percentile_metrics:
1380 return
1381 if values is None:
1382 mean = getattr(metrics, f"mean_{metric_attribute_name}_ms")
1383 median = getattr(metrics, f"median_{metric_attribute_name}_ms")
1384 std = getattr(metrics, f"std_{metric_attribute_name}_ms")
1385 percentiles = getattr(metrics, f"percentiles_{metric_attribute_name}_ms")
1386 else:
1387 mean = np.mean(values or 0) * 1000
1388 median = np.median(values or 0) * 1000
1389 std = np.std(values or 0) * 1000
1390 percentiles = [
1391 (p, np.percentile(values or 0, p) * 1000) for p in selected_percentiles
1392 ]
1393 print("{s:{c}^{n}}".format(s=metric_header, n=50, c="-"))
1394 print("{:<40} {:<10.2f}".format(f"Mean {metric_name} (ms):", mean))
1395 print("{:<40} {:<10.2f}".format(f"Median {metric_name} (ms):", median))
1396 result[f"mean_{metric_attribute_name}_ms"] = mean
1397 result[f"median_{metric_attribute_name}_ms"] = median
1398 result[f"std_{metric_attribute_name}_ms"] = std
1399 for p, value in percentiles:
1400 p_word = str(int(p)) if int(p) == p else str(p)
1401 print("{:<40} {:<10.2f}".format(f"P{p_word} {metric_name} (ms):", value))
1402 result[f"p{p_word}_{metric_attribute_name}_ms"] = value
1403
1404 if task_type == TaskType.GENERATION and tokenizer:
1405 process_one_metric("ttft", "TTFT", "Time to First Token")
1406 process_one_metric("tpot", "TPOT", "Time per Output Token (excl. 1st token)")
1407 process_one_metric("itl", "ITL", "Inter-token Latency")
1408 process_one_metric("e2el", "E2EL", "End-to-end Latency")
1409 if queue_times is not None:
1410 process_one_metric(
1411 "client_queue_time",
1412 "Client Queue Time",
1413 "Client-side Queueing",
1414 queue_times,
1415 )
1416 if e2els_including_queue is not None:
1417 process_one_metric(
1418 "e2el_including_client_queue",
1419 "E2EL incl. Client Queue",
1420 "Queue-inclusive End-to-end Latency",
1421 e2els_including_queue,
1422 )
1423
1424 if diffusion_stats is not None:
1425 print("{s:{c}^{n}}".format(s="Diffusion Decoding", n=50, c="-"))
1426 for label, key, value_fmt in (
1427 ("Committed throughput (tok/s):", "committed_throughput", "{:<10.2f}"),
1428 ("Denoising steps per canvas:", "steps_per_canvas", "{:<10.2f}"),
1429 ("Committed per denoising step:", "committed_per_step", "{:<10.2f}"),
1430 ("Committed tokens:", "committed_tokens", "{:<10d}"),
1431 ("Denoising steps:", "denoising_steps", "{:<10d}"),
1432 ("Canvas positions evaluated:", "canvas_positions", "{:<10d}"),
1433 ):
1434 value = diffusion_stats[key]
1435 if value_fmt.endswith("d}"):
1436 value = int(value)
1437 print("{:<40} ".format(label) + value_fmt.format(value))
1438
1439 if spec_decode_stats is not None and diffusion_stats is None:
1440 print("{s:{c}^{n}}".format(s="Speculative Decoding", n=50, c="-"))
1441 print(
1442 "{:<40} {:<10.2f}".format(
1443 "Acceptance rate (%):", spec_decode_stats["acceptance_rate"]
1444 )
1445 )
1446 print(
1447 "{:<40} {:<10.2f}".format(
1448 "Acceptance length:", spec_decode_stats["acceptance_length"]
1449 )
1450 )
1451 print("{:<40} {:<10}".format("Drafts:", int(spec_decode_stats["num_drafts"])))
1452 print(
1453 "{:<40} {:<10}".format(
1454 "Draft tokens:", int(spec_decode_stats["draft_tokens"])
1455 )
1456 )
1457 print(
1458 "{:<40} {:<10}".format(
1459 "Accepted tokens:", int(spec_decode_stats["accepted_tokens"])
1460 )
1461 )
1462 per_pos = spec_decode_stats.get("per_position_acceptance_rates", [])
1463 if per_pos:
1464 print("Per-position acceptance (%):")
1465 for i, rate in enumerate(per_pos):
1466 print("{:<40} {:<10.2f}".format(f" Position {i}:", rate * 100))
1467
1468 print("=" * 50)
1469
1470 if profile:
1471 print("Stopping profiler...")
1472 profile_input = RequestFuncInput(
1473 model=model_id,
1474 prompt=test_prompt,
1475 api_url=base_url + "/stop_profile",
1476 prompt_len=test_prompt_len,
1477 output_len=test_output_len,
1478 logprobs=logprobs,
1479 )
1480 profile_output = await request_func(
1481 request_func_input=profile_input, session=session
1482 )
1483 if profile_output.success:
1484 print("Profiler stopped")
1485
1486 await session.close()
1487 return result
1488
1489
1490 def check_goodput_args(args):
1491 # Check and parse goodput arguments
1492 goodput_config_dict = {}
1493 VALID_NAMES = ["ttft", "tpot", "e2el"]
1494 if args.goodput:
1495 goodput_config_dict = parse_goodput(args.goodput)
1496 for slo_name, slo_val in goodput_config_dict.items():
1497 if slo_name not in VALID_NAMES:
1498 raise ValueError(
1499 f"Invalid metric name found, {slo_name}: {slo_val}. "
1500 "The service level objective name should be one of "
1501 f"{str(VALID_NAMES)}. "
1502 )
1503 if slo_val < 0:
1504 raise ValueError(
1505 f"Invalid value found, {slo_name}: {slo_val}. "
1506 "The service level objective value should be "
1507 "non-negative."
1508 )
1509 return goodput_config_dict
1510
1511
1512 def parse_goodput(slo_pairs):
1513 goodput_config_dict = {}
1514 try:
1515 for slo_pair in slo_pairs:
1516 slo_name, slo_val = slo_pair.split(":")
1517 goodput_config_dict[slo_name] = float(slo_val)
1518 except ValueError as err:
1519 raise argparse.ArgumentTypeError(
1520 "Invalid format found for service level objectives. "
1521 'Specify service level objectives for goodput as "KEY:VALUE" '
1522 "pairs, where the key is a metric name, and the value is a "
1523 "number in milliseconds."
1524 ) from err
1525 return goodput_config_dict
1526
1527
1528 def save_to_pytorch_benchmark_format(
1529 args: argparse.Namespace, results: dict[str, Any], file_name: str
1530 ) -> None:
1531 metrics = [
1532 "median_ttft_ms",
1533 "mean_ttft_ms",
1534 "std_ttft_ms",
1535 "p99_ttft_ms",
1536 "mean_tpot_ms",
1537 "median_tpot_ms",
1538 "std_tpot_ms",
1539 "p99_tpot_ms",
1540 "median_itl_ms",
1541 "mean_itl_ms",
1542 "std_itl_ms",
1543 "p99_itl_ms",
1544 ]
1545 # These raw data might be useful, but they are rather big. They can be added
1546 # later if needed
1547 ignored_metrics = ["ttfts", "itls", "generated_texts", "errors"]
1548 pt_records = convert_to_pytorch_benchmark_format(
1549 args=redact_sensitive_namespace(args, _SENSITIVE_ARG_FIELDS),
1550 metrics={k: [results[k]] for k in metrics if k in results},
1551 extra_info={
1552 k: results[k]
1553 for k in results
1554 if k not in metrics and k not in ignored_metrics
1555 },
1556 )
1557 if pt_records:
1558 # Don't use json suffix here as we don't want CI to pick it up
1559 pt_file = f"{os.path.splitext(file_name)[0]}.pytorch.json"
1560 write_to_json(pt_file, pt_records)
1561
1562
1563 def compute_result_filename(
1564 args: argparse.Namespace,
1565 model_id: str,
1566 label: str,
1567 current_dt: str,
1568 ) -> str | None:
1569 """Compute the result filename based on benchmark configuration.
1570
1571 Args:
1572 args: Command line arguments containing result configuration
1573 model_id: The model identifier
1574 label: The benchmark label
1575 current_dt: Current datetime string
1576
1577 Returns:
1578 The computed filename path or None if no result saving is requested
1579
1580 """
1581 if not (args.plot_timeline or args.save_result or args.append_result):
1582 return None
1583
1584 base_model_id = model_id.split("/")[-1]
1585 max_concurrency_str = (
1586 f"-concurrency{args.max_concurrency}"
1587 if args.max_concurrency is not None
1588 else ""
1589 )
1590 label = label or args.backend
1591
1592 if args.ramp_up_strategy is not None:
1593 file_name = f"{label}-ramp-up-{args.ramp_up_strategy}-{args.ramp_up_start_rps}qps-{args.ramp_up_end_rps}qps{max_concurrency_str}-{base_model_id}-{current_dt}.json" # noqa
1594 else:
1595 file_name = f"{label}-{args.request_rate}qps{max_concurrency_str}-{base_model_id}-{current_dt}.json" # noqa
1596
1597 if args.result_filename:
1598 file_name = args.result_filename
1599
1600 if args.result_dir:
1601 os.makedirs(args.result_dir, exist_ok=True)
1602 file_name = os.path.join(args.result_dir, file_name)
1603
1604 return file_name
1605
1606
1607 def add_cli_args(parser: FlexibleArgumentParser):
1608 add_dataset_parser(parser)
1609 parser.add_argument(
1610 "--label",
1611 type=str,
1612 default=None,
1613 help="The label (prefix) of the benchmark results. If not specified, "
1614 "the value of '--backend' will be used as the label.",
1615 )
1616 parser.add_argument(
1617 "--backend",
1618 type=str,
1619 default="openai",
1620 choices=list(ASYNC_REQUEST_FUNCS.keys()),
1621 help="The type of backend or endpoint to use for the benchmark.",
1622 )
1623 parser.add_argument(
1624 "--base-url",
1625 type=str,
1626 default=None,
1627 help="Server or API base url if not using http host and port.",
1628 )
1629 # Use 127.0.0.1 here instead of localhost to force the use of ipv4
1630 parser.add_argument("--host", type=str, default="127.0.0.1")
1631 parser.add_argument("--port", type=int, default=8000)
1632 parser.add_argument(
1633 "--endpoint",
1634 type=str,
1635 default="/v1/completions",
1636 help="API endpoint.",
1637 )
1638 parser.add_argument(
1639 "--header",
1640 metavar="KEY=VALUE",
1641 nargs="*",
1642 help="Key-value pairs (e.g, --header x-additional-info=0.3.3) "
1643 "for headers to be passed with each request. These headers override "
1644 "per backend constants and values set via environment variable, and "
1645 "will be overridden by other arguments (such as request ids).",
1646 )
1647 parser.add_argument(
1648 "--max-concurrency",
1649 type=int,
1650 default=None,
1651 help="Maximum number of concurrent requests. This can be used "
1652 "to help simulate an environment where a higher level component "
1653 "is enforcing a maximum number of concurrent requests. While the "
1654 "--request-rate argument controls the rate at which requests are "
1655 "initiated, this argument will control how many are actually allowed "
1656 "to execute at a time. This means that when used in combination, the "
1657 "actual request rate may be lower than specified with --request-rate, "
1658 "if the server is not processing requests fast enough to keep up.",
1659 )
1660
1661 parser.add_argument(
1662 "--model",
1663 type=str,
1664 required=False,
1665 default=None,
1666 help="Name of the model. If not specified, will fetch the first model "
1667 "from the server's /v1/models endpoint.",
1668 )
1669 parser.add_argument(
1670 "--input-len",
1671 type=int,
1672 default=None,
1673 help="General input length for datasets. Maps to dataset-specific "
1674 "input length arguments (e.g., --random-input-len, --sonnet-input-len). "
1675 "If not specified, uses dataset defaults.",
1676 )
1677 parser.add_argument(
1678 "--output-len",
1679 type=int,
1680 default=None,
1681 help="General output length for datasets. Maps to dataset-specific "
1682 "output length arguments (e.g., --random-output-len, --sonnet-output-len). "
1683 "If not specified, uses dataset defaults.",
1684 )
1685 parser.add_argument(
1686 "--tokenizer",
1687 type=str,
1688 help="Name or path of the tokenizer, if not using the default tokenizer.", # noqa: E501
1689 )
1690 parser.add_argument(
1691 "--tokenizer-mode",
1692 type=str,
1693 default="auto",
1694 help="""Tokenizer mode:\n
1695 - "auto" will use the tokenizer from `mistral_common` for Mistral models
1696 if available, otherwise it will use the "hf" tokenizer.\n
1697 - "hf" will use the fast tokenizer if available.\n
1698 - "mistral" will always use the tokenizer from `mistral_common`.\n
1699 - "deepseek_v32" will always use the tokenizer from `deepseek_v32`.\n
1700 - Other custom values can be supported via plugins.""",
1701 )
1702 parser.add_argument("--use-beam-search", action="store_true")
1703 parser.add_argument(
1704 "--logprobs",
1705 type=int,
1706 default=None,
1707 help=(
1708 "Number of logprobs-per-token to compute & return as part of "
1709 "the request. If unspecified, then either (1) if beam search "
1710 "is disabled, no logprobs are computed & a single dummy "
1711 "logprob is returned for each token; or (2) if beam search "
1712 "is enabled 1 logprob per token is computed"
1713 ),
1714 )
1715 parser.add_argument(
1716 "--request-rate",
1717 type=float,
1718 default=float("inf"),
1719 help="Number of requests per second. If this is inf, "
1720 "then all the requests are sent at time 0. "
1721 "Otherwise, we use Poisson process or gamma distribution "
1722 "to synthesize the request arrival times.",
1723 )
1724 parser.add_argument(
1725 "--burstiness",
1726 type=float,
1727 default=1.0,
1728 help="Burstiness factor of the request generation. "
1729 "Only take effect when request_rate is not inf. "
1730 "Default value is 1, which follows Poisson process. "
1731 "Otherwise, the request intervals follow a gamma distribution. "
1732 "A lower burstiness value (0 < burstiness < 1) results in more "
1733 "bursty requests. A higher burstiness value (burstiness > 1) "
1734 "results in a more uniform arrival of requests.",
1735 )
1736 parser.add_argument(
1737 "--probe-request-rate",
1738 type=float,
1739 default=0.0,
1740 help="If positive, send single-token text-only probe requests at "
1741 "this rate (req/s) alongside the main workload, bypassing "
1742 "--max-concurrency, and report their latency separately. Useful "
1743 "for measuring how the main workload stalls unrelated requests.",
1744 )
1745 parser.add_argument(
1746 "--disable-tqdm",
1747 action="store_true",
1748 help="Specify to disable tqdm progress bar.",
1749 )
1750 parser.add_argument(
1751 "--num-warmups",
1752 type=int,
1753 default=0,
1754 help="Number of warmup requests.",
1755 )
1756 parser.add_argument(
1757 "--profile",
1758 action="store_true",
1759 help="Use vLLM Profiling. --profiler-config must be provided on the server.",
1760 )
1761 parser.add_argument(
1762 "--save-result",
1763 action="store_true",
1764 help="Specify to save benchmark results to a json file",
1765 )
1766 parser.add_argument(
1767 "--save-detailed",
1768 action="store_true",
1769 help="When saving the results, whether to include per request "
1770 "information such as response, error, ttfts, tpots, etc.",
1771 )
1772 parser.add_argument(
1773 "--append-result",
1774 action="store_true",
1775 help="Append the benchmark result to the existing json file.",
1776 )
1777 parser.add_argument(
1778 "--metadata",
1779 metavar="KEY=VALUE",
1780 nargs="*",
1781 help="Key-value pairs (e.g, --metadata version=0.3.3 tp=1) "
1782 "for metadata of this run to be saved in the result JSON file "
1783 "for record keeping purposes.",
1784 )
1785 parser.add_argument(
1786 "--result-dir",
1787 type=str,
1788 default=None,
1789 help="Specify directory to save benchmark json results."
1790 "If not specified, results are saved in the current directory.",
1791 )
1792 parser.add_argument(
1793 "--result-filename",
1794 type=str,
1795 default=None,
1796 help="Specify the filename to save benchmark json results."
1797 "If not specified, results will be saved in "
1798 "{label}-{args.request_rate}qps-{base_model_id}-{current_dt}.json" # noqa
1799 " format.",
1800 )
1801 parser.add_argument(
1802 "--ignore-eos",
1803 action="store_true",
1804 help="Set ignore_eos flag when sending the benchmark request."
1805 "Warning: ignore_eos is not supported in deepspeed_mii and tgi.",
1806 )
1807 parser.add_argument(
1808 "--self-timed",
1809 action=argparse.BooleanOptionalAction,
1810 default=None,
1811 help="Use timing information from the traces instead of the configuration. "
1812 "This is useful when replaying traces faithfully based on their timestamps. "
1813 "When unset, defaults to False, except for --dataset-name=timed_trace where "
1814 "it defaults to True. Use --no-self-timed to force off. When off, user "
1815 "defined generation rates are used and in trace timing info is ignored.",
1816 )
1817 parser.add_argument(
1818 "--percentile-metrics",
1819 type=str,
1820 default=None,
1821 help="Comma-separated list of selected metrics to report percentiles. "
1822 "This argument specifies the metrics to report percentiles. "
1823 'Allowed metric names are "ttft", "tpot", "itl", "e2el", '
1824 '"client_queue_time", "e2el_including_client_queue". '
1825 'If not specified, defaults to "ttft,tpot,itl" for generative models '
1826 'and "e2el" for pooling models.',
1827 )
1828 parser.add_argument(
1829 "--metric-percentiles",
1830 type=str,
1831 default="99",
1832 help="Comma-separated list of percentiles for selected metrics. "
1833 'To report 25-th, 50-th, and 75-th percentiles, use "25,50,75". '
1834 'Default value is "99".'
1835 'Use "--percentile-metrics" to select metrics.',
1836 )
1837 parser.add_argument(
1838 "--goodput",
1839 nargs="+",
1840 required=False,
1841 help='Specify service level objectives for goodput as "KEY:VALUE" '
1842 "pairs, where the key is a metric name, and the value is in "
1843 'milliseconds. Multiple "KEY:VALUE" pairs can be provided, '
1844 "separated by spaces. Allowed request level metric names are "
1845 '"ttft", "tpot", "e2el". For more context on the definition of '
1846 "goodput, refer to DistServe paper: https://arxiv.org/pdf/2401.09670 "
1847 "and the blog: https://hao-ai-lab.github.io/blogs/distserve",
1848 )
1849 parser.add_argument(
1850 "--request-id-prefix",
1851 type=str,
1852 required=False,
1853 default=f"bench-{uuid.uuid4().hex[:8]}-",
1854 help="Specify the prefix of request id.",
1855 )
1856
1857 sampling_group = parser.add_argument_group("sampling parameters")
1858 sampling_group.add_argument(
1859 "--top-p",
1860 type=float,
1861 default=None,
1862 help="Top-p sampling parameter. Only has effect on openai-compatible backends.",
1863 )
1864 sampling_group.add_argument(
1865 "--top-k",
1866 type=int,
1867 default=None,
1868 help="Top-k sampling parameter. Only has effect on openai-compatible backends.",
1869 )
1870 sampling_group.add_argument(
1871 "--min-p",
1872 type=float,
1873 default=None,
1874 help="Min-p sampling parameter. Only has effect on openai-compatible backends.",
1875 )
1876 sampling_group.add_argument(
1877 "--temperature",
1878 type=float,
1879 default=None,
1880 help="Temperature sampling parameter. Only has effect on "
1881 "openai-compatible backends.",
1882 )
1883 sampling_group.add_argument(
1884 "--frequency-penalty",
1885 type=float,
1886 default=None,
1887 help="Frequency penalty sampling parameter. Only has effect on "
1888 "openai-compatible backends.",
1889 )
1890 sampling_group.add_argument(
1891 "--presence-penalty",
1892 type=float,
1893 default=None,
1894 help="Presence penalty sampling parameter. Only has effect on "
1895 "openai-compatible backends.",
1896 )
1897 sampling_group.add_argument(
1898 "--repetition-penalty",
1899 type=float,
1900 default=None,
1901 help="Repetition penalty sampling parameter. Only has effect on "
1902 "openai-compatible backends.",
1903 )
1904
1905 parser.add_argument(
1906 "--served-model-name",
1907 type=str,
1908 default=None,
1909 help="The model name used in the API. "
1910 "If not specified, the model name will be the "
1911 "same as the `--model` argument. ",
1912 )
1913
1914 parser.add_argument(
1915 "--lora-modules",
1916 nargs="+",
1917 default=None,
1918 help="A subset of LoRA module names passed in when "
1919 "launching the server. For each request, the "
1920 "script chooses a LoRA module at random by default. "
1921 "Use --lora-assignment to control selection strategy.",
1922 )
1923
1924 parser.add_argument(
1925 "--lora-assignment",
1926 type=str,
1927 default="random",
1928 choices=["random", "round-robin"],
1929 help="Strategy for assigning LoRA modules to requests. "
1930 "'random' (default) selects a LoRA at random for each request. "
1931 "'round-robin' cycles through LoRA modules deterministically.",
1932 )
1933
1934 parser.add_argument(
1935 "--ramp-up-strategy",
1936 type=str,
1937 default=None,
1938 choices=["linear", "exponential"],
1939 help="The ramp-up strategy. This would be used to "
1940 "ramp up the request rate from initial RPS to final "
1941 "RPS rate (specified by --ramp-up-start-rps and "
1942 "--ramp-up-end-rps.) over the duration of the benchmark.",
1943 )
1944 parser.add_argument(
1945 "--ramp-up-start-rps",
1946 type=int,
1947 default=None,
1948 help="The starting request rate for ramp-up (RPS). "
1949 "Needs to be specified when --ramp-up-strategy is used.",
1950 )
1951 parser.add_argument(
1952 "--ramp-up-end-rps",
1953 type=int,
1954 default=None,
1955 help="The ending request rate for ramp-up (RPS). "
1956 "Needs to be specified when --ramp-up-strategy is used.",
1957 )
1958 parser.add_argument(
1959 "--ready-check-timeout-sec",
1960 type=int,
1961 default=0,
1962 help="Maximum time to wait for the endpoint to become ready "
1963 "in seconds. Ready check will be skipped by default.",
1964 )
1965
1966 parser.add_argument(
1967 "--chat-template-kwargs",
1968 type=json.loads,
1969 default=None,
1970 help="A JSON string of kwargs forwarded to the tokenizer's "
1971 "apply_chat_template when a dataset renders prompts client-side "
1972 "(e.g. custom / speed_bench). "
1973 "Example: '{\"thinking\": true}' to enable reasoning models.",
1974 )
1975 parser.add_argument(
1976 "--extra-body",
1977 help="A JSON string representing extra body parameters to include "
1978 "in each request."
1979 'Example: \'{"chat_template_kwargs":{"enable_thinking":false}}\'',
1980 type=json.loads,
1981 default=None,
1982 )
1983 parser.add_argument(
1984 "--skip-tokenizer-init",
1985 action="store_true",
1986 default=False,
1987 help="Skip initialization of tokenizer and detokenizer",
1988 )
1989
1990 parser.add_argument(
1991 "--insecure",
1992 action="store_true",
1993 default=False,
1994 help="Disable SSL certificate verification. Use this option when "
1995 "connecting to servers with self-signed certificates.",
1996 )
1997
1998 parser.add_argument(
1999 "--plot-timeline",
2000 action="store_true",
2001 help="Generate an HTML timeline plot showing request execution. "
2002 "The plot will be saved alongside the results JSON file.",
2003 )
2004 parser.add_argument(
2005 "--timeline-itl-thresholds",
2006 type=str,
2007 default="25,50",
2008 help="ITL thresholds in milliseconds for timeline plot coloring. "
2009 "Specify two comma-separated values to categorize inter-token "
2010 "latencies into three groups: below first threshold (green), "
2011 "between thresholds (orange), and above second threshold (red).",
2012 )
2013 parser.add_argument(
2014 "--plot-dataset-stats",
2015 action="store_true",
2016 help="Generate a matplotlib figure with dataset statistics showing "
2017 "prompt tokens, output tokens, and combined token distributions.",
2018 )
2019
2020
2021 def main(args: argparse.Namespace) -> dict[str, Any]:
2022 return asyncio.run(main_async(args))
2023
2024
2025 async def main_async(args: argparse.Namespace) -> dict[str, Any]:
2026 print(redact_sensitive_namespace(args, _SENSITIVE_ARG_FIELDS))
2027 if args.max_concurrency is not None and args.max_concurrency <= 0:
2028 raise ValueError("--max-concurrency must be greater than 0")
2029
2030 random.seed(args.seed)
2031 np.random.seed(args.seed)
2032
2033 # Validate timeline ITL thresholds
2034 if args.plot_timeline:
2035 try:
2036 itl_thresholds = [
2037 float(t.strip()) for t in args.timeline_itl_thresholds.split(",")
2038 ]
2039 if len(itl_thresholds) != 2:
2040 raise ValueError(
2041 f"Expected 2 ITL threshold values, got {len(itl_thresholds)}"
2042 )
2043 except ValueError as e:
2044 raise ValueError(f"Invalid --timeline-itl-thresholds format: {e}") from e
2045
2046 # Validate ramp-up arguments
2047 if args.ramp_up_strategy is not None:
2048 if args.request_rate != float("inf"):
2049 raise ValueError(
2050 "When using ramp-up, do not specify --request-rate. "
2051 "The request rate will be controlled by ramp-up parameters. "
2052 "Please remove the --request-rate argument."
2053 )
2054 if args.ramp_up_start_rps is None or args.ramp_up_end_rps is None:
2055 raise ValueError(
2056 "When using --ramp-up-strategy, both --ramp-up-start-rps and "
2057 "--ramp-up-end-rps must be specified"
2058 )
2059 if args.ramp_up_start_rps < 0 or args.ramp_up_end_rps < 0:
2060 raise ValueError("Ramp-up start and end RPS must be non-negative")
2061 if args.ramp_up_start_rps > args.ramp_up_end_rps:
2062 raise ValueError("Ramp-up start RPS must be less than end RPS")
2063 if args.ramp_up_strategy == "exponential" and args.ramp_up_start_rps == 0:
2064 raise ValueError("For exponential ramp-up, the start RPS cannot be 0.")
2065
2066 label = args.label
2067
2068 if args.base_url is not None:
2069 api_url = f"{args.base_url}{args.endpoint}"
2070 base_url = f"{args.base_url}"
2071 else:
2072 host_port = join_host_port(args.host, args.port)
2073 api_url = f"http://{host_port}{args.endpoint}"
2074 base_url = f"http://{host_port}"
2075
2076 # Headers
2077 headers = None
2078 if args.header:
2079 headers = {}
2080 for item in args.header:
2081 if "=" in item:
2082 kvstring = item.split("=", 1)
2083 headers[kvstring[0].strip()] = kvstring[1].strip()
2084 else:
2085 raise ValueError("Invalid header format. Please use KEY=VALUE format.")
2086
2087 # SSL context configuration
2088 ssl_context: ssl.SSLContext | bool | None = None
2089 if args.insecure:
2090 # Disable SSL certificate verification
2091 ssl_context = False
2092 elif "https://" in base_url:
2093 # Use default SSL context for HTTPS
2094 ssl_context = True
2095
2096 # Fetch model from server if not specified
2097 if args.model is None:
2098 print("Model not specified, fetching first model from server...")
2099 model_name, model_id = await get_first_model_from_server(
2100 base_url, headers, ssl_context
2101 )
2102 print(f"First model name: {model_name}, first model id: {model_id}")
2103 else:
2104 model_name = args.served_model_name
2105 model_id = args.model
2106
2107 if args.skip_tokenizer_init:
2108 tokenizer_id = None
2109 tokenizer_mode = None
2110 tokenizer = None
2111 else:
2112 tokenizer_id = args.tokenizer if args.tokenizer is not None else model_id
2113 tokenizer_mode = args.tokenizer_mode
2114 tokenizer = get_tokenizer(
2115 tokenizer_id,
2116 tokenizer_mode=tokenizer_mode,
2117 trust_remote_code=args.trust_remote_code,
2118 )
2119
2120 # Validate dataset name/path
2121 if args.dataset_name is None:
2122 raise ValueError(
2123 "Please specify '--dataset-name' and the corresponding "
2124 "'--dataset-path' if required."
2125 )
2126
2127 if (
2128 args.dataset_name
2129 in ["random", "random-mm", "random-rerank", "prefix_repetition"]
2130 and args.dataset_path is not None
2131 ):
2132 raise ValueError(
2133 f"Cannot use '{args.dataset_name}' dataset with --dataset-path. "
2134 "Please specify the appropriate --dataset-name (e.g., "
2135 "'sharegpt', 'custom', 'sonnet') for your dataset file: "
2136 f"{args.dataset_path}"
2137 )
2138
2139 # Map general --input-len and --output-len to all dataset-specific arguments
2140 if args.input_len is not None:
2141 args.random_input_len = args.input_len
2142 args.sonnet_input_len = args.input_len
2143
2144 if args.output_len is not None:
2145 args.random_output_len = args.output_len
2146 args.sonnet_output_len = args.output_len
2147 args.sharegpt_output_len = args.output_len
2148 args.custom_output_len = args.output_len
2149 args.hf_output_len = args.output_len
2150 args.spec_bench_output_len = args.output_len
2151 args.prefix_repetition_output_len = args.output_len
2152
2153 # when using random datasets, default to ignoring EOS
2154 # so generation runs to the requested length
2155 if (
2156 args.dataset_name in ("random", "random-mm")
2157 and args.backend in OPENAI_COMPATIBLE_BACKENDS
2158 ):
2159 args.ignore_eos = True
2160
2161 if args.dataset_name == "timed_trace":
2162 if args.backend not in ("vllm", "openai"):
2163 raise ValueError(
2164 "timed_trace dataset passes pre-tokenized prompts (list[int])"
2165 " and requires a completions backend ('vllm' or 'openai')."
2166 )
2167 # timed_trace carries per-request timestamps;
2168 # ignore EOS so generation runs to the trace's specified output length,
2169 # and default to using those timestamps for scheduling unless the user
2170 # opted out.
2171 args.ignore_eos = True
2172 if args.self_timed is None:
2173 args.self_timed = True
2174 else:
2175 # if this is set for anything else, it is an error
2176 if args.self_timed is not None:
2177 raise ValueError(
2178 "--self-timed/--no-self-timed is only supported with "
2179 "--dataset-name=timed_trace"
2180 )
2181 # for any non self-timed trace, this is False
2182 args.self_timed = False
2183
2184 # Load the dataset.
2185 input_requests = get_samples(args, tokenizer)
2186
2187 if args.dataset_name in ("random", "prefix_repetition"):
2188 input_requests = await _align_prompts_to_server_tokenizer(
2189 base_url, model_id, input_requests, ssl_context
2190 )
2191
2192 goodput_config_dict = check_goodput_args(args)
2193
2194 backend = args.backend
2195 task_type = TaskType.POOLING if backend in POOLING_BACKENDS else TaskType.GENERATION
2196
2197 # Collect the sampling parameters.
2198 if task_type == TaskType.GENERATION:
2199 sampling_params = {
2200 k: v
2201 for k, v in {
2202 "top_p": args.top_p,
2203 "top_k": args.top_k,
2204 "min_p": args.min_p,
2205 "temperature": args.temperature,
2206 "frequency_penalty": args.frequency_penalty,
2207 "presence_penalty": args.presence_penalty,
2208 "repetition_penalty": args.repetition_penalty,
2209 }.items()
2210 if v is not None
2211 }
2212
2213 # Sampling parameters are only supported by openai-compatible backend.
2214 if sampling_params and args.backend not in OPENAI_COMPATIBLE_BACKENDS:
2215 raise ValueError(
2216 "Sampling parameters are only supported by openai-compatible backends."
2217 )
2218
2219 # The Responses API accepts every sampling parameter above except
2220 # min_p, which it would silently drop as an unknown field.
2221 if args.backend == "openai-responses" and "min_p" in sampling_params:
2222 raise ValueError("--min-p is not supported by the Responses API.")
2223
2224 if "temperature" not in sampling_params:
2225 print(
2226 "WARNING: vllm bench serve no longer sets temperature==0 (greedy) "
2227 "in requests by default. The default will be determined on the "
2228 "server side and can be model/API specific. "
2229 "For the old behavior, include --temperature=0."
2230 )
2231
2232 default_percentile_metrics = "ttft,tpot,itl"
2233 else:
2234 sampling_params = {}
2235 default_percentile_metrics = "e2el"
2236
2237 extra_body = args.extra_body or {}
2238 extra_body = {**sampling_params, **extra_body}
2239
2240 percentile_metrics: str = args.percentile_metrics or default_percentile_metrics
2241
2242 # Avoid GC processing "static" data - reduce pause times.
2243 freeze_gc_heap()
2244
2245 benchmark_result = await benchmark(
2246 task_type=task_type,
2247 endpoint_type=backend,
2248 api_url=api_url,
2249 base_url=base_url,
2250 model_id=model_id,
2251 model_name=model_name,
2252 tokenizer=tokenizer,
2253 input_requests=input_requests,
2254 logprobs=args.logprobs,
2255 request_rate=args.request_rate,
2256 burstiness=args.burstiness,
2257 disable_tqdm=args.disable_tqdm,
2258 num_warmups=args.num_warmups,
2259 profile=args.profile,
2260 selected_percentile_metrics=percentile_metrics.split(","),
2261 selected_percentiles=[float(p) for p in args.metric_percentiles.split(",")],
2262 ignore_eos=args.ignore_eos,
2263 goodput_config_dict=goodput_config_dict,
2264 max_concurrency=args.max_concurrency,
2265 lora_modules=args.lora_modules,
2266 lora_assignment=args.lora_assignment,
2267 extra_headers=headers,
2268 extra_body=extra_body,
2269 ramp_up_strategy=args.ramp_up_strategy,
2270 ramp_up_start_rps=args.ramp_up_start_rps,
2271 ramp_up_end_rps=args.ramp_up_end_rps,
2272 ready_check_timeout_sec=args.ready_check_timeout_sec,
2273 ssl_context=ssl_context,
2274 self_timed=args.self_timed,
2275 probe_request_rate=args.probe_request_rate,
2276 )
2277
2278 # Save config and results to json
2279 result_json: dict[str, Any] = {}
2280
2281 # Setup
2282 current_dt = datetime.now().strftime("%Y%m%d-%H%M%S")
2283 result_json["date"] = current_dt
2284 result_json["endpoint_type"] = args.backend # for backward compatibility
2285 result_json["backend"] = args.backend
2286 result_json["label"] = label
2287 result_json["model_id"] = model_id
2288 result_json["tokenizer_id"] = tokenizer_id
2289 result_json["num_prompts"] = args.num_prompts
2290
2291 # Metadata
2292 if args.metadata:
2293 for item in args.metadata:
2294 if "=" in item:
2295 kvstring = item.split("=", 1)
2296 result_json[kvstring[0].strip()] = kvstring[1].strip()
2297 else:
2298 raise ValueError(
2299 "Invalid metadata format. Please use KEY=VALUE format."
2300 )
2301
2302 # Traffic
2303 result_json["request_rate"] = (
2304 args.request_rate if args.request_rate < float("inf") else "inf"
2305 )
2306 result_json["burstiness"] = args.burstiness
2307 result_json["max_concurrency"] = args.max_concurrency
2308
2309 if args.ramp_up_strategy is not None:
2310 result_json["ramp_up_strategy"] = args.ramp_up_strategy
2311 result_json["ramp_up_start_rps"] = args.ramp_up_start_rps
2312 result_json["ramp_up_end_rps"] = args.ramp_up_end_rps
2313
2314 # Merge with benchmark result
2315 result_json = {**result_json, **benchmark_result}
2316
2317 # Compute file_name once before using it for plots or saving results
2318 file_name = compute_result_filename(args, model_id, label, current_dt)
2319
2320 # Generate timeline plot if requested
2321 if args.plot_timeline:
2322 assert file_name is not None, (
2323 "file_name must be set when plot_timeline is enabled"
2324 )
2325 try:
2326 from vllm.benchmarks.plot import generate_timeline_plot
2327
2328 # Prepare per-request data for timeline
2329 per_request_data = []
2330 start_times = benchmark_result.get("start_times", [])
2331 ttfts = benchmark_result.get("ttfts", [])
2332 itls = benchmark_result.get("itls", [])
2333 input_lens = benchmark_result.get("input_lens", [])
2334 output_lens = benchmark_result.get("output_lens", [])
2335
2336 if start_times and ttfts and itls:
2337 for i in range(len(start_times)):
2338 # Calculate latency as ttft + sum of all itls
2339 latency = ttfts[i] + sum(itls[i]) if itls[i] else ttfts[i]
2340
2341 per_request_data.append(
2342 {
2343 "start_time": start_times[i],
2344 "ttft": ttfts[i],
2345 "itl": itls[i],
2346 "latency": latency,
2347 "prompt_len": input_lens[i],
2348 "output_tokens": output_lens[i],
2349 }
2350 )
2351
2352 timeline_path = Path(file_name).with_suffix(".timeline.html")
2353 # Convert thresholds from milliseconds to seconds
2354 itl_thresholds_sec = [
2355 float(t) / 1000.0 for t in args.timeline_itl_thresholds.split(",")
2356 ]
2357 generate_timeline_plot(
2358 per_request_data, timeline_path, itl_thresholds=itl_thresholds_sec
2359 )
2360 else:
2361 warnings.warn(
2362 "Timeline plot requires detailed metrics. "
2363 "Ensure the benchmark completed successfully.",
2364 stacklevel=2,
2365 )
2366 except Exception as e:
2367 warnings.warn(f"Failed to generate timeline plot: {e}", stacklevel=2)
2368
2369 # Generate dataset statistics plot if requested
2370 if args.plot_dataset_stats:
2371 assert file_name is not None, (
2372 "file_name must be set when plot_dataset_stats is enabled"
2373 )
2374 try:
2375 from vllm.benchmarks.plot import generate_dataset_stats_plot
2376
2377 # Prepare per-request data for dataset stats
2378 per_request_data = []
2379 input_lens = benchmark_result.get("input_lens", [])
2380 output_lens = benchmark_result.get("output_lens", [])
2381
2382 if input_lens and output_lens:
2383 for req_input_len, req_output_len in zip(input_lens, output_lens):
2384 per_request_data.append(
2385 {
2386 "prompt_len": req_input_len,
2387 "output_tokens": req_output_len,
2388 }
2389 )
2390
2391 stats_path = Path(file_name).with_suffix(".dataset_stats.png")
2392 generate_dataset_stats_plot(per_request_data, stats_path)
2393 else:
2394 warnings.warn(
2395 "Dataset statistics plot requires input and "
2396 "output length data. Ensure the benchmark completed "
2397 "successfully.",
2398 stacklevel=2,
2399 )
2400 except Exception as e:
2401 warnings.warn(
2402 f"Failed to generate dataset statistics plot: {e}", stacklevel=2
2403 )
2404
2405 if not args.save_detailed:
2406 # Remove fields with too many data points
2407 for field in [
2408 "input_lens",
2409 "output_lens",
2410 "start_times",
2411 "ttfts",
2412 "itls",
2413 "generated_texts",
2414 "errors",
2415 ]:
2416 if field in result_json:
2417 del result_json[field]
2418 if field in benchmark_result:
2419 del benchmark_result[field]
2420
2421 # Save to file
2422 if args.save_result or args.append_result:
2423 assert file_name is not None, (
2424 "file_name must be set when save_result or append_result is enabled"
2425 )
2426 with open(
2427 file_name, mode="a+" if args.append_result else "w", encoding="utf-8"
2428 ) as outfile:
2429 # Append a newline.
2430 if args.append_result and outfile.tell() != 0:
2431 outfile.write("\n")
2432 json.dump(result_json, outfile)
2433 save_to_pytorch_benchmark_format(args, result_json, file_name)
2434
2435 return result_json