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

434 lines
15 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Standard
from dataclasses import dataclass
import argparse
import asyncio
import logging
import random
import time
# Third Party
from transformers import AutoTokenizer
from utils import (
AsyncLoopWrapper,
PromptBuildMethodType,
build_rag_prompt,
compute_f1,
compute_rl,
init_logger,
load_dataset,
)
import openai
import pandas as pd
logger = init_logger(__name__, logging.INFO)
system_prompt_set = {
PromptBuildMethodType.QA: "You will be asked a question after reading several passages. " # noqa: E501
"Please directly answer the question based on the given passages. "
"Do NOT repeat the question. "
"The answer should be within 5 words..\nPassages:\n",
PromptBuildMethodType.FEW_SHOT: "Summarize the dialogue into a few short sentences. " # noqa: E501
"The following are some examples.\n\n",
}
query_prompt_set = {
PromptBuildMethodType.QA: "\n\nAnswer the question directly based on the given passages." # noqa: E501
" Do NOT repeat the question. "
"The answer should be within 5 words. \nQuestion:",
PromptBuildMethodType.FEW_SHOT: "",
}
@dataclass
class WorkloadConfig:
# Overall QPS
qps: float
# Model name
model: str
# Tokenizer name
tokenizer: str
# Dataset.
dataset: str
# Start index of the workload
start_index: int
# End index of the workload
end_index: int
# Random shuffle.
shuffle: bool
# System prompt.
system_prompt: str
# Separator.
separator: str
# Query prompt.
query_prompt: str
# Prompt build method.
prompt_build_method: PromptBuildMethodType
# Max tokens for each generation.
max_tokens: int
@dataclass
class Response:
request_id: int
body: str
ttft: float
generation_time: float
prompt_tokens: int
generation_tokens: int
launch_time: float
finish_time: float
def parse_arguments():
parser = argparse.ArgumentParser(description="Parse RAG benchmark configurations.")
parser.add_argument("--qps", type=float, required=True, help="Overall QPS")
parser.add_argument("--model", type=str, required=True, help="Model name")
parser.add_argument("--tokenizer", type=str, default="", help="Tokenizer name")
parser.add_argument("--dataset", type=str, required=True, help="The dataset path")
parser.add_argument(
"--start-index", type=int, default=0, help="Start index of the workload"
)
parser.add_argument(
"--end-index", type=int, default=-1, help="End index of the workload"
)
parser.add_argument("--shuffle", action="store_true", help="Random shuffle")
parser.add_argument("--system-prompt", type=str, default="", help="System prompt")
parser.add_argument("--separator", type=str, default="", help="Separator")
parser.add_argument("--query-prompt", type=str, default="", help="Query prompt")
parser.add_argument(
"--prompt-build-method",
type=str,
required=True,
help="Prompt build method",
)
parser.add_argument(
"--base-url",
type=str,
required=True,
help="Base URL of the serving engine endpoint",
)
parser.add_argument(
"--api-key",
type=str,
default="EMPTY",
help="API key of the serving engine endpoint",
)
parser.add_argument(
"--output",
type=str,
default="summary.csv",
help="The output file name (ended with csv or txt) for the summary csv and txt",
)
parser.add_argument(
"--warmup", action="store_true", help="Whether to enable warmup"
)
parser.add_argument(
"--time",
type=int,
default=None,
help="The total running time in seconds",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Whether to enable verbose logging",
)
parser.add_argument(
"--max-tokens",
type=int,
default=32,
help="Max tokens for each generation",
)
parser.add_argument(
"--step-interval", type=float, default=0.02, help="Step interval"
)
args = parser.parse_args()
return args
class RequestExecutor:
def __init__(
self,
base_url: str,
api_key: str,
prompt_build_method: PromptBuildMethodType,
model: str,
):
self.client = openai.AsyncOpenAI(api_key=api_key, base_url=base_url)
self.model = model
self.loop = AsyncLoopWrapper.GetOrStartLoop()
self.prompt_build_method = prompt_build_method
async def _async_launch_request(self, request_id, prompt, max_tokens):
start_time = time.time()
first_token_time = None
words = ""
response = None
if self.prompt_build_method == PromptBuildMethodType.QA:
messages = [{"role": "user", "content": prompt}]
response = await self.client.chat.completions.create(
messages=messages,
model=self.model,
temperature=0,
stream=True,
max_tokens=max_tokens,
stream_options={"include_usage": True},
)
elif self.prompt_build_method == PromptBuildMethodType.FEW_SHOT:
response = await self.client.completions.create(
prompt=prompt,
model=self.model,
temperature=0,
stream=True,
max_tokens=max_tokens,
stream_options={"include_usage": True},
)
else:
raise ValueError(f"Invalid prompt build method {self.prompt_build_method}")
async for tok in response:
if not tok.choices:
continue
chunk_message = tok.choices[0].delta.content
if chunk_message is not None:
if first_token_time is None and chunk_message != "":
first_token_time = time.time()
words += chunk_message
tokens_out = tok.usage.completion_tokens
tokens_prefill = tok.usage.prompt_tokens
finish_time = time.time()
if first_token_time is None:
first_token_time = finish_time
return Response(
request_id=request_id,
body=words,
ttft=first_token_time - start_time,
generation_time=finish_time - first_token_time,
prompt_tokens=tokens_prefill,
generation_tokens=tokens_out,
launch_time=start_time,
finish_time=finish_time,
)
def launch_request(self, request_id: int, prompt, max_tokens, finish_callback):
"""
finish_callback: Callable[[Response], None]
"""
real_callback = lambda x: finish_callback(x.result())
future = asyncio.run_coroutine_threadsafe(
self._async_launch_request(request_id, prompt, max_tokens),
self.loop,
)
future.add_done_callback(real_callback)
def warmup_engine(executor: RequestExecutor):
logger.info("Warming up the engine")
for i in range(10):
prompt = f"WARMUP: Hi, I'm user {i}. Here are some text: {'hi ' * 100}."
executor.launch_request(-1, prompt, 100, lambda x: None)
AsyncLoopWrapper.WaitLoop()
logger.info("Warm up finished.")
class RAGManager:
def __init__(self, workload_config: WorkloadConfig):
self.workload_config = workload_config
eval_dataset = load_dataset(workload_config.dataset)
start_index = workload_config.start_index
end_index = workload_config.end_index
if end_index < 0:
end_index = len(eval_dataset)
eval_dataset = eval_dataset[start_index:end_index]
if workload_config.shuffle:
random.shuffle(eval_dataset)
self._prompts = []
self._answers = []
self._build_method = workload_config.prompt_build_method
self._generated_text: list[str | None] = []
self._generation_time: list[float | None] = []
self._prefill_tok_cnt: list[int | None] = []
self._generation_tok_cnt: list[int | None] = []
self._ttft: list[float | None] = []
self._tpot: list[float | None] = []
for ex in eval_dataset:
prompt, _ = build_rag_prompt(
workload_config.system_prompt,
ex,
workload_config.query_prompt,
workload_config.separator,
workload_config.prompt_build_method,
)
self._prompts.append(prompt)
self._answers.append(ex["answers"])
self._generated_text.append(None)
self._generation_time.append(None)
self._prefill_tok_cnt.append(None)
self._generation_tok_cnt.append(None)
self._ttft.append(None)
self._tpot.append(None)
self._tokenizer = AutoTokenizer.from_pretrained(workload_config.tokenizer)
self._last_request_time = -1.0
self._last_request_index = 0
assert workload_config.qps > 0
self._gap = 1.0 / workload_config.qps
self._max_tokens = workload_config.max_tokens
def _update_result(self, response: Response):
self._generated_text[response.request_id] = response.body
self._ttft[response.request_id] = response.ttft
self._tpot[response.request_id] = (
response.generation_time / response.generation_tokens
)
self._generation_time[response.request_id] = response.generation_time
self._prefill_tok_cnt[response.request_id] = response.prompt_tokens
self._generation_tok_cnt[response.request_id] = response.generation_tokens
def step(self, timestamp: float, executor: RequestExecutor) -> bool:
if self._last_request_index >= len(self._prompts):
return False
if (
self._last_request_time < 0
or timestamp >= self._last_request_time + self._gap
):
prompt = self._prompts[self._last_request_index]
request_id = self._last_request_index
self._last_request_time = timestamp
self._last_request_index += 1
executor.launch_request(
request_id, prompt, self._max_tokens, self._update_result
)
return True
def summary(self, start_time: float, end_time: float) -> pd.DataFrame:
cnt = len(self._ttft)
assert cnt > 0
avg_ttft = sum(ttft for ttft in self._ttft if ttft is not None) / cnt
avg_tpot = sum(tpot for tpot in self._tpot if tpot is not None) / cnt
# Create a dataframe
quality = []
for i in range(cnt):
if self._build_method == PromptBuildMethodType.QA:
quality.append(
max(
[
compute_f1(self._generated_text[i], answer, self._tokenizer)
for answer in self._answers[i]
]
)
)
elif self._build_method == PromptBuildMethodType.FEW_SHOT:
quality.append(
max(
[
compute_rl(self._generated_text[i], answer)
for answer in self._answers[i]
]
)
)
else:
raise ValueError(f"Invalid prompt build method {self._build_method}")
avg_quality = sum(quality) / cnt
df = pd.DataFrame(
{
"quality": quality,
"ttft": self._ttft,
"tpot": self._tpot,
"generation_time": self._generation_time,
"prefill_token_cnt": self._prefill_tok_cnt,
"generation_token_cnt": self._generation_tok_cnt,
}
)
total_time = end_time - start_time
thput = cnt / total_time
logger.info(
f"Summary: {cnt} requests, average_ttft={avg_ttft} (second)\n"
f" average_tpot={avg_tpot} (second)\n"
f"throughput={thput} (req/s)\n"
f"average_quality={avg_quality}\n"
)
return df
def run_rag(args):
build_prompt_method_str = args.prompt_build_method.upper()
build_prompt_method = None
if build_prompt_method_str == "QA":
build_prompt_method = PromptBuildMethodType.QA
elif build_prompt_method_str == "FEW_SHOT":
build_prompt_method = PromptBuildMethodType.FEW_SHOT
else:
raise ValueError(f"Invalid prompt build method {build_prompt_method_str}")
workload_config = WorkloadConfig(
qps=args.qps,
model=args.model,
tokenizer=args.tokenizer,
dataset=args.dataset,
start_index=args.start_index,
end_index=args.end_index,
shuffle=args.shuffle,
system_prompt=args.system_prompt,
separator=args.separator,
query_prompt=args.query_prompt,
prompt_build_method=build_prompt_method,
max_tokens=args.max_tokens,
)
executor = RequestExecutor(
base_url=args.base_url,
api_key=args.api_key,
prompt_build_method=build_prompt_method,
model=args.model,
)
if args.warmup:
warmup_engine(executor)
manager = RAGManager(workload_config)
step_interval = args.step_interval
num_steps = 0
start_time = time.time()
try:
while True:
num_steps += 1
effective = manager.step(time.time(), executor)
if not effective:
break
time.sleep(step_interval)
if args.time is not None and time.time() - start_time > args.time:
break
except KeyboardInterrupt:
logger.info("Interrupted, waiting for the final result")
AsyncLoopWrapper.StopLoop()
logger.info(f"Finished benchmarking, dumping summary to {args.output}")
summary = manager.summary(start_time, time.time())
summary.to_csv(args.output, index=False)
def main():
args = parse_arguments()
build_prompt_method_str = args.prompt_build_method.upper()
build_prompt_method = None
if build_prompt_method_str == "QA":
build_prompt_method = PromptBuildMethodType.QA
elif build_prompt_method_str == "FEW_SHOT":
build_prompt_method = PromptBuildMethodType.FEW_SHOT
else:
raise ValueError(f"Invalid prompt build method {build_prompt_method_str}")
if len(args.system_prompt) == 0:
args.system_prompt = system_prompt_set[build_prompt_method]
if len(args.query_prompt) == 0:
args.query_prompt = query_prompt_set[build_prompt_method]
if len(args.tokenizer) == 0:
args.tokenizer = args.model
args.system_prompt = args.system_prompt.encode().decode("unicode_escape")
args.query_prompt = args.query_prompt.encode().decode("unicode_escape")
if args.verbose:
global logger
logger = init_logger(__name__, logging.DEBUG)
run_rag(args)
if __name__ == "__main__":
main()