Files
2026-07-13 12:24:33 +08:00

779 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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))