496 lines
15 KiB
Python
496 lines
15 KiB
Python
# 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-total-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)
|
||
|
||
--num-requests: The number of requests to send.
|
||
|
||
--num-docs-per-request: The number of documents to use in each prompt.
|
||
|
||
--sampling-strategy: The sampling strategy to use. Currently only supports
|
||
"random".
|
||
|
||
--random-seed: Random seed when the repeat mode is "random".
|
||
(Optional, default: 0)
|
||
|
||
--blend-special-str: The special string to use for blending documents.
|
||
(Optional, default: " # # ")
|
||
|
||
--port: Port to query the vLLM server
|
||
|
||
--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.
|
||
"""
|
||
|
||
# Standard
|
||
import argparse
|
||
import asyncio
|
||
import random
|
||
import sys
|
||
import time
|
||
|
||
# Third Party
|
||
from openai import AsyncOpenAI
|
||
from transformers import AutoTokenizer
|
||
|
||
# Global output filename (set in __main__)
|
||
OUTPUT_FILE = None
|
||
|
||
|
||
def has_content(chunk):
|
||
"""
|
||
Check if the chunk has content in the choices.
|
||
Args:
|
||
chunk: The response chunk from OpenAI API.
|
||
|
||
Returns:
|
||
bool: True if content exists, False otherwise.
|
||
"""
|
||
return chunk.choices and chunk.choices[0].text
|
||
|
||
|
||
def extract_content(chunk):
|
||
"""
|
||
Extract content from the response chunk.
|
||
Args:
|
||
chunk: The response chunk from OpenAI API.
|
||
Returns:
|
||
str: The content extracted from the chunk.
|
||
"""
|
||
if chunk.choices[0].text is not None:
|
||
return chunk.choices[0].text
|
||
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
|
||
):
|
||
"""
|
||
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:
|
||
float: Time-to-first-token measurement
|
||
"""
|
||
async with semaphore: # Acquire semaphore to limit concurrent requests
|
||
write_resp(f"\n--- Sending prompt {prompt_index + 1}/{total_prompts} ---\n")
|
||
start_time = time.time()
|
||
first_token_time = None
|
||
words = ""
|
||
|
||
response = await client.completions.create(
|
||
model=model,
|
||
prompt=prompt,
|
||
max_tokens=output_len,
|
||
temperature=0.0,
|
||
stream=True,
|
||
extra_body={"ignore_eos": True},
|
||
)
|
||
|
||
responses = []
|
||
# Collect the response chunks
|
||
async for chunk in response:
|
||
if not chunk.choices:
|
||
continue
|
||
|
||
# Handle content for chat completions
|
||
if has_content(chunk):
|
||
content = extract_content(chunk)
|
||
if first_token_time is None and content != "":
|
||
first_token_time = time.time()
|
||
responses.append(content)
|
||
words += content
|
||
|
||
final_response = "".join(responses)
|
||
write_resp(f"\nResponse of request {prompt_index}: {final_response}\n")
|
||
|
||
if first_token_time is not None:
|
||
return first_token_time - start_time
|
||
else:
|
||
# If no content was generated, return a default value
|
||
return 0.0
|
||
|
||
|
||
async def test_long_document_qa(
|
||
client, model, prompts=None, output_len=100, max_inflight_requests=10
|
||
):
|
||
"""
|
||
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: ttfts - a list of time-to-first-token measurements
|
||
"""
|
||
# Create semaphore to limit concurrent requests
|
||
semaphore = asyncio.Semaphore(max_inflight_requests)
|
||
|
||
# Create tasks for all prompts
|
||
tasks = []
|
||
for i, prompt in enumerate(prompts):
|
||
task = process_single_prompt(
|
||
client=client,
|
||
model=model,
|
||
prompt=prompt,
|
||
prompt_index=i,
|
||
total_prompts=len(prompts),
|
||
output_len=output_len,
|
||
semaphore=semaphore,
|
||
)
|
||
tasks.append(task)
|
||
|
||
# Execute all tasks concurrently and collect results
|
||
ttfts = await asyncio.gather(*tasks)
|
||
|
||
return ttfts
|
||
|
||
|
||
def generate_warmup_prompt_ids(
|
||
doc_prompts, sys_prompts, query_prompts, blend_special_str, tokenizer, offset=1
|
||
):
|
||
blend_special_ids = tokenizer.encode(blend_special_str)[offset:]
|
||
warmup_prompt_ids = []
|
||
for doc_prompt, sys_prompt, query_prompt in zip(
|
||
doc_prompts, sys_prompts, query_prompts, strict=False
|
||
):
|
||
sys_prompt_ids = tokenizer.encode(sys_prompt)
|
||
doc_prompt_ids = tokenizer.encode(doc_prompt)[offset:]
|
||
query_prompt_ids = tokenizer.encode(query_prompt)[offset:]
|
||
warmup_prompt_ids.append(
|
||
sys_prompt_ids
|
||
+ blend_special_ids
|
||
+ doc_prompt_ids
|
||
+ blend_special_ids
|
||
+ query_prompt_ids
|
||
)
|
||
return warmup_prompt_ids
|
||
|
||
|
||
def generate_prompt_ids(
|
||
doc_prompts: list[str],
|
||
sys_prompts: list[str],
|
||
query_prompts: list[str],
|
||
num_requests: int,
|
||
num_docs_per_request: int,
|
||
blend_special_str: str,
|
||
tokenizer,
|
||
offset: int = 1,
|
||
):
|
||
blend_special_ids = tokenizer.encode(blend_special_str)[offset:]
|
||
|
||
prompt_ids = []
|
||
|
||
for i in range(num_requests):
|
||
temp_prompt_ids = []
|
||
sample_docs = random.sample(doc_prompts, num_docs_per_request)
|
||
sample_docs_ids = [tokenizer.encode(doc)[offset:] for doc in sample_docs]
|
||
sys_prompt_ids = tokenizer.encode(sys_prompts[i])
|
||
query_prompt_ids = tokenizer.encode(query_prompts[i])[offset:]
|
||
temp_prompt_ids += sys_prompt_ids
|
||
for doc_ids in sample_docs_ids:
|
||
temp_prompt_ids += blend_special_ids + doc_ids
|
||
temp_prompt_ids += blend_special_ids + query_prompt_ids
|
||
|
||
prompt_ids.append(temp_prompt_ids)
|
||
|
||
return prompt_ids
|
||
|
||
|
||
async def main(args):
|
||
random.seed(args.random_seed)
|
||
|
||
# Create the OpenAI client
|
||
client = AsyncOpenAI(
|
||
base_url=f"http://localhost:{args.port}/v1", api_key="sk-dummy"
|
||
)
|
||
model = args.model
|
||
blend_special_str = args.blend_special_str
|
||
num_requests = args.num_requests
|
||
num_docs_per_request = args.num_docs_per_request
|
||
document_length = args.document_length
|
||
num_total_documents = args.num_total_documents
|
||
|
||
tokenizer = AutoTokenizer.from_pretrained(args.model)
|
||
|
||
doc_prompts = [
|
||
str(i) + " " + " ".join(["hi"] * document_length)
|
||
for i in range(num_total_documents)
|
||
]
|
||
warmup_sys_prompts = ["You are a helpful assistant."] * num_total_documents
|
||
warmup_query_prompts = ["What's up? how are you recently?"] * num_total_documents
|
||
|
||
warmup_prompt_ids = generate_warmup_prompt_ids(
|
||
doc_prompts,
|
||
warmup_sys_prompts,
|
||
warmup_query_prompts,
|
||
blend_special_str,
|
||
tokenizer,
|
||
offset=1,
|
||
)
|
||
|
||
sys_prompts = ["You are a helpful assistant."] * num_requests
|
||
query_prompts = ["What's up? how are you recently?"] * num_requests
|
||
|
||
prompt_ids = generate_prompt_ids(
|
||
doc_prompts,
|
||
sys_prompts,
|
||
query_prompts,
|
||
num_requests,
|
||
num_docs_per_request,
|
||
blend_special_str,
|
||
tokenizer,
|
||
offset=1,
|
||
)
|
||
|
||
write_resp("------warm up round------\n")
|
||
warmup_start_time = time.time()
|
||
warmup_ttfts = await test_long_document_qa(
|
||
client=client,
|
||
model=model,
|
||
prompts=warmup_prompt_ids,
|
||
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_ttfts = await test_long_document_qa(
|
||
client=client,
|
||
model=model,
|
||
prompts=prompt_ids,
|
||
output_len=args.output_len,
|
||
max_inflight_requests=args.max_inflight_requests,
|
||
)
|
||
benchmark_end_time = time.time()
|
||
|
||
# Print results
|
||
warmup_mean_ttft = sum(warmup_ttfts) / len(warmup_ttfts)
|
||
query_mean_ttft = sum(benchmark_ttfts) / len(benchmark_ttfts)
|
||
CSI = "\x1b["
|
||
RESET = CSI + "0m"
|
||
print(f"{CSI}36;1m\n=== BENCHMARK RESULTS ==={RESET}")
|
||
print(f"{CSI}32mWarmup round mean TTFT: {warmup_mean_ttft:.3f}s{RESET}")
|
||
print(
|
||
f"{CSI}33mWarmup round time: {warmup_end_time - warmup_start_time:.3f}s{RESET}"
|
||
)
|
||
print(f"{CSI}35mWarmup round prompt count: {len(warmup_ttfts)}{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_ttfts)}{RESET}")
|
||
|
||
# Validate expected gains as multiplicative speed-ups
|
||
if args.expected_ttft_gain is not None:
|
||
actual_ttft_gain = (
|
||
warmup_mean_ttft / query_mean_ttft if query_mean_ttft > 0 else float("inf")
|
||
)
|
||
print(f"{CSI}34mActual TTFT gain: {actual_ttft_gain:.2f}×{RESET}")
|
||
if actual_ttft_gain < args.expected_ttft_gain:
|
||
sys.exit(
|
||
f"ERROR: TTFT gain {actual_ttft_gain:.2f}× < expected "
|
||
f"{args.expected_ttft_gain:.2f}×"
|
||
)
|
||
|
||
if args.expected_latency_gain is not None:
|
||
warmup_duration = warmup_end_time - warmup_start_time
|
||
query_duration = benchmark_end_time - benchmark_start_time
|
||
|
||
# compute per-prompt latency before comparing
|
||
warmup_per_prompt = warmup_duration / len(warmup_ttfts)
|
||
query_per_prompt = query_duration / len(benchmark_ttfts)
|
||
actual_latency_gain = (
|
||
warmup_per_prompt / query_per_prompt
|
||
if query_per_prompt > 0
|
||
else float("inf")
|
||
)
|
||
print(f"{CSI}34mActual latency gain: {actual_latency_gain:.2f}×{RESET}")
|
||
if actual_latency_gain < args.expected_latency_gain:
|
||
sys.exit(
|
||
f"ERROR: latency gain {actual_latency_gain:.2f}× < expected "
|
||
f"{args.expected_latency_gain:.2f}×"
|
||
)
|
||
|
||
|
||
def create_argument_parser():
|
||
parser = argparse.ArgumentParser(
|
||
description="Benchmark the performance forMulti-Doc QA."
|
||
)
|
||
|
||
parser.add_argument(
|
||
"--document-length",
|
||
type=int,
|
||
# Roughly the number of tokens for a system paper,
|
||
# excluding images
|
||
default=3000,
|
||
help="Length of each document in tokens.",
|
||
)
|
||
|
||
parser.add_argument(
|
||
"--num-total-documents",
|
||
type=int,
|
||
default=100,
|
||
help="Number of documents to generate for testing.",
|
||
)
|
||
|
||
parser.add_argument(
|
||
"--output-len",
|
||
type=int,
|
||
default=10,
|
||
help="Maximum number of tokens to generate for each prompt.",
|
||
)
|
||
|
||
parser.add_argument(
|
||
"--num-requests",
|
||
type=int,
|
||
default=100,
|
||
help="Number of requests to send.",
|
||
)
|
||
|
||
parser.add_argument(
|
||
"--num-docs-per-request",
|
||
type=int,
|
||
default=5,
|
||
help="Number of requests to send.",
|
||
)
|
||
|
||
parser.add_argument(
|
||
"--sampling-strategy",
|
||
type=str,
|
||
default="random",
|
||
help="Random seed for sampling",
|
||
)
|
||
|
||
parser.add_argument(
|
||
"--random-seed",
|
||
type=int,
|
||
default=0,
|
||
help='Random seed when the repeat mode is "random"',
|
||
)
|
||
|
||
parser.add_argument(
|
||
"--blend-special-str",
|
||
type=str,
|
||
default=" # # ",
|
||
help="Special string to separate different documents.",
|
||
)
|
||
|
||
parser.add_argument(
|
||
"--port",
|
||
type=int,
|
||
default=8000,
|
||
help="Port to query the vLLM server",
|
||
)
|
||
|
||
parser.add_argument(
|
||
"--model",
|
||
type=str,
|
||
default="meta-llama/Llama-3.1-8B-Instruct",
|
||
help="Model name",
|
||
)
|
||
|
||
parser.add_argument(
|
||
"--max-inflight-requests",
|
||
type=int,
|
||
default=20,
|
||
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(
|
||
"--expected-ttft-gain",
|
||
type=float,
|
||
default=None,
|
||
help=(
|
||
"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."
|
||
),
|
||
)
|
||
parser.add_argument(
|
||
"--expected-latency-gain",
|
||
type=float,
|
||
default=None,
|
||
help=(
|
||
"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."
|
||
),
|
||
)
|
||
|
||
return parser
|
||
|
||
|
||
if __name__ == "__main__":
|
||
parser = create_argument_parser()
|
||
args = parser.parse_args()
|
||
OUTPUT_FILE = args.output
|
||
asyncio.run(main(args))
|