@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / a904ea78ea
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 14, 05:30 UTC

[benchmark] add peak throughput metrics and plot (#23867)

Signed-off-by: simon-mo <simon.mo@hey.com>

Simon Mo committed Sep 17, 2025 at 22:30 UTC a904ea78eaf7fc3f9b136a1ba6f6f66fb5658496
2 files changed +134 -69
vllm/benchmarks/lib/endpoint_request_func.py
+5
@@ -89,6 +89,7 @@ class RequestFuncOutput:
89 tpot: float = 0.0 # avg next-token latencies
90 prompt_len: int = 0
91 error: str = ""
92 + start_time: float = 0.0
93
94
95 async def async_request_openai_completions(
@@ -140,6 +141,7 @@ async def async_request_openai_completions(
141
142 generated_text = ""
143 st = time.perf_counter()
144 + output.start_time = st
145 most_recent_timestamp = st
146 try:
147 async with session.post(url=api_url, json=payload,
@@ -272,6 +274,7 @@ async def async_request_openai_chat_completions(
274 generated_text = ""
275 ttft = 0.0
276 st = time.perf_counter()
277 + output.start_time = st
278 most_recent_timestamp = st
279 try:
280 async with session.post(url=api_url, json=payload,
@@ -396,6 +399,7 @@ async def async_request_openai_audio(
399 generated_text = ""
400 ttft = 0.0
401 st = time.perf_counter()
402 + output.start_time = st
403 most_recent_timestamp = st
404 try:
405 async with session.post(url=api_url,
@@ -475,6 +479,7 @@ async def async_request_openai_embeddings(
479
480 output = RequestFuncOutput()
481 st = time.perf_counter()
482 + output.start_time = st
483 try:
484 async with session.post(
485 url=api_url,
vllm/benchmarks/serve.py
+129 -69
@@ -18,9 +18,11 @@ On the client side, run:
18 import argparse
19 import asyncio
20 import gc
21 +import importlib.util
22 import json
23 import os
24 import random
25 +import shutil
26 import time
27 import warnings
28 from collections.abc import AsyncGenerator, Iterable
@@ -46,6 +48,9 @@ from vllm.transformers_utils.tokenizer import get_tokenizer
48
49 MILLISECONDS_TO_SECONDS_CONVERSION = 1000
50
51 +TERM_PLOTLIB_AVAILABLE = ((importlib.util.find_spec("termplotlib") is not None)
52 + and (shutil.which("gnuplot") is not None))
53 +
54
55 class TaskType(Enum):
56 GENERATION = "generation"
@@ -80,18 +85,23 @@ class BenchmarkMetrics:
85 median_e2el_ms: float
86 std_e2el_ms: float
87 percentiles_e2el_ms: list[tuple[float, float]]
88 + # Max output tokens per second and concurrent requests at that peak
89 + max_output_tokens_per_s: float
90 + max_concurrent_requests: int
91 +
92
93 @dataclass
94 class EmbedBenchmarkMetrics:
95 completed: int
96 total_input: int
97 request_throughput: float
89 - total_token_throughput :float
98 + total_token_throughput: float
99 mean_e2el_ms: float
100 std_e2el_ms: float
101 median_e2el_ms: float
102 percentiles_e2el_ms: float
103
104 +
105 def _get_current_request_rate(
106 ramp_up_strategy: Optional[Literal["linear", "exponential"]],
107 ramp_up_start_rps: Optional[int],
@@ -150,8 +160,8 @@ async def get_request(
160 assert burstiness > 0, (
161 f"A positive burstiness factor is expected, but given {burstiness}.")
162 # Convert to list to get length for ramp-up calculations
153 - if isinstance(input_requests, Iterable) and not isinstance(
154 - input_requests, list):
163 + if isinstance(input_requests,
164 + Iterable) and not isinstance(input_requests, list):
165 input_requests = list(input_requests)
166
167 total_requests = len(input_requests)
@@ -161,12 +171,9 @@ async def get_request(
171 request_rates = []
172 delay_ts = []
173 for request_index, request in enumerate(input_requests):
164 - current_request_rate = _get_current_request_rate(ramp_up_strategy,
165 - ramp_up_start_rps,
166 - ramp_up_end_rps,
167 - request_index,
168 - total_requests,
169 - request_rate)
174 + current_request_rate = _get_current_request_rate(
175 + ramp_up_strategy, ramp_up_start_rps, ramp_up_end_rps,
176 + request_index, total_requests, request_rate)
177 request_rates.append(current_request_rate)
178 if current_request_rate == float("inf"):
179 delay_ts.append(0)
@@ -206,10 +213,8 @@ async def get_request(
213
214
215 def calculate_metrics_for_embeddings(
209 - outputs: list[RequestFuncOutput],
210 - dur_s: float,
211 - selected_percentiles: list[float]
212 -) -> EmbedBenchmarkMetrics:
216 + outputs: list[RequestFuncOutput], dur_s: float,
217 + selected_percentiles: list[float]) -> EmbedBenchmarkMetrics:
218 """Calculate the metrics for the embedding requests.
219
220 Args:
@@ -242,10 +247,8 @@ def calculate_metrics_for_embeddings(
247 mean_e2el_ms=np.mean(e2els or 0) * 1000,
248 std_e2el_ms=np.std(e2els or 0) * 1000,
249 median_e2el_ms=np.median(e2els or 0) * 1000,
245 - percentiles_e2el_ms=[
246 - (p, np.percentile(e2els or 0, p) * 1000)
247 - for p in selected_percentiles
248 - ],
250 + percentiles_e2el_ms=[(p, np.percentile(e2els or 0, p) * 1000)
251 + for p in selected_percentiles],
252 )
253 return metrics
254
@@ -336,6 +339,67 @@ def calculate_metrics(
339 "All requests failed. This is likely due to a misconfiguration "
340 "on the benchmark arguments.",
341 stacklevel=2)
342 +
343 + # Calculate max output tokens per second metric
344 + max_output_tokens_per_s = 0.0
345 + max_concurrent_requests = 0
346 +
347 + # Find the time range across all successful requests
348 + successful_outputs = [output for output in outputs if output.success]
349 + if successful_outputs:
350 + min_start_time = min(output.start_time
351 + for output in successful_outputs)
352 + max_end_time = max(output.start_time + output.latency
353 + for output in successful_outputs)
354 +
355 + # Create second buckets (ceiling to ensure we capture all time)
356 + duration_seconds = int(np.ceil(max_end_time - min_start_time)) + 1
357 + tokens_per_second = np.zeros(duration_seconds)
358 + concurrent_requests_per_second = np.zeros(duration_seconds)
359 +
360 + for i, output in enumerate(successful_outputs):
361 + # Calculate token generation timestamp using
362 + # start_time, ttft, and itl
363 + token_times = [output.start_time + output.ttft]
364 + current_time = token_times[0]
365 + for itl_value in output.itl:
366 + current_time += itl_value
367 + token_times.append(current_time)
368 +
369 + # Add tokens to second buckets
370 + for token_time in token_times:
371 + second_bucket = int(token_time - min_start_time)
372 + if 0 <= second_bucket < duration_seconds:
373 + tokens_per_second[second_bucket] += 1
374 +
375 + # Track concurrent requests for each second this request was active
376 + request_start_second = int(output.start_time - min_start_time)
377 + request_end_second = int((output.start_time + output.latency) -
378 + min_start_time)
379 +
380 + for second in range(request_start_second, request_end_second + 1):
381 + concurrent_requests_per_second[second] += 1
382 +
383 + # Find the maximum tokens per second and corresponding
384 + # concurrent requests
385 + if len(tokens_per_second) > 0:
386 + max_output_tokens_per_s = float(np.max(tokens_per_second))
387 + max_concurrent_requests = int(
388 + np.max(concurrent_requests_per_second))
389 +
390 + if TERM_PLOTLIB_AVAILABLE:
391 + import termplotlib as tpl
392 + fig = tpl.figure()
393 + fig.plot(np.arange(len(tokens_per_second)),
394 + tokens_per_second,
395 + title="Output tokens per second")
396 + fig.plot(np.arange(len(concurrent_requests_per_second)),
397 + concurrent_requests_per_second,
398 + title="Concurrent requests per second")
399 + fig.show()
400 + else:
401 + print("tip: install termplotlib and gnuplot to plot the metrics")
402 +
403 metrics = BenchmarkMetrics(
404 completed=completed,
405 total_input=total_input,
@@ -365,6 +429,8 @@ def calculate_metrics(
429 median_e2el_ms=np.median(e2els or 0) * 1000,
430 percentiles_e2el_ms=[(p, np.percentile(e2els or 0, p) * 1000)
431 for p in selected_percentiles],
432 + max_output_tokens_per_s=max_output_tokens_per_s,
433 + max_concurrent_requests=max_concurrent_requests,
434 )
435
436 return metrics, actual_output_lens
@@ -396,11 +462,8 @@ async def benchmark(
462 ramp_up_end_rps: Optional[int] = None,
463 ready_check_timeout_sec: int = 600,
464 ):
399 - task_type = (
400 - TaskType.EMBEDDING
401 - if api_url.endswith("/v1/embeddings")
402 - else TaskType.GENERATION
403 - )
465 + task_type = (TaskType.EMBEDDING if api_url.endswith("/v1/embeddings") else
466 + TaskType.GENERATION)
467 if endpoint_type in ASYNC_REQUEST_FUNCS:
468 if task_type == TaskType.EMBEDDING:
469 request_func = ASYNC_REQUEST_FUNCS["openai-embeddings"]
@@ -435,14 +498,10 @@ async def benchmark(
498 input_requests[0].multi_modal_data,
499 )
500
438 - assert (
439 - test_mm_content is None
440 - or isinstance(test_mm_content, dict)
441 - or (
442 - isinstance(test_mm_content, list)
443 - and all(isinstance(item, dict) for item in test_mm_content)
444 - )
445 - ), "multi_modal_data must be a dict or list[dict]"
501 + assert (test_mm_content is None or isinstance(test_mm_content, dict)
502 + or (isinstance(test_mm_content, list)
503 + and all(isinstance(item, dict) for item in test_mm_content))
504 + ), "multi_modal_data must be a dict or list[dict]"
505 test_input = RequestFuncInput(
506 model=model_id,
507 model_name=model_name,
@@ -488,13 +547,13 @@ async def benchmark(
547 ignore_eos=ignore_eos,
548 extra_headers=extra_headers,
549 extra_body=extra_body)
491 - profile_output = await request_func(
492 - request_func_input=profile_input, session=session)
550 + profile_output = await request_func(request_func_input=profile_input,
551 + session=session)
552 if profile_output.success:
553 print("Profiler started")
554
496 - distribution = ("Poisson process" if burstiness == 1.0
497 - else "Gamma distribution")
555 + distribution = ("Poisson process"
556 + if burstiness == 1.0 else "Gamma distribution")
557
558 if ramp_up_strategy is not None:
559 print(f"Traffic ramp-up strategy: {ramp_up_strategy}.")
@@ -562,18 +621,20 @@ async def benchmark(
621 req_lora_module = next(lora_modules)
622 req_model_id, req_model_name = req_lora_module, req_lora_module
623
565 - request_func_input = RequestFuncInput(model=req_model_id,
566 - model_name=req_model_name,
567 - prompt=prompt,
568 - api_url=api_url,
569 - prompt_len=prompt_len,
570 - output_len=output_len,
571 - logprobs=logprobs,
572 - multi_modal_content=mm_content,
573 - ignore_eos=ignore_eos,
574 - extra_headers=extra_headers,
575 - extra_body=extra_body,
576 - request_id=request_id,)
624 + request_func_input = RequestFuncInput(
625 + model=req_model_id,
626 + model_name=req_model_name,
627 + prompt=prompt,
628 + api_url=api_url,
629 + prompt_len=prompt_len,
630 + output_len=output_len,
631 + logprobs=logprobs,
632 + multi_modal_content=mm_content,
633 + ignore_eos=ignore_eos,
634 + extra_headers=extra_headers,
635 + extra_body=extra_body,
636 + request_id=request_id,
637 + )
638 tasks.append(
639 asyncio.create_task(
640 limited_request_func(request_func_input=request_func_input,
@@ -615,19 +676,21 @@ async def benchmark(
676 benchmark_duration))
677 print("{:<40} {:<10}".format("Total input tokens:", metrics.total_input))
678 if isinstance(metrics, BenchmarkMetrics):
618 - print("{:<40} {:<10}".format(
619 - "Total generated tokens:", metrics.total_output))
679 + print("{:<40} {:<10}".format("Total generated tokens:",
680 + metrics.total_output))
681 print("{:<40} {:<10.2f}".format("Request throughput (req/s):",
682 metrics.request_throughput))
683 if goodput_config_dict:
684 print("{:<40} {:<10.2f}".format("Request goodput (req/s):",
685 metrics.request_goodput))
686 if isinstance(metrics, BenchmarkMetrics):
626 - print(
627 - "{:<40} {:<10.2f}".format(
628 - "Output token throughput (tok/s):", metrics.output_throughput
629 - )
630 - )
687 + print("{:<40} {:<10.2f}".format("Output token throughput (tok/s):",
688 + metrics.output_throughput))
689 + print("{:<40} {:<10.2f}".format(
690 + "Peak output token throughput (tok/s):",
691 + metrics.max_output_tokens_per_s))
692 + print("{:<40} {:<10.2f}".format("Peak concurrent requests:",
693 + metrics.max_concurrent_requests))
694 print("{:<40} {:<10.2f}".format("Total Token throughput (tok/s):",
695 metrics.total_token_throughput))
696
@@ -648,6 +711,8 @@ async def benchmark(
711 "itls": [output.itl for output in outputs],
712 "generated_texts": [output.generated_text for output in outputs],
713 "errors": [output.error for output in outputs],
714 + "max_output_tokens_per_s": metrics.max_output_tokens_per_s,
715 + "max_concurrent_requests": metrics.max_concurrent_requests,
716 }
717 else:
718 result = {
@@ -697,8 +762,8 @@ async def benchmark(
762
763 if task_type == TaskType.GENERATION:
764 process_one_metric("ttft", "TTFT", "Time to First Token")
700 - process_one_metric(
701 - "tpot", "TPOT", "Time per Output Token (excl. 1st token)")
765 + process_one_metric("tpot", "TPOT",
766 + "Time per Output Token (excl. 1st token)")
767 process_one_metric("itl", "ITL", "Inter-token Latency")
768 process_one_metric("e2el", "E2EL", "End-to-end Latency")
769
@@ -714,8 +779,8 @@ async def benchmark(
779 output_len=test_output_len,
780 logprobs=logprobs,
781 )
717 - profile_output = await request_func(
718 - request_func_input=profile_input, session=session)
782 + profile_output = await request_func(request_func_input=profile_input,
783 + session=session)
784 if profile_output.success:
785 print("Profiler stopped")
786
@@ -851,7 +916,8 @@ def add_cli_args(parser: argparse.ArgumentParser):
916 parser.add_argument(
917 "--tokenizer",
918 type=str,
854 - help="Name or path of the tokenizer, if not using the default tokenizer.", # noqa: E501
919 + help=
920 + "Name or path of the tokenizer, if not using the default tokenizer.", # noqa: E501
921 )
922 parser.add_argument("--use-beam-search", action="store_true")
923 parser.add_argument(
@@ -982,7 +1048,6 @@ def add_cli_args(parser: argparse.ArgumentParser):
1048 help="Specify the prefix of request id.",
1049 )
1050
985 -
1051 sampling_group = parser.add_argument_group("sampling parameters")
1052 sampling_group.add_argument(
1053 "--top-p",
@@ -1047,8 +1112,7 @@ def add_cli_args(parser: argparse.ArgumentParser):
1112 help="The ramp-up strategy. This would be used to "
1113 "ramp up the request rate from initial RPS to final "
1114 "RPS rate (specified by --ramp-up-start-rps and "
1050 - "--ramp-up-end-rps.) over the duration of the benchmark."
1051 - )
1115 + "--ramp-up-end-rps.) over the duration of the benchmark.")
1116 parser.add_argument(
1117 "--ramp-up-start-rps",
1118 type=int,
@@ -1087,13 +1151,11 @@ async def main_async(args: argparse.Namespace) -> dict[str, Any]:
1151 raise ValueError(
1152 "When using ramp-up, do not specify --request-rate. "
1153 "The request rate will be controlled by ramp-up parameters. "
1090 - "Please remove the --request-rate argument."
1091 - )
1154 + "Please remove the --request-rate argument.")
1155 if args.ramp_up_start_rps is None or args.ramp_up_end_rps is None:
1156 raise ValueError(
1157 "When using --ramp-up-strategy, both --ramp-up-start-rps and "
1095 - "--ramp-up-end-rps must be specified"
1096 - )
1158 + "--ramp-up-end-rps must be specified")
1159 if args.ramp_up_start_rps < 0 or args.ramp_up_end_rps < 0:
1160 raise ValueError("Ramp-up start and end RPS must be non-negative")
1161 if args.ramp_up_start_rps > args.ramp_up_end_rps:
@@ -1127,8 +1189,7 @@ async def main_async(args: argparse.Namespace) -> dict[str, Any]:
1189 headers[kvstring[0].strip()] = kvstring[1].strip()
1190 else:
1191 raise ValueError(
1130 - "Invalid header format. Please use KEY=VALUE format."
1131 - )
1192 + "Invalid header format. Please use KEY=VALUE format.")
1193
1194 tokenizer = get_tokenizer(tokenizer_id,
1195 tokenizer_mode=tokenizer_mode,
@@ -1215,8 +1276,7 @@ async def main_async(args: argparse.Namespace) -> dict[str, Any]:
1276 result_json[kvstring[0].strip()] = kvstring[1].strip()
1277 else:
1278 raise ValueError(
1218 - "Invalid metadata format. Please use KEY=VALUE format."
1219 - )
1279 + "Invalid metadata format. Please use KEY=VALUE format.")
1280
1281 # Traffic
1282 result_json["request_rate"] = (args.request_rate if args.request_rate