# SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project # Adapted from # https://github.com/vllm-project/vllm/blob/main/benchmarks/benchmark_long_document_qa_throughput.py """ Commandline arguments: --num-documents: The number of documents to sample prompts from. --document-length: The length of each document in tokens. (Optional, default: 20000) --output-len: The number of tokens to generate for each prompt. (Optional, default: 100) --repeat-count: The number of times to repeat each prompt. (Optional, default: 2) --repeat-mode: The mode to repeat prompts. The supported modes are: - 'random': shuffle the prompts randomly. (Default) - 'tile': the entire prompt list is repeated in sequence. - 'interleave': each prompt is repeated consecutively before moving to the next element. --shuffle-seed: Random seed when the repeat mode is "random". (Optional, default: 0) --host: Host to query the vLLM server --port: Port to query the vLLM server --base-url: Base URL to query the LLM server (exclusive with --host/--port) --model: Model name --max-inflight-requests: Maximum number of in-flight requests. Default is 2 --sleep-time-after-warmup: Sleep time after warm up iteration. (Optional, default: 0.0 seconds) --output: Filename to write all responses to. If omitted, writes to stdout. --expected-ttft-gain: Expected minimum speed-up in time-to-first-token (warmup/query) as a factor, e.g. 4.3 for 4.3×. If actual gain is below this, exits. --expected-latency-gain: Expected minimum speed-up in total round time (warmup/query) as a factor, e.g. 4.5 for 4.5×. If actual gain is below this, exits. --expected-latency: Expected end to end latency for the first query round. --completions: Use completions API instead of chat completions API --visualize: Visualize the results --eos-token-id: EOS token id. we bias against this token id so we always get the number of output tokens we specify --hit-miss-ratio: In query round, control how many of the prompts will miss the cache. For example, 3:1 means every fourth repeated prompt will miss the cache. 2:2 means 2 hits and 2 misses. --trim-fraction: Exclude the smallest X fraction and largest X fraction and calculate mean of the rest. For example, 0.1 drops bottom 10% and top 10%. """ # Standard from dataclasses import dataclass import argparse import asyncio import os import random import sys import time # Third Party from openai import AsyncOpenAI import pandas as pd # Global output filename (set in __main__) OUTPUT_FILE = None completions_mode = False visualize = False eos_token_id = None @dataclass class RequestStats: prompt_id: int request_start: float ttft: float request_end: float successful: bool def get_url_from_args(args): """ Get the base URL from command line arguments. Args: args: Command line arguments. Returns: str: The base URL. """ if args.base_url is not None: return args.base_url else: host = args.host if args.host is not None else "localhost" port = args.port if args.port is not None else 8000 return f"http://{host}:{port}/v1" def extract_reasoning_content(chunk): """ Extract reasoning content from the response chunk. Args: chunk: The response chunk from OpenAI Chat Completions API. Returns: str | None: The reasoning content extracted from the chunk. None means no reasoning content in this chunk. """ delta = chunk.choices[0].delta potential_reasoning_keys = [ "reasoning_content", "reasoning", "tool_calls", "tool_call", "tool_responses", ] for key in potential_reasoning_keys: if hasattr(delta, key) and getattr(delta, key): return getattr(delta, key) return None def extract_normal_content(chunk): """ Extract normal content from the response chunk. Args: chunk: The response chunk from OpenAI Chat Completions API. Returns: str | None: The normal content extracted from the chunk. None means no normal content in this chunk. """ delta = chunk.choices[0].delta if hasattr(delta, "content") and delta.content: return chunk.choices[0].delta.content return None def has_content_completions(chunk): """ Completions streaming emits text at choices[0].text. """ return bool(chunk.choices) and (chunk.choices[0].text is not None) def has_content(chunk, completions_mode=False): """ Check if the chunk has content in the choices. Args: chunk: The response chunk from OpenAI Chat Completions API. Returns: bool: True if content exists, False otherwise. """ if completions_mode: return has_content_completions(chunk) return ( chunk.choices and chunk.choices[0].delta and ( extract_normal_content(chunk) is not None or extract_reasoning_content(chunk) is not None ) ) def extract_content_completions(chunk): """ Extract content from a Completions stream chunk. """ return chunk.choices[0].text or "" def extract_content(chunk, completions_mode=False): """ Extract content from the response chunk. Args: chunk: The response chunk from OpenAI Chat Completions API. Returns: str: The content extracted from the chunk. """ if completions_mode: return extract_content_completions(chunk) if normal_content := extract_normal_content(chunk): return normal_content elif reasoning_content := extract_reasoning_content(chunk): return reasoning_content else: return "" def write_resp(text: str): """ Write text to the specified output file (if any), otherwise to stdout. """ if OUTPUT_FILE: with open(OUTPUT_FILE, "a") as resp_file: resp_file.write(text) else: sys.stdout.write(text) async def process_single_prompt( client, model, prompt, prompt_index, total_prompts, output_len, semaphore ) -> RequestStats: """ Process a single prompt with the given client and model. Args: client: The OpenAI client for making API calls. model: The model name to use for generation. prompt: The prompt string to be processed. prompt_index: Index of the current prompt (0-based). total_prompts: Total number of prompts being processed. output_len: The maximum number of tokens to generate. semaphore: Asyncio semaphore to limit concurrent requests. Returns: RequestStats: RequestStats object containing the request stats """ async with semaphore: # Acquire semaphore to limit concurrent requests write_resp(f"\n--- Sending prompt {prompt_index + 1}/{total_prompts} ---\n") # a request starts once it acquires the semaphore start_time = time.time() first_token_time = None # add stop None so we always get the number of output tokens we specify if completions_mode: response = await client.completions.create( model=model, prompt=prompt, stream=True, max_tokens=output_len, temperature=0.0, stream_options={"include_usage": True}, logit_bias={str(eos_token_id): -100} if eos_token_id is not None else None, ) else: response = await client.chat.completions.create( model=model, messages=[{"role": "user", "content": prompt}], stream=True, max_tokens=output_len, temperature=0.0, stream_options={"include_usage": True}, logit_bias={str(eos_token_id): -100} if eos_token_id is not None else None, ) responses = [] # Collect the response chunks async for chunk in response: if not chunk.choices: continue # Handle content for chat completions if has_content(chunk, completions_mode): content = extract_content(chunk, completions_mode) if first_token_time is None and content != "": first_token_time = time.time() responses.append(content) end_time = time.time() final_response = "".join(responses) write_resp(f"\nResponse of request {prompt_index}: {final_response}\n") # TTFT < 0 means not successful ttft = (first_token_time - start_time) if first_token_time is not None else -1 return RequestStats( prompt_id=prompt_index, request_start=start_time, ttft=ttft, request_end=end_time, successful=ttft > 0, ) async def test_long_document_qa( client, model, prompts=None, output_len=100, max_inflight_requests=10 ) -> list[RequestStats]: """ Test long document QA with the given prompts and sampling parameters. Process prompts concurrently with a limit on inflight requests. Args: client: The OpenAI client for making API calls. model: The model name to use for generation. prompts: A list of prompt strings to be processed by the LLM. output_len: The maximum number of tokens to generate. max_inflight_requests: Maximum number of concurrent requests. Returns: list: request_stats - a list of RequestStats objects """ # Create semaphore to limit concurrent requests semaphore = asyncio.Semaphore(max_inflight_requests) # Create tasks for all prompts tasks = [ process_single_prompt( client=client, model=model, prompt=prompt, prompt_index=i, total_prompts=len(prompts), output_len=output_len, semaphore=semaphore, ) for i, prompt in enumerate(prompts) ] # Execute all tasks concurrently and collect results # The semaphore will control max concurrent requests request_stats = await asyncio.gather(*tasks) return request_stats def repeat_prompts(prompts, repeat_count, mode: str): """ Repeat each prompt in the list for a specified number of times. The order of prompts in the output list depends on the mode. Args: prompts: A list of prompts to be repeated. repeat_count: The number of times each prompt is repeated. mode: The mode of repetition. Supported modes are: - 'random': Shuffle the prompts randomly after repetition. - 'tile': Repeat the entire prompt list in sequence. Example: [1, 2, 3] -> [1, 2, 3, 1, 2, 3]. - 'interleave': Repeat each prompt consecutively before moving to the next. Example: [1, 2, 3] -> [1, 1, 2, 2, 3, 3]. Returns: A list of repeated prompts in the specified order. Raises: ValueError: If an invalid mode is provided. """ write_resp(f"Repeat mode: {mode}\n") if mode == "random": repeated_prompts = prompts * repeat_count random.shuffle(repeated_prompts) return repeated_prompts elif mode == "tile": return prompts * repeat_count elif mode == "interleave": repeated_prompts = [] for prompt in prompts: repeated_prompts.extend([prompt] * repeat_count) return repeated_prompts else: raise ValueError( f"Invalid mode: {mode}, only support 'random', 'tile', 'interleave'" ) def add_cache_misses(prompts, hit_miss_ratio): """ Add cache misses to the prompts and return a boolean mask aligned with prompts. """ if hit_miss_ratio is None: return prompts, [False] * len(prompts) hit, miss = map(int, hit_miss_ratio.split(":", 1)) period = hit + miss miss_mask = [False] * len(prompts) for i in range(len(prompts)): # every (hit+miss) window: first `hit` are hits, rest are misses if period and (i % period) >= hit: miss_mask[i] = True prompts[i] = f"{random.randint(-10_000_000, 10_000_000)} {prompts[i]}" return prompts, miss_mask def relative_time(df, start_time): """ Relative time to the start of the benchmark. """ df["request_start"] = df["request_start"] - start_time df["request_end"] = df["request_end"] - start_time df["ttft_time"] = df["request_start"] + df["ttft"] def visualize_results(warmup_df, benchmark_df): def plot_bars(df, title, filename): plt.figure(figsize=(12, 6)) if "is_miss" in df.columns: is_miss = df["is_miss"] else: is_miss = pd.Series(False, index=df.index) hits = df[~is_miss] misses = df[is_miss] # Prefill: dark blue (hit), dark orange (miss) if not hits.empty: plt.barh( hits["prompt_id"], hits["ttft_time"] - hits["request_start"], left=hits["request_start"], color="darkblue", label="Loading", # prefill hits ) if not misses.empty: plt.barh( misses["prompt_id"], misses["ttft_time"] - misses["request_start"], left=misses["request_start"], color="darkorange", label="Compute", # prefill misses ) # Decode: light blue (hit), light orange (miss) if not hits.empty: plt.barh( hits["prompt_id"], hits["request_end"] - hits["ttft_time"], left=hits["ttft_time"], color="skyblue", label="Decoding after loading", ) if not misses.empty: plt.barh( misses["prompt_id"], misses["request_end"] - misses["ttft_time"], left=misses["ttft_time"], color="pink", label="Decoding after compute", ) plt.xlabel("Time (s)") plt.ylabel("Prompt ID") plt.legend() plt.tight_layout() plt.savefig(filename) plt.close() plot_bars(warmup_df, "Warmup Round", "warmup_round.png") plot_bars(benchmark_df, "Query Round", "query_round.png") def trimmed_mean(series: pd.Series, trim_fraction: float) -> float: """ Exclude the smallest trim_fraction and largest trim_fraction and take mean of the rest. If trim_fraction <= 0, returns normal mean. """ s = series.dropna() if len(s) == 0: return float("nan") if trim_fraction <= 0: return float(s.mean()) if not (0.0 <= trim_fraction < 0.5): raise ValueError("--trim-fraction must be in [0, 0.5).") s = s.sort_values() n = len(s) k = int(n * trim_fraction) if n - 2 * k <= 0: return float(s.mean()) return float(s.iloc[k : n - k].mean()) async def main(args): random.seed(args.shuffle_seed) # Create the OpenAI client # No timeout: some benchmarks can take 4-5 minutes per request base_url = get_url_from_args(args) print("Using base URL:", base_url) api_key = os.getenv("OPENAI_API_KEY", "sk-dummy") client = AsyncOpenAI( base_url=base_url, api_key=api_key, timeout=None, ) model = args.model if model == "auto": print("Auto-selecting model...", end=" ") models = (await client.models.list(),) model = models[0].data[0].id print(f"selected model: {model}") pre_warmup_prompts = [str(i) + "xx" + " ".join(["hi"] * 1000) for i in range(5)] await test_long_document_qa( client=client, model=model, prompts=pre_warmup_prompts, output_len=args.output_len, max_inflight_requests=args.max_inflight_requests, ) # Prepare the prompts: # we append the document id at the beginning to avoid any of the document # being the prefix of other documents warmup_prompts = [ str(i) + " " + " ".join(["hi"] * args.document_length) for i in range(args.num_documents) ] prompts = repeat_prompts(warmup_prompts, args.repeat_count, mode=args.repeat_mode) prompts, miss_mask = add_cache_misses(prompts, args.hit_miss_ratio) write_resp("------warm up round------\n") warmup_start_time = time.time() warmup_request_stats = await test_long_document_qa( client=client, model=model, prompts=warmup_prompts, output_len=args.output_len, max_inflight_requests=args.max_inflight_requests, ) warmup_end_time = time.time() write_resp("------query round------\n") sleep_time_after_warmup = args.sleep_time_after_warmup if sleep_time_after_warmup > 0: write_resp(f"Sleeping for {sleep_time_after_warmup} seconds after warmup...\n") time.sleep(sleep_time_after_warmup) benchmark_start_time = time.time() benchmark_request_stats = await test_long_document_qa( client=client, model=model, prompts=prompts, output_len=args.output_len, max_inflight_requests=args.max_inflight_requests, ) benchmark_end_time = time.time() warmup_df = pd.DataFrame([stats.__dict__ for stats in warmup_request_stats]) relative_time(warmup_df, warmup_start_time) warmup_df["is_miss"] = True benchmark_df = pd.DataFrame([stats.__dict__ for stats in benchmark_request_stats]) benchmark_df["is_miss"] = miss_mask relative_time(benchmark_df, benchmark_start_time) warmup_df.to_csv("warmup_round.csv", index=False) benchmark_df.to_csv("query_round.csv", index=False) # Print results warmup_mean_ttft = trimmed_mean( warmup_df.query("successful == True")["ttft"], args.trim_fraction ) query_mean_ttft = trimmed_mean( benchmark_df.query("successful == True")["ttft"], args.trim_fraction ) warmup_success_count = warmup_df.query("successful == True").shape[0] query_success_count = benchmark_df.query("successful == True").shape[0] CSI = "\x1b[" RESET = CSI + "0m" print(f"Warmup round mean TTFT: {warmup_mean_ttft:.3f}s") print(f"Warmup round time: {warmup_end_time - warmup_start_time:.3f}s") print(f"Warmup round prompt count: {len(warmup_df)}") print(f"Warmup round successful prompt count: {warmup_success_count}") print(f"{CSI}36;1m\n=== BENCHMARK RESULTS ==={RESET}") print(f"{CSI}32mQuery round mean TTFT: {query_mean_ttft:.3f}s{RESET}") print( f"{CSI}33mQuery round time: " f"{benchmark_end_time - benchmark_start_time:.3f}s{RESET}" ) print(f"{CSI}35mQuery round prompt count: {len(benchmark_df)}{RESET}") print(f"{CSI}34mQuery round successful prompt count: {query_success_count}{RESET}") if visualize: visualize_results(warmup_df, benchmark_df) if args.json_output: query_duration = benchmark_end_time - benchmark_start_time query_round_time_per_prompt = query_duration / len(benchmark_df) warmup_duration = warmup_end_time - warmup_start_time warmup_round_time_per_prompt = warmup_duration / len(warmup_df) # Standard import json summary = { "query_ttft_per_prompt": query_mean_ttft, "query_round_time_per_prompt": query_round_time_per_prompt, "warmup_round_time_per_prompt": warmup_round_time_per_prompt, } print(json.dumps(summary)) def create_argument_parser(): parser = argparse.ArgumentParser( description="Benchmark the performance with or " "without automatic prefix caching." ) parser.add_argument( "--document-length", type=int, # Roughly the number of tokens for a system paper, # excluding images default=20000, help="Length of each document in tokens.", ) parser.add_argument( "--num-documents", type=int, default=8, help="Number of documents to generate for testing.", ) parser.add_argument( "--output-len", type=int, default=100, help="Maximum number of tokens to generate for each prompt.", ) parser.add_argument( "--repeat-count", type=int, default=2, help="Number of times to repeat each prompt", ) parser.add_argument( "--repeat-mode", type=str, default="random", help="The mode to repeat prompts. The supported " 'modes are "random", "tile", and "interleave". ' "See repeat_prompts() in the source code for details.", ) parser.add_argument( "--shuffle-seed", type=int, default=0, help='Random seed when the repeat mode is "random"', ) parser.add_argument( "--host", type=str, default=None, help="Host to query the vLLM server", ) parser.add_argument( "--port", type=int, default=None, help="Port to query the vLLM server", ) parser.add_argument( "--base-url", type=str, default=None, help="Base URL to query the LLM server", ) parser.add_argument( "--model", type=str, default="auto", help="Model name, can be set to 'auto' if the " "endpoint support openai api /models", ) parser.add_argument( "--max-inflight-requests", type=int, default=2, help="Maximum number of concurrent inflight requests", ) parser.add_argument( "--sleep-time-after-warmup", type=float, default=0.0, help="Sleep time after warm up iteration", ) parser.add_argument( "--output", type=str, default=None, help="Filename to write all responses to; if omitted, writes to stdout.", ) parser.add_argument( "--completions", action="store_true", help="Use completions API instead of chat completions API", ) parser.add_argument( "--visualize", action="store_true", help="Visualize the results", ) parser.add_argument( "--hit-miss-ratio", type=str, default=None, help=( "In query round, control how many of the prompts will miss the cache." "For example, 3:1 means every fourth repeated prompt will be randomized " "to force a cache miss. 2:2 means 2 hits and 2 misses" ), ) parser.add_argument( "--eos-token-id", type=int, default=None, help=( "EOS token id. we bias against this token id so we always " "get the number of output tokens we specify" ), ) parser.add_argument( "--json-output", action="store_true", help="Print benchmark summary as a single JSON line to stdout.", ) parser.add_argument( "--trim-fraction", type=float, default=0.0, help=( "Exclude the smallest and largest fraction of successful samples " "before averaging. Example: 0.1 drops bottom 10% and top 10%." ), ) return parser def validate_args(args): # Verify port and base_url are exclusive has_host_port = args.host is not None and args.port is not None has_base_url = args.base_url is not None if has_host_port and has_base_url: raise ValueError("Cannot use --host/--port and --base-url together.") if __name__ == "__main__": parser = create_argument_parser() args = parser.parse_args() validate_args(args) completions_mode = args.completions visualize = args.visualize if visualize: # Third Party import matplotlib.pyplot as plt if args.eos_token_id is not None: eos_token_id = args.eos_token_id OUTPUT_FILE = args.output asyncio.run(main(args))