chore: import upstream snapshot with attribution
Lint / lint (push) Has been cancelled
Build Docs / Deploy Docs (push) Has been cancelled
Windows CI / Windows (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:23:58 +08:00
commit 770d92cb1f
694 changed files with 114634 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Subdirectory of bench."""
+403
View File
@@ -0,0 +1,403 @@
"""MLC LLM benchmark main entrance"""
import functools
import json
import random
from typing import Any, Dict, List, Optional, Tuple # noqa: UP035
import numpy as np
import requests
from transformers import AutoTokenizer
import mlc_llm
from mlc_llm.bench.api_endpoint import SUPPORTED_BACKENDS, create_api_endpoint
from mlc_llm.bench.dataset import SUPPORTED_DATASET, Dataset, create_dataset
from mlc_llm.bench.request_processor import (
MetricAnalyzer,
RequestProcessor,
create_pipelines,
)
from mlc_llm.bench.request_record import (
RequestRecord,
convert_reports_to_df,
generate_metrics_summary,
pretty_print_report,
)
from mlc_llm.cli.serve import EngineConfigOverride
from mlc_llm.serve import EngineConfig
from mlc_llm.support import argparse, logging
logging.enable_logging()
logger = logging.getLogger(__name__)
def _parse_num_concurrent_requests(num_str: Optional[str]) -> Optional[List[int]]: # noqa: UP006
if num_str is None:
return None
numbers = num_str.split(",")
if any(not number.isdigit() for number in numbers):
raise ValueError(f"Unrecognized num_concurrent_requests list: {numbers}")
return list(int(number) for number in numbers)
def _parse_request_rate(request_rate_str: Optional[str]) -> Optional[List[np.float32]]: # noqa: UP006
if request_rate_str is None:
return None
request_rates = request_rate_str.split(",")
results = []
for rate_str in request_rates:
request_rate = float(rate_str)
if request_rate <= 0:
raise ValueError(f"Invalid request rate {request_rate}")
results.append(np.float32(request_rate))
return results
def _parse_mlc_engine_config(config_str: Optional[str]) -> EngineConfig:
if config_str is None:
return None
engine_config_override = EngineConfigOverride.from_str(config_str)
return EngineConfig(
tensor_parallel_shards=engine_config_override.tensor_parallel_shards,
max_num_sequence=engine_config_override.max_num_sequence,
max_total_sequence_length=engine_config_override.max_total_seq_length,
prefill_chunk_size=engine_config_override.prefill_chunk_size,
sliding_window_size=engine_config_override.sliding_window_size,
attention_sink_size=engine_config_override.attention_sink_size,
max_history_size=engine_config_override.max_history_size,
gpu_memory_utilization=engine_config_override.gpu_memory_utilization,
spec_draft_length=engine_config_override.spec_draft_length,
prefill_mode=engine_config_override.prefill_mode,
prefix_cache_max_num_recycling_seqs=engine_config_override.prefix_cache_max_num_recycling_seqs,
prefix_cache_mode=engine_config_override.prefix_cache_mode,
)
def _launch_mlc_server(args: argparse.argparse.Namespace):
return mlc_llm.serve.PopenServer(
model=args.tokenizer,
mode="server",
model_lib=args.mlc_model_lib,
enable_tracing=False,
host=args.host,
port=args.port,
engine_config=args.mlc_engine_config,
)
def run_pipeline(
pipeline: RequestProcessor,
dataset: Dataset,
tokenizer: AutoTokenizer,
args: argparse.argparse.Namespace,
) -> Tuple[Dict[str, Any], List[RequestRecord]]: # noqa: UP006
"""Run the pipeline with the given dataset and args. Return the benchmark report dict."""
random.seed(args.seed)
np.random.seed(args.seed)
request_records = dataset.generate_request_records(
args.input_len,
args.output_len,
args.input_len_std,
args.output_len_std,
)
request_records = pipeline(request_records)
num_total_requests = (
args.num_requests if not args.per_gpu_workload else args.num_requests * args.num_gpus
)
assert len(request_records) == num_total_requests
sorted_requests: List[RequestRecord] = [None] * num_total_requests # noqa: UP006
for request_record in request_records:
assert request_record.request_id is not None
assert sorted_requests[request_record.request_id] is None
sorted_requests[request_record.request_id] = request_record
request_records = MetricAnalyzer(tokenizer)(request_records)
report = generate_metrics_summary(request_records, num_total_requests, args.num_gpus)
return report, sorted_requests
def query_mlc_server_metrics(host: str, port: int):
"""Try to get the MLC server metrics whenever it exists."""
try:
r = requests.post(f"http://{host}:{port}/debug/dump_engine_metrics", json={}, timeout=10)
if r.status_code == 200:
print(f"MLC server metrics: {r.json()}")
except Exception:
pass
def main(args: argparse.argparse.Namespace):
"""Main benchmark entrance."""
mlc_server = None
if args.mlc_model_lib:
mlc_server = _launch_mlc_server(args)
if args.num_requests <= 0:
raise ValueError("Number of requests to benchmark must be positive.")
def _main():
tokenizer = AutoTokenizer.from_pretrained(args.tokenizer)
dataset = create_dataset(args, tokenizer)
f_create_api_endpoint = functools.partial(create_api_endpoint, args)
pipelines = create_pipelines(args, f_create_api_endpoint, dataset)
reports = []
alltime_records = {}
for i, pipeline in enumerate(pipelines):
report, request_records = run_pipeline(pipeline, dataset, tokenizer, args)
exec_feature = (
json.dumps(report["exec_feature"])
if report["exec_feature"] is not None
else f"pipeline{i}"
)
alltime_records[exec_feature] = [
request_record.model_dump() for request_record in request_records
]
reports.append(report)
pretty_print_report(report)
query_mlc_server_metrics(args.host, args.port)
# Construct data frame
df = convert_reports_to_df(reports)
print(df)
df.to_csv(args.output, index=False)
logger.info("Benchmark results dumped to file %s", args.output)
if args.debug_dump:
debug_dump_filepath = (
args.output[:-4] if args.output.endswith(".csv") else args.output
) + "_debug_dump.log"
with open(debug_dump_filepath, "w", encoding="utf-8") as file:
json.dump(alltime_records, file, indent=4)
logger.info("Debug log dumped to file %s", debug_dump_filepath)
if mlc_server is not None:
with mlc_server:
_main()
else:
_main()
if __name__ == "__main__":
parser = argparse.ArgumentParser("MLC LLM benchmark")
parser.add_argument(
"--dataset",
type=str,
choices=SUPPORTED_DATASET,
help=f"The benchmark dataset kind. Supporting {SUPPORTED_DATASET}",
)
parser.add_argument(
"--dataset-path",
type=str,
help="The dataset file path.",
)
parser.add_argument(
"--api-endpoint",
type=str,
choices=SUPPORTED_BACKENDS,
default="openai",
help="The API endpoint API for benchmarking.",
)
parser.add_argument(
"--tokenizer",
type=str,
required=True,
help="The path of the tokenizer directory.",
)
parser.add_argument(
"--num-gpus",
type=int,
required=True,
help="The number of GPUs used by the server. "
"We need this to better analyze the throughput per GPU.",
)
parser.add_argument(
"--num-requests",
type=int,
required=True,
help="The number of requests for benchmark.",
)
parser.add_argument(
"--num-warmup-requests",
type=int,
help="The number of requests for warmup. "
"It is optional when fixing the number of concurrent requests, and is required otherwise.",
)
parser.add_argument(
"--per-gpu-workload",
default=False,
action="store_true",
help='When set to True, the specified "num_concurrent_requests"/"request_rate" '
"denote the workload **per GPU**, which means that the real values of "
'"num_concurrent_requests"/"request_rate" used in benchmark'
'will be multiplied by "num_gpus".',
)
parser.add_argument(
"--num-concurrent-requests",
type=_parse_num_concurrent_requests,
help="The number(s) of concurrent requests to benchmark. "
'It can be either one integer or a list of integer separated by commas(","). '
"When specified, for each integer, the benchmark keeps these many consistent "
"number of concurrently running requests.",
)
parser.add_argument(
"--request-rate",
type=_parse_request_rate,
help="The request rate(s) denoting the number of new requests each second. "
'It can be either one float number (or "inf") or a list of numbers separated '
'by commas(","). '
"When specified, the benchmark sends these many new requests each second. "
'If it is "inf", all requests will be sent together at once.',
)
parser.add_argument(
"--replay-timestamp-scale",
type=float,
help="The timestamp scale when replaying the timestamps in a dataset. "
'The dataset replay mode is enabled when neither "--num-concurrent-requests" and '
'"--request-rate" is specified. '
"The scale is 1 by default in the replay mode.",
)
parser.add_argument(
"--input-len",
type=int,
help="The benchmark request average input length. Default to None, "
"which means the request input length depends on the dataset being used.",
)
parser.add_argument(
"--input-len-std",
type=float,
default=0,
help="The benchmark request input length standard deviation. Default to 0.",
)
parser.add_argument(
"--output-len",
type=int,
help="The benchmark request average output length. Default to None, "
"which means the request output length depends on the dataset being used.",
)
parser.add_argument(
"--output-len-std",
type=float,
default=0,
help="The benchmark request output length standard deviation. Default to 0.",
)
parser.add_argument(
"--stream",
type=bool,
default=True,
help="Whether to benchmark stream responses. "
"When not enabled, metrics such as time-to-first-token (TTFT) will not be available. "
"Default to True.",
)
parser.add_argument(
# NOTE: The current implementation of server metrics still has some issues that need fixes,
# which makes it not work to include server metrics.
"--include-server-metrics",
action="store_true",
help="Whether to also benchmark the server side request metrics. "
"This option is only available when benchmarking MLC server.",
)
parser.add_argument(
"--host",
type=str,
required=True,
help="The host address of the backend API.",
)
parser.add_argument(
"--port",
type=int,
required=True,
help="The port of the backend API.",
)
parser.add_argument(
"--timeout",
type=float,
default=3 * 60 * 60,
help="The timeout limit of each request.",
)
parser.add_argument(
"--seed",
type=int,
default=0,
help="The random number seed. Default to 0.",
)
parser.add_argument(
"--temperature",
type=float,
default=1.0,
help="The temperature value for logit adjustment. Default to 1.",
)
parser.add_argument(
"--top-p",
type=float,
default=1.0,
help="The top-p value for sampling. Default to 1.",
)
parser.add_argument(
"--ignore-eos",
default=False,
action="store_true",
help='Whether to set the "ignore_eos" field.',
)
parser.add_argument(
"--apply-chat-template",
default=False,
action="store_true",
help="Whether to apply chat template to the request input text. "
'It is not supported when "--input-len" is specified.',
)
parser.add_argument(
"--num-process-workers",
type=int,
help="The number of parallel process workers to send the requests.",
)
parser.add_argument(
"--disable-tqdm",
action="store_true",
help="Whether to disable showing progress bar with tqdm during benchmarking.",
)
parser.add_argument(
"--max-schedule-gap",
type=float,
default=0.5,
help="The maximum allowed delay between the scheduled time in seconds.",
)
parser.add_argument(
"--mlc-model-lib",
type=str,
help="The model lib path when benchmarking MLC serve. "
"When specified, the server is automatic launched and no external server launch is needed.",
)
parser.add_argument(
"--mlc-engine-config",
type=_parse_mlc_engine_config,
help="The engine config used when launch MLC server.",
)
parser.add_argument(
"--cuda-profile",
default=False,
action="store_true",
help="Whether to enable cuda profile on server. "
"The --mlc-model-lib path should be provided when enabling this option.",
)
parser.add_argument(
"--debug-dump",
default=False,
action="store_true",
help="Whether to dump all request record raw data to file.",
)
parser.add_argument(
"--multi-round",
default=False,
action="store_true",
help="Whether to chat like multi round conversion with history log each request. "
"Only enabled when benchmarked with fixed concurrent request mode."
"The --num-concurrent-requests should be provided when enabling this option.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default="mlc_benchmark.csv",
help="The path of the output file where to dump the benchmark results.",
)
main(parser.parse_args())
+452
View File
@@ -0,0 +1,452 @@
"""MLC LLM bench backends"""
import argparse
import json
import os
import time
import traceback
from typing import Optional
from typing_extensions import Self
from mlc_llm.bench.request_record import Metrics, RequestRecord, ServerMetrics
from mlc_llm.support import logging
logger = logging.getLogger(__name__)
class APIEndPoint:
"""Manages the sending of requests to a specified API endpoint and gathers
inference statistics.
"""
def __init__(self, include_server_metrics: bool = False) -> None:
self.include_server_metrics = include_server_metrics
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, exc_type, exc_value, tb) -> None:
pass
async def __call__(self, request: RequestRecord) -> RequestRecord:
raise NotImplementedError()
class OpenAIChatEndPoint(APIEndPoint):
"""The backend of sending HTTP requests in OpenAI API through "v1/chat/completions"."""
def __init__(
self,
host: str,
port: int,
timeout: Optional[float] = None,
include_server_metrics: bool = False,
) -> None:
super().__init__(include_server_metrics=include_server_metrics)
import aiohttp
self.timeout = timeout
self.client: aiohttp.ClientSession = None
self.url = f"http://{host}:{port}/v1/chat/completions"
self.headers = {"Content-Type": "application/json"}
if os.getenv("MLC_LLM_API_KEY"):
self.headers["Authorization"] = f"Bearer {os.getenv('MLC_LLM_API_KEY')}"
async def __aenter__(self) -> Self:
import aiohttp
self.client = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(self.timeout))
return self
async def __aexit__(self, exc_type, exc_value, tb) -> None:
await self.client.close()
async def __call__(self, request_record: RequestRecord) -> RequestRecord:
payload = request_record.chat_cmpl.model_dump()
if self.timeout is not None and "timeout" not in payload:
payload["timeout"] = self.timeout
if self.include_server_metrics:
if "stream_options" not in payload or payload["stream_options"] is None:
payload["stream_options"] = {"include_usage": True}
else:
payload["stream_options"]["include_usage"] = True
if (
request_record.chat_cmpl.debug_config is not None
and request_record.chat_cmpl.debug_config.ignore_eos
):
payload["ignore_eos"] = True
generated_text = ""
first_chunk_output_str = ""
time_to_first_token_s = None
start_time = time.monotonic()
server_metrics = None
try:
async with self.client.post(self.url, json=payload, headers=self.headers) as response:
assert response.status == 200, await response.text()
if payload["stream"]:
async for chunk in response.content:
chunk = chunk.strip()
if not chunk or chunk == b"\n":
continue
# Get rid of the prefix "data: " and suffix "\n"
raw_data = chunk[6:].strip()
if raw_data == b"[DONE]":
continue
data = json.loads(raw_data)
if not data["choices"]:
continue
delta = data["choices"][0]["delta"]
content = delta.get("content", None)
if content is not None and not time_to_first_token_s:
time_to_first_token_s = time.monotonic() - start_time
first_chunk_output_str = content
if self.include_server_metrics and data["usage"] is not None:
# fmt: off
server_metrics = ServerMetrics(
input_tokens=data["usage"]["extra"]["prompt_tokens"],
prefill_tokens=data["usage"]["extra"]["prefill_tokens"],
output_tokens=data["usage"]["extra"]["completion_tokens"],
end_to_end_latency_s=data["usage"]["extra"]["end_to_end_latency_s"],
prefill_tokens_per_s=data["usage"]["extra"]["prefill_tokens_per_s"],
inter_token_latency_s=data["usage"]["extra"]["inter_token_latency_s"],
time_per_output_token_s=1 / data["usage"]["extra"]["decode_tokens_per_s"], # noqa: E501
time_to_first_token_s=data["usage"]["extra"]["ttft_s"],
)
# fmt: on
if content is not None:
generated_text += content
else:
data = await response.json()
generated_text = data["choices"][0]["message"]["content"]
if self.include_server_metrics and data["usage"] is not None:
# fmt: off
server_metrics = ServerMetrics(
input_tokens=data["usage"]["extra"]["prompt_tokens"],
prefill_tokens=data["usage"]["extra"]["prefill_tokens"],
output_tokens=data["usage"]["extra"]["completion_tokens"],
end_to_end_latency_s=data["usage"]["extra"]["end_to_end_latency_s"],
prefill_tokens_per_s=data["usage"]["extra"]["prefill_tokens_per_s"],
inter_token_latency_s=data["usage"]["extra"]["inter_token_latency_s"],
time_per_output_token_s=1 / data["usage"]["extra"]["decode_tokens_per_s"], # noqa: E501
time_to_first_token_s=data["usage"]["extra"]["ttft_s"],
)
# fmt: on
except Exception:
error_msg = "API endpoint errored when sending request: " + traceback.format_exc()
logger.info(error_msg)
finish_time = time.monotonic()
request_record.output_str = generated_text
request_record.first_chunk_output_str = first_chunk_output_str
request_record.metrics = Metrics(
success=False,
start_time=start_time,
finish_time=finish_time,
end_to_end_latency_s=finish_time - start_time,
input_tokens=request_record.metrics.input_tokens,
time_to_first_token_s=time_to_first_token_s,
server_metrics=server_metrics,
exec_feature=request_record.metrics.exec_feature,
)
request_record.error_msg = error_msg
return request_record
finish_time = time.monotonic()
request_record.output_str = generated_text
request_record.first_chunk_output_str = first_chunk_output_str
success = True
error_msg = None
if len(generated_text) == 0:
success = False
error_msg = "Empty generated text."
request_record.metrics = Metrics(
success=success,
start_time=start_time,
finish_time=finish_time,
end_to_end_latency_s=finish_time - start_time,
input_tokens=request_record.metrics.input_tokens,
time_to_first_token_s=time_to_first_token_s,
server_metrics=server_metrics,
exec_feature=request_record.metrics.exec_feature,
)
request_record.error_msg = error_msg
return request_record
class OpenAIEndPoint(APIEndPoint):
"""The backend of sending HTTP requests in OpenAI API through "v1/completions"."""
def __init__(
self,
host: str,
port: int,
timeout: Optional[float] = None,
include_server_metrics: bool = False,
no_debug_config: bool = False,
) -> None:
super().__init__(include_server_metrics=include_server_metrics)
import aiohttp
self.timeout = timeout
self.client: aiohttp.ClientSession = None
self.url = f"http://{host}:{port}/v1/completions"
self.headers = {"Content-Type": "application/json"}
if os.getenv("MLC_LLM_API_KEY"):
self.headers["Authorization"] = f"Bearer {os.getenv('MLC_LLM_API_KEY')}"
assert not include_server_metrics, (
'"include_server_metrics" only works for "openai-chat" endpoint for now'
)
self.no_debug_config = no_debug_config
async def __aenter__(self) -> Self:
import aiohttp
self.client = aiohttp.ClientSession()
return self
async def __aexit__(self, exc_type, exc_value, tb) -> None:
await self.client.close()
async def __call__(self, request_record: RequestRecord) -> RequestRecord:
assert len(request_record.chat_cmpl.messages) == 1, (
'Endpoint "openai" does not support system prompt and multi-round conversation.'
)
assert isinstance(request_record.chat_cmpl.messages[0].content, str)
payload = {
"model": request_record.chat_cmpl.model,
"prompt": request_record.chat_cmpl.messages[0].content,
"temperature": request_record.chat_cmpl.temperature,
"top_p": request_record.chat_cmpl.top_p,
"max_tokens": request_record.chat_cmpl.max_tokens,
"stream": True,
}
if self.timeout is not None and "timeout" not in payload:
payload["timeout"] = self.timeout
if (
request_record.chat_cmpl.debug_config is not None
and request_record.chat_cmpl.debug_config.ignore_eos
):
payload["ignore_eos"] = True
if not self.no_debug_config:
payload["debug_config"] = {"ignore_eos": True}
generated_text = ""
first_chunk_output_str = ""
time_to_first_token_s = None
start_time = time.monotonic()
try:
async with self.client.post(
self.url, json=payload, headers=self.headers, timeout=3600
) as response:
assert response.status == 200, await response.text()
if payload["stream"]:
async for chunk in response.content:
chunk = chunk.strip()
if not chunk or chunk == b"\n":
continue
# Get rid of the prefix "data: " and suffix "\n"
raw_data = chunk[6:].strip()
if raw_data == b"[DONE]":
continue
data = json.loads(raw_data)
if not data["choices"]:
continue
content = data["choices"][0]["text"]
if content is not None and not time_to_first_token_s:
time_to_first_token_s = time.monotonic() - start_time
first_chunk_output_str = content
if content is not None:
generated_text += content
else:
data = await response.json()
generated_text = data["choices"][0]["message"]["content"]
except Exception:
error_msg = "API endpoint errored when sending request: " + traceback.format_exc()
logger.info(error_msg)
finish_time = time.monotonic()
request_record.output_str = generated_text
request_record.first_chunk_output_str = first_chunk_output_str
request_record.metrics = Metrics(
success=False,
start_time=start_time,
finish_time=finish_time,
end_to_end_latency_s=finish_time - start_time,
input_tokens=request_record.metrics.input_tokens,
time_to_first_token_s=time_to_first_token_s,
server_metrics=None,
exec_feature=request_record.metrics.exec_feature,
)
request_record.error_msg = error_msg
return request_record
finish_time = time.monotonic()
request_record.output_str = generated_text
request_record.first_chunk_output_str = first_chunk_output_str
success = True
error_msg = None
if len(generated_text) == 0:
success = False
error_msg = "Empty generated text."
request_record.metrics = Metrics(
success=success,
start_time=start_time,
finish_time=finish_time,
end_to_end_latency_s=finish_time - start_time,
input_tokens=request_record.metrics.input_tokens,
time_to_first_token_s=time_to_first_token_s,
server_metrics=None,
exec_feature=request_record.metrics.exec_feature,
)
request_record.error_msg = error_msg
return request_record
class TensorRTLLMEndPoint(APIEndPoint):
"""The backend of sending HTTP requests in TensorRT-LLM API."""
def __init__(self, host: str, port: int, timeout: Optional[float] = None) -> None:
super().__init__(include_server_metrics=False)
import aiohttp
self.timeout = timeout
self.client: aiohttp.ClientSession = None
self.url_stream = f"http://{host}:{port}/v2/models/ensemble/generate_stream"
self.url_no_stream = f"http://{host}:{port}/v2/models/ensemble/generate"
async def __aenter__(self) -> Self:
import aiohttp
self.client = aiohttp.ClientSession()
return self
async def __aexit__(self, exc_type, exc_value, tb) -> None:
await self.client.close()
async def __call__(self, request_record: RequestRecord) -> RequestRecord:
assert len(request_record.chat_cmpl.messages) == 1
assert isinstance(request_record.chat_cmpl.messages[0].content, str)
payload = {
"accumulate_tokens": True,
"text_input": request_record.chat_cmpl.messages[0].content,
"temperature": (
max(request_record.chat_cmpl.temperature, 1e-5)
if request_record.chat_cmpl.temperature
else 1
),
"top_p": request_record.chat_cmpl.top_p if request_record.chat_cmpl.top_p else 1,
"max_tokens": request_record.chat_cmpl.max_tokens,
"stream": request_record.chat_cmpl.stream,
}
if (
request_record.chat_cmpl.debug_config is not None
and request_record.chat_cmpl.debug_config.ignore_eos
):
payload["min_length"] = payload["max_tokens"]
if self.timeout is not None and "timeout" not in payload:
payload["timeout"] = self.timeout
generated_text = ""
first_chunk_output_str = ""
url = self.url_stream if request_record.chat_cmpl.stream else self.url_no_stream
time_to_first_token_s = None
start_time = time.monotonic()
try:
async with self.client.post(url, json=payload) as response:
assert response.status == 200, await response.text()
if payload["stream"]:
async for chunk in response.content:
chunk = chunk.strip()
if not chunk or chunk == b"\n":
continue
# Get rid of the prefix "data:" and suffix "\n"
raw_data = chunk[5:].strip()
data = json.loads(raw_data)
delta = data["text_output"]
if delta is None:
continue
if not time_to_first_token_s:
time_to_first_token_s = time.monotonic() - start_time
first_chunk_output_str = delta
generated_text += delta
else:
data = await response.json()
generated_text = data["text_output"]
except Exception:
error_msg = "API endpoint errored when sending request: " + traceback.format_exc()
logger.info(error_msg)
finish_time = time.monotonic()
request_record.output_str = generated_text
request_record.first_chunk_output_str = first_chunk_output_str
request_record.metrics = Metrics(
success=False,
start_time=start_time,
finish_time=finish_time,
end_to_end_latency_s=finish_time - start_time,
input_tokens=request_record.metrics.input_tokens,
time_to_first_token_s=time_to_first_token_s,
exec_feature=request_record.metrics.exec_feature,
)
request_record.error_msg = error_msg
return request_record
finish_time = time.monotonic()
request_record.output_str = generated_text
request_record.first_chunk_output_str = first_chunk_output_str
success = True
error_msg = None
if len(generated_text) == 0:
success = False
error_msg = "Empty generated text."
request_record.metrics = Metrics(
success=success,
start_time=start_time,
finish_time=finish_time,
end_to_end_latency_s=finish_time - start_time,
input_tokens=request_record.metrics.input_tokens,
time_to_first_token_s=time_to_first_token_s,
exec_feature=request_record.metrics.exec_feature,
)
request_record.error_msg = error_msg
return request_record
# Todo: APIEndPoint with AsyncOpenAI Python interface
# class OpenAIPythonEndPoint(APIEndPoint):
# pass
SUPPORTED_BACKENDS = [
"openai",
"openai-chat",
"mlc",
"sglang",
"tensorrt-llm",
"vllm",
]
def create_api_endpoint(args: argparse.Namespace) -> APIEndPoint:
"""Create an API endpoint instance with regard to the specified endpoint kind."""
if args.api_endpoint in ["openai", "mlc", "sglang"]:
return OpenAIEndPoint(args.host, args.port, args.timeout, args.include_server_metrics)
if args.api_endpoint == "vllm":
return OpenAIEndPoint(
args.host,
args.port,
args.timeout,
include_server_metrics=False,
no_debug_config=True,
)
if args.api_endpoint == "openai-chat":
return OpenAIChatEndPoint(args.host, args.port, args.timeout, args.include_server_metrics)
if args.api_endpoint == "tensorrt-llm":
return TensorRTLLMEndPoint(args.host, args.port, args.timeout)
raise ValueError(f'Unrecognized endpoint "{args.api_endpoint}"')
+878
View File
@@ -0,0 +1,878 @@
"""MLC LLM benchmark dataset classes"""
import argparse
import json
import random
from datetime import datetime
from typing import ClassVar, Dict, List, Optional, Tuple # noqa: UP035
import numpy as np
import pandas as pd
from datasets import load_dataset
from transformers import AutoTokenizer
from mlc_llm.bench.request_record import GroupedRequestRecord, Metrics, RequestRecord
from mlc_llm.protocol.openai_api_protocol import (
ChatCompletionMessage,
ChatCompletionRequest,
DebugConfig,
)
class Dataset:
"""The dataset base class."""
# We set a truncation limit of 100k.
truncate_length = int(1e5)
# For some that datasets (e.g., dataset that has shared common prefix),
# we need fake warmup requests to avoid prefilling common prefixes to the engine.
require_fake_warmup: bool = False
# Whether the dataset contains timestamps already.
# If the dataset comes with timestamps, the benchmark can just replay
# the requests according to their timestamps.
timestamp_available: bool = False
def generate_request_records(
self,
input_len: Optional[int],
output_len: Optional[int],
input_len_std: float = 0.0,
output_len_std: float = 0.0,
) -> List[RequestRecord]: # noqa: UP006
"""Get the raw unprocessed request records of the dataset."""
raise NotImplementedError()
class ShareGPTDataset(Dataset):
"""The dataset class for ShareGPT dataset."""
_tokenized_dataset: List[Tuple[str, List[int], int]] # noqa: UP006
apply_chat_template: bool
def __init__(
self, dataset_path: str, tokenizer: AutoTokenizer, apply_chat_template: bool
) -> None:
self.apply_chat_template = apply_chat_template
with open(dataset_path, encoding="utf-8") as f:
raw_dataset = json.load(f)
# Filter out the conversations with less than 2 turns.
_dataset = [
(data["conversations"][0]["value"], data["conversations"][1]["value"])
for data in raw_dataset
if len(data["conversations"]) >= 2 and data["conversations"][0]["from"] == "human"
]
# Tokenize the prompts and completions.
self.tokenizer = tokenizer
prompts = [prompt for prompt, _ in _dataset]
if apply_chat_template:
assert getattr(tokenizer, "chat_template", None) is not None, (
'"--apply-chat-template" is set but the tokenizer does not have chat template.'
)
prompts = [
tokenizer.apply_chat_template(
[{"role": "user", "content": prompt}],
add_generation_prompt=True,
tokenize=False,
)
for prompt in prompts
]
prompt_token_ids = list(
tokenizer(
prompts,
truncation=True,
max_length=min(tokenizer.model_max_length, self.truncate_length),
add_special_tokens=False,
).input_ids
)
completions = [completion for _, completion in _dataset]
completion_token_ids = tokenizer(
completions,
truncation=True,
max_length=min(tokenizer.model_max_length, self.truncate_length),
add_special_tokens=False,
).input_ids
self._tokenized_dataset: List[Tuple[str, List[int], int]] = [] # noqa: UP006
for i in range(len(_dataset)):
if (
len(prompt_token_ids[i]) < 4
or len(completion_token_ids[i]) < 4
or len(prompt_token_ids[i]) + len(completion_token_ids[i])
>= min(tokenizer.model_max_length, 8192)
):
# Filter out sequences that are too short or too long
continue
self._tokenized_dataset.append(
(prompts[i], prompt_token_ids[i], len(completion_token_ids[i]))
)
def generate_request_records(
self,
input_len: Optional[int],
output_len: Optional[int],
input_len_std: float = 0.0,
output_len_std: float = 0.0,
) -> List[RequestRecord]: # noqa: UP006
if self.apply_chat_template:
assert input_len is None, (
'"--apply-chat-template" is not supported when "--input-len" is specified.'
)
request_records = []
for prompt, input_token_ids, output_length in self._tokenized_dataset:
input_length = len(input_token_ids)
# If the request does not have enough length, discard it.
if input_len is not None and input_length < input_len + 4 * input_len_std:
continue
if input_len is not None:
input_length = round(
float(np.random.normal(loc=input_len, scale=input_len_std, size=1)[0])
)
input_token_ids = input_token_ids[:input_length]
input_truncated = True
else:
input_truncated = False
if output_len is not None:
output_length = round(
float(np.random.normal(loc=output_len, scale=output_len_std, size=1)[0])
)
elif output_length <= 1:
continue
request_records.append(
RequestRecord(
chat_cmpl=ChatCompletionRequest(
messages=[
{
"role": "user",
"content": (
self.tokenizer.decode(input_token_ids)
if input_truncated
else prompt
),
}
],
model="",
max_tokens=output_length,
),
metrics=Metrics(
success=False,
start_time=0,
finish_time=0,
end_to_end_latency_s=0,
input_tokens=len(input_token_ids),
),
)
)
return request_records
class LoogleDataset(Dataset):
"""The dataset class for Loogle dataset."""
task2prompt: ClassVar[Dict[str, str]] = { # noqa: UP006
"shortdep_qa": "Please answer the question based on the long texts below. \n{input}\nQuestion: {Q}\nAnswer: ", # noqa: E501
"longdep_qa": "Please answer the question based on the long texts below. \n{input}\nQuestion: {Q}\nAnswer: ", # noqa: E501
"longdep_summarization": "Please generate a summary of the below paper. \n{input}\n Summarization: ", # noqa: E501
"shortdep_cloze": "Please fill in the clozes based on the given long texts below. Each of the placeholder '<mask-n>' in the question could be an entity of Person, Location or Organiocation. The same masks represent the same entity. Output a json format answer, for example: {{'<mask-0>': 'Bob', '<mask-1>': 'Gorrosion Magazine','<mask-2>': 'Bethel Horizon'}}\n{input}\n Question: {Q} What are the masked entities? \nAnswer:", # noqa: E501
}
require_fake_warmup: bool = True
def __init__(self, tokenizer: AutoTokenizer, testset_name: str) -> None:
raw_dataset = load_dataset("bigainlco/LooGLE", testset_name, split="test")
self.tokenizer = tokenizer
self.dataset = []
self.prompt_format = self.task2prompt[testset_name]
prompts = []
generate_lens = []
questions = []
for data in raw_dataset:
prompt = data["input"]
prompts.append(prompt)
qa_pairs = eval(data["qa_pairs"])
questions.append([j["Q"] for j in qa_pairs])
generate_lens.append(
[len(tokenizer.encode(j["A"], add_special_tokens=False)) for j in qa_pairs]
)
prompt_token_ids = tokenizer(
prompts,
truncation=True,
max_length=min(tokenizer.model_max_length, self.truncate_length),
add_special_tokens=False,
).input_ids
for prompt, prompt_token_id, question, generate_len in zip(
prompts, prompt_token_ids, questions, generate_lens
):
self.dataset.append((prompt, prompt_token_id, question, generate_len))
def generate_request_records(
self,
input_len: Optional[int],
output_len: Optional[int],
input_len_std: float = 0.0,
output_len_std: float = 0.0,
) -> List[RequestRecord]: # noqa: UP006
request_records = []
for prompt, input_token_ids, questions, generate_lens in self.dataset:
input_length = round(float(np.random.normal(loc=input_len, scale=input_len_std)))
if len(input_token_ids) > input_length:
input_token_ids = input_token_ids[:input_length]
prompt = self.tokenizer.decode(input_token_ids)
grouped_request_records = []
for question, generate_len in zip(questions, generate_lens):
json_obj = {"input": prompt, "Q": question}
full_prompt = self.prompt_format.format(**json_obj)
output_length = (
round(float(np.random.normal(loc=output_len, scale=output_len_std, size=1)[0]))
if output_len is not None
else generate_len
)
grouped_request_records.append(
RequestRecord(
chat_cmpl=ChatCompletionRequest(
messages=[
{
"role": "user",
"content": full_prompt,
}
],
model="",
max_tokens=output_length,
),
metrics=Metrics(
success=False,
start_time=0,
finish_time=0,
end_to_end_latency_s=0,
input_tokens=len(input_token_ids),
),
)
)
request_records.append(
GroupedRequestRecord(
# Create a dummy ChatCompletionRequest.
chat_cmpl=ChatCompletionRequest(messages=[]),
records=grouped_request_records,
)
)
return request_records
class LLMPerfDataset(Dataset):
"""The dataset class for LLMPerf dataset."""
def __init__(self, dataset_path: str, num_requests: int, tokenizer: AutoTokenizer) -> None:
self.tokenizer = tokenizer
self.num_requests = num_requests
with open(dataset_path, encoding="utf-8") as f:
untokenized_data = f.readlines()
# Tokenize the prompts and completions.
tokenized_data = tokenizer(
untokenized_data,
truncation=True,
max_length=min(tokenizer.model_max_length, self.truncate_length),
add_special_tokens=False,
).input_ids
tokenized_data_lengths = [len(tokens) for tokens in tokenized_data]
self.dataset: List[Tuple[str, List[int], int]] = list( # noqa: UP006
zip(untokenized_data, tokenized_data, tokenized_data_lengths)
)
def generate_request_records(
self,
input_len: Optional[int] = None,
output_len: Optional[int] = None,
input_len_std: float = 250,
output_len_std: float = 0.0,
) -> List[RequestRecord]: # noqa: UP006
if input_len is None or input_len < 40:
input_len = 550
if output_len is None:
output_len = 150
request_records = []
for _ in range(self.num_requests):
input_length = round(float(np.random.normal(loc=input_len, scale=input_len_std)))
output_length = round(float(np.random.normal(loc=output_len, scale=output_len_std)))
prompt = (
"Randomly stream lines from the following text "
f"with {output_length} output tokens. "
"Don't generate eos tokens:\n\n"
)
remaining_token_length = input_length - len(
self.tokenizer.encode(prompt, add_special_tokens=False)
)
random.shuffle(self.dataset)
while remaining_token_length > 0:
for text, tokens, token_length in self.dataset:
if remaining_token_length < token_length:
prompt += self.tokenizer.decode(tokens[:remaining_token_length])
else:
prompt += text
remaining_token_length -= token_length
if remaining_token_length < 0:
break
request_records.append(
RequestRecord(
chat_cmpl=ChatCompletionRequest(
messages=[{"role": "user", "content": prompt}],
model="",
max_tokens=output_length,
debug_config=DebugConfig(ignore_eos=True),
),
metrics=Metrics(
success=False,
start_time=0,
finish_time=0,
end_to_end_latency_s=0,
input_tokens=input_length,
),
)
)
return request_records
class JSONModeEvalDataset(Dataset):
"""The dataset class for JSON dataset."""
def __init__(self, tokenizer: AutoTokenizer) -> None:
raw_dataset = load_dataset("NousResearch/json-mode-eval")
self.tokenizer = tokenizer
self.dataset = []
for data in raw_dataset["train"]:
messages = data["prompt"]
schema = {
"type": "json_object",
"schema": data["schema"],
}
num_tokens = 0
for message in messages:
num_tokens += len(
self.tokenizer.encode(message["content"], add_special_tokens=False)
)
self.dataset.append((messages, schema, num_tokens))
def generate_request_records(
self,
input_len: Optional[int],
output_len: Optional[int],
input_len_std: float = 0.0,
output_len_std: float = 0.0,
) -> List[RequestRecord]: # noqa: UP006
request_records = []
for messages, schema, num_tokens in self.dataset:
# If the request does not have enough length, discard it.
if input_len is not None and num_tokens < input_len + 4 * input_len_std:
continue
if output_len is not None:
output_length = max(
round(np.random.normal(loc=output_len, scale=output_len_std)), 1
)
else:
output_length = None
request_records.append(
RequestRecord(
chat_cmpl=ChatCompletionRequest(
messages=[
ChatCompletionMessage(content=message["content"], role=message["role"])
for message in messages
],
model="",
max_tokens=output_length,
response_format=schema,
),
metrics=Metrics(
success=False,
start_time=0,
finish_time=0,
end_to_end_latency_s=0,
input_tokens=num_tokens,
),
)
)
return request_records
class ReActDataset(Dataset):
"""The dataset class for replaying a given ReAct trace for benchmark purpose.
It is not an actual ReAct agent implementation.
"""
_dataset: List[List[Tuple[str, int, int]]] # noqa: UP006
require_fake_warmup: bool = True
prefix: str = """Solve a question answering task with interleaving Thought, Action, Observation steps. Thought can reason about the current situation, and Action can be three types:
(1) Search[entity], which searches the exact entity on Wikipedia and returns the first paragraph if it exists. If not, it will return some similar entities to search.
(2) Lookup[keyword], which returns the next sentence containing keyword in the current passage.
(3) Finish[answer], which returns the answer and finishes the task.
Here are some examples.
Question: What is the elevation range for the area that the eastern sector of the Colorado orogeny extends into?
Thought 1: I need to search Colorado orogeny, find the area that the eastern sector of the Colorado orogeny extends into, then find the elevation range of the area.
Action 1: Search[Colorado orogeny]
Observation 1: The Colorado orogeny was an episode of mountain building (an orogeny) in Colorado and surrounding areas.
Thought 2: It does not mention the eastern sector. So I need to look up eastern sector.
Action 2: Lookup[eastern sector]
Observation 2: (Result 1 / 1) The eastern sector extends into the High Plains and is called the Central Plains orogeny.
Thought 3: The eastern sector of Colorado orogeny extends into the High Plains. So I need to search High Plains and find its elevation range.
Action 3: Search[High Plains]
Observation 3: High Plains refers to one of two distinct land regions:
Thought 4: I need to instead search High Plains (United States).
Action 4: Search[High Plains (United States)]
Observation 4: The High Plains are a subregion of the Great Plains. From east to west, the High Plains rise in elevation from around 1,800 to 7,000 ft (550 to 2,130 m).[3]
Thought 5: High Plains rise in elevation from around 1,800 to 7,000 ft, so the answer is 1,800 to 7,000 ft.
Action 5: Finish[1,800 to 7,000 ft]
Question: Musician and satirist Allie Goertz wrote a song about the "The Simpsons" character Milhouse, who Matt Groening named after who?
Thought 1: The question simplifies to "The Simpsons" character Milhouse is named after who. I only need to search Milhouse and find who it is named after.
Action 1: Search[Milhouse]
Observation 1: Milhouse Mussolini Van Houten is a recurring character in the Fox animated television series The Simpsons voiced by Pamela Hayden and created by Matt Groening.
Thought 2: The paragraph does not tell who Milhouse is named after, maybe I can look up "named after".
Action 2: Lookup[named after]
Observation 2: (Result 1 / 1) Milhouse was named after U.S. president Richard Nixon, whose middle name was Milhous.
Thought 3: Milhouse was named after U.S. president Richard Nixon, so the answer is Richard Nixon.
Action 3: Finish[Richard Nixon]
Question: Which documentary is about Finnish rock groups, Adam Clayton Powell or The Saimaa Gesture?
Thought 1: I need to search Adam Clayton Powell and The Saimaa Gesture, and find which documentary is about Finnish rock groups.
Action 1: Search[Adam Clayton Powell]
Observation 1: Could not find [Adam Clayton Powell]. Similar: ['Adam Clayton Powell III', 'Seventh Avenue (Manhattan)', 'Adam Clayton Powell Jr. State Office Building', 'Isabel Washington Powell', 'Adam Powell', 'Adam Clayton Powell (film)', 'Giancarlo Esposito'].
Thought 2: To find the documentary, I can search Adam Clayton Powell (film).
Action 2: Search[Adam Clayton Powell (film)]
Observation 2: Adam Clayton Powell is a 1989 American documentary film directed by Richard Kilberg.
The film is about the rise and fall of influential African-American politician Adam Clayton Powell Jr.[3][4] It was later aired as part of the PBS series The American Experience.
Thought 3: Adam Clayton Powell (film) is a documentary about an African-American politician, not Finnish rock groups. So the documentary about Finnish rock groups must instead be The Saimaa Gesture.
Action 3: Finish[The Saimaa Gesture]
Question: What profession does Nicholas Ray and Elia Kazan have in common?
Thought 1: I need to search Nicholas Ray and Elia Kazan, find their professions, then find the profession they have in common.
Action 1: Search[Nicholas Ray]
Observation 1: Nicholas Ray (born Raymond Nicholas Kienzle Jr., August 7, 1911 June 16, 1979) was an American film director, screenwriter, and actor best known for the 1955 film Rebel Without a Cause.
Thought 2: Professions of Nicholas Ray are director, screenwriter, and actor. I need to search Elia Kazan next and find his professions.
Action 2: Search[Elia Kazan]
Observation 2: Elia Kazan was an American film and theatre director, producer, screenwriter and actor.
Thought 3: Professions of Elia Kazan are director, producer, screenwriter, and actor. So profession Nicholas Ray and Elia Kazan have in common is director, screenwriter, and actor.
Action 3: Finish[director, screenwriter, actor]
Question: Which magazine was started first Arthur's Magazine or First for Women?
Thought 1: I need to search Arthur's Magazine and First for Women, and find which was started first.
Action 1: Search[Arthur's Magazine]
Observation 1: Arthur's Magazine (1844-1846) was an American literary periodical published in Philadelphia in the 19th century.
Thought 2: Arthur's Magazine was started in 1844. I need to search First for Women next.
Action 2: Search[First for Women]
Observation 2: First for Women is a woman's magazine published by Bauer Media Group in the USA.[1] The magazine was started in 1989.
Thought 3: First for Women was started in 1989. 1844 (Arthur's Magazine) < 1989 (First for Women), so Arthur's Magazine was started first.
Action 3: Finish[Arthur's Magazine]
Question: Were Pavel Urysohn and Leonid Levin known for the same type of work?
Thought 1: I need to search Pavel Urysohn and Leonid Levin, find their types of work, then find if they are the same.
Action 1: Search[Pavel Urysohn]
Observation 1: Pavel Samuilovich Urysohn (February 3, 1898 â August 17, 1924) was a Soviet mathematician who is best known for his contributions in dimension theory.
Thought 2: Pavel Urysohn is a mathematician. I need to search Leonid Levin next and find its type of work.
Action 2: Search[Leonid Levin]
Observation 2: Leonid Anatolievich Levin is a Soviet-American mathematician and computer scientist.
Thought 3: Leonid Levin is a mathematician and computer scientist. So Pavel Urysohn and Leonid Levin have the same type of work.
Action 3: Finish[yes]
""" # noqa: E501, RUF001
def __init__(self, dataset_path: str, tokenizer: AutoTokenizer) -> None:
raw_entries: List[Dict] = [] # noqa: UP006
with open(dataset_path) as fin:
for line in fin:
line_content = json.loads(line)
raw_entries += list({"question": k, "triplets": v} for k, v in line_content.items())
self._dataset = []
max_rounds = 0
for raw_entry in raw_entries:
processed_entry = []
question = raw_entry["question"]
triplets = raw_entry["triplets"]
seq = self.prefix + question
max_rounds = max(max_rounds, len(triplets) + 1)
output_lengths: List[int] = [] # noqa: UP006
for i, triplet in enumerate(triplets):
output_lengths.append(
len(
tokenizer(
triplet["thought"]
+ "\nAction "
+ str(i + 1)
+ ": "
+ triplet["action"]
+ "\n",
truncation=True,
max_length=min(tokenizer.model_max_length, self.truncate_length),
add_special_tokens=False,
).input_ids
)
)
for i in range(1, len(triplets) + 2):
seq += "Thought " + str(i) + ":"
input_len = len(
tokenizer(
seq,
truncation=True,
max_length=min(tokenizer.model_max_length, self.truncate_length),
add_special_tokens=False,
).input_ids
)
output_length = (
output_lengths[i - 1]
if i <= len(triplets)
else int(sum(output_lengths) / len(triplets))
)
processed_entry.append((seq, input_len, output_length))
if i != len(triplets) + 1:
seq += (
triplets[i - 1]["thought"]
+ "\nAction "
+ str(i)
+ ": "
+ triplets[i - 1]["action"]
+ "\nObservation "
+ str(i)
+ ": "
+ triplets[i - 1]["observation"]
+ "\n"
)
self._dataset.append(processed_entry)
def generate_request_records(
self,
input_len: Optional[int],
output_len: Optional[int],
input_len_std: float = 0.0,
output_len_std: float = 0.0,
) -> List[RequestRecord]: # noqa: UP006
if input_len is not None or output_len is not None:
raise ValueError("ReAct dataset does not support specifying input/output length.")
request_records = []
for processed_entries in self._dataset:
grouped_request_records = []
for prompt, input_length, output_length in processed_entries:
grouped_request_records.append(
RequestRecord(
chat_cmpl=ChatCompletionRequest(
messages=[{"role": "user", "content": prompt}],
model="",
max_tokens=output_length,
),
metrics=Metrics(
success=False,
start_time=0,
finish_time=0,
end_to_end_latency_s=0,
input_tokens=input_length,
),
)
)
request_records.append(
GroupedRequestRecord(
# Create a dummy ChatCompletionRequest.
chat_cmpl=ChatCompletionRequest(messages=[]),
records=grouped_request_records,
)
)
return request_records
class WildChatDataset(Dataset):
"""The dataset class for WildChat dataset."""
apply_chat_template: bool
def __init__(self, tokenizer: AutoTokenizer, apply_chat_template: bool) -> None:
raw_dataset = load_dataset("allenai/WildChat", split="train")
self.tokenizer = tokenizer
self.apply_chat_template = apply_chat_template
# Filter out the conversations with less than 2 turns.
_dataset = [
(entry["conversation"][0]["content"], entry["conversation"][1]["content"])
for entry in raw_dataset
if len(entry["conversation"]) >= 2
and entry["conversation"][0]["role"] == "user"
and entry["conversation"][1]["role"] == "assistant"
]
prompts = []
completions = []
for prompt, completion in _dataset:
prompts.append(prompt)
completions.append(completion)
if apply_chat_template:
assert getattr(tokenizer, "chat_template", None) is not None, (
'"--apply-chat-template" is set but the tokenizer does not have chat template.'
)
prompts = [
tokenizer.apply_chat_template(
[{"role": "user", "content": prompt}],
add_generation_prompt=True,
tokenize=False,
)
for prompt in prompts
]
prompt_token_ids = list(
tokenizer(
prompts,
truncation=True,
max_length=min(tokenizer.model_max_length, self.truncate_length),
add_special_tokens=False,
).input_ids
)
completion_token_ids = tokenizer(
completions,
truncation=True,
max_length=min(tokenizer.model_max_length, self.truncate_length),
add_special_tokens=False,
).input_ids
self._tokenized_dataset: List[Tuple[str, List[int], int]] = [] # noqa: UP006
for i in range(len(_dataset)):
if len(prompt_token_ids[i]) < 4 or len(completion_token_ids[i]) < 4:
# Filter out sequences that are too short
continue
self._tokenized_dataset.append(
(prompts[i], prompt_token_ids[i], len(completion_token_ids[i]))
)
def generate_request_records(
self,
input_len: Optional[int],
output_len: Optional[int],
input_len_std: float = 0.0,
output_len_std: float = 0.0,
) -> List[RequestRecord]: # noqa: UP006
if self.apply_chat_template:
assert input_len is None, (
'"--apply-chat-template" is not supported when "--input-len" is specified.'
)
request_records = []
for prompt, input_token_ids, output_length in self._tokenized_dataset:
input_length = len(input_token_ids)
# If the request does not have enough length, discard it.
if input_len is not None and input_length < input_len + 4 * input_len_std:
continue
if input_len is not None:
input_length = round(
float(np.random.normal(loc=input_len, scale=input_len_std, size=1)[0])
)
input_token_ids = input_token_ids[:input_length]
input_truncated = True
else:
input_truncated = False
if output_len is not None:
output_length = round(
float(np.random.normal(loc=output_len, scale=output_len_std, size=1)[0])
)
elif output_length <= 1:
continue
request_records.append(
RequestRecord(
chat_cmpl=ChatCompletionRequest(
messages=[
{
"role": "user",
"content": (
self.tokenizer.decode(input_token_ids)
if input_truncated
else prompt
),
}
],
model="",
max_tokens=output_length,
),
metrics=Metrics(
success=False,
start_time=0,
finish_time=0,
end_to_end_latency_s=0,
input_tokens=len(input_token_ids),
),
)
)
return request_records
class AzureLLMInferenceDataset(Dataset):
"""The dataset class for AzureLLMInference dataset.
Reference: https://github.com/Azure/AzurePublicDataset
"""
timestamp_available: bool = True
def __init__(self, dataset_path: str, tokenizer: AutoTokenizer) -> None:
df = pd.read_csv(dataset_path)
self.tokenizer = tokenizer
# Filter out the conversations with less than 2 turns.
self.dataset = [
(
entry["TIMESTAMP"],
min(
entry["ContextTokens"],
tokenizer.model_max_length,
self.truncate_length,
),
min(
entry["GeneratedTokens"],
tokenizer.model_max_length,
self.truncate_length,
),
)
for _, entry in df.iterrows()
if entry["ContextTokens"] >= 4 and entry["GeneratedTokens"] >= 4
]
def generate_request_records(
self,
input_len: Optional[int],
output_len: Optional[int],
input_len_std: float = 0.0,
output_len_std: float = 0.0,
) -> List[RequestRecord]: # noqa: UP006
time_fmt = "%Y-%m-%d %H:%M:%S.%f"
start_time = datetime.strptime(self.dataset[0][0][:-1], time_fmt)
request_records = []
for timestamp, input_length, output_length in self.dataset:
# If the request does not have enough length, discard it.
if input_len is not None and input_length < input_len + 4 * input_len_std:
continue
if input_len is not None:
input_length = round(
float(np.random.normal(loc=input_len, scale=input_len_std, size=1)[0])
)
if output_len is not None:
output_length = round(
float(np.random.normal(loc=output_len, scale=output_len_std, size=1)[0])
)
elif output_length <= 1:
continue
prompt_token_ids = [
random.randint(0, self.tokenizer.vocab_size - 1) for _ in range(input_length)
]
while True:
# Adjust the token ids until the retokenization on the decoded string
# matches the required input length.
prompt = self.tokenizer.decode(prompt_token_ids)
retokenized_token_ids = self.tokenizer.encode(prompt, add_special_tokens=False)
if len(retokenized_token_ids) < input_length:
prompt_token_ids = retokenized_token_ids + [
random.randint(0, self.tokenizer.vocab_size - 1)
for _ in range(input_length - len(retokenized_token_ids))
]
elif len(retokenized_token_ids) > input_length:
prompt_token_ids = retokenized_token_ids[:input_length]
else:
break
time_diff = (datetime.strptime(timestamp[:-1], time_fmt) - start_time).total_seconds()
request_records.append(
RequestRecord(
chat_cmpl=ChatCompletionRequest(
messages=[{"role": "user", "content": prompt}],
model="",
max_tokens=output_length,
),
timestamp=time_diff,
metrics=Metrics(
success=False,
start_time=0,
finish_time=0,
end_to_end_latency_s=0,
input_tokens=input_length,
),
)
)
return request_records
SUPPORTED_DATASET = [
"sharegpt",
"llmperf",
"json-mode-eval",
"loogle",
"react",
"wildchat",
"azure-llm-inference",
]
def create_dataset(args: argparse.Namespace, tokenizer: AutoTokenizer) -> Dataset:
"""Create a dataset instance with regard to the specified dataset kind and file path."""
if args.dataset_path is not None and not isinstance(args.dataset_path, str):
raise TypeError(f"Invalid dataset path {args.dataset_path}. Please use a string.")
if args.dataset is None and args.dataset_path is not None:
# Auto-detect the dataset kind by looking into the dataset path.
if "sharegpt" in args.dataset_path.lower():
args.dataset = "sharegpt"
else:
raise ValueError(
f"Unable to detect the dataset kind from dataset path {args.dataset_path}. "
'Please specify the dataset kind via "--dataset".'
)
if args.dataset == "sharegpt":
if args.dataset_path is None:
raise ValueError(
'ShareGPT dataset requires dataset path. Please specify it with "--dataset-path".'
)
return ShareGPTDataset(args.dataset_path, tokenizer, args.apply_chat_template)
if args.dataset == "llmperf":
if args.dataset_path is None:
raise ValueError(
'LLMPerf dataset requires dataset path. Please specify it with "--dataset-path".'
)
assert args.apply_chat_template is False, (
"LLMPerf dataset does not support applying chat template"
)
return LLMPerfDataset(
args.dataset_path,
(args.num_requests + args.num_warmup_requests) * 4,
tokenizer,
)
if args.dataset == "json-mode-eval":
assert args.apply_chat_template is False, (
"JSON mode evaluation does not support applying chat template"
)
return JSONModeEvalDataset(tokenizer)
if args.dataset == "loogle":
if args.dataset_path is None:
raise ValueError(
'Loogle dataset requires a testset name. Please specify it with "--dataset-path".'
)
assert args.apply_chat_template is False, (
"Loogle dataset does not support applying chat template"
)
return LoogleDataset(tokenizer, testset_name=args.dataset_path)
if args.dataset == "react":
if args.dataset_path is None:
raise ValueError(
'ReAct dataset requires dataset path. Please specify it with "--dataset-path".'
)
assert args.apply_chat_template is False, (
"ReAct dataset does not support applying chat template"
)
return ReActDataset(args.dataset_path, tokenizer)
if args.dataset == "wildchat":
return WildChatDataset(tokenizer, args.apply_chat_template)
if args.dataset == "azure-llm-inference":
if args.dataset_path is None:
raise ValueError(
"AzureLLMInference dataset requires dataset path. "
'Please specify it with "--dataset-path".'
)
assert args.apply_chat_template is False, (
"AzureLLMInference dataset does not support applying chat template"
)
return AzureLLMInferenceDataset(args.dataset_path, tokenizer)
raise ValueError(f"Unrecognized dataset {args.dataset}")
+315
View File
@@ -0,0 +1,315 @@
"""Eval GSM8K with MLCEngine."""
import argparse
import asyncio
import json
import random
import re
from datetime import datetime
from pathlib import Path
from typing import List, Literal, Optional # noqa: UP035
import tqdm
from mlc_llm import AsyncMLCEngine
DEVICES = ["cuda", "rocm", "metal", "vulkan"]
ANSWER_TRIGGER = "The answer is"
INVALID_ANS = "[invalid]"
def extract_answer(text: str, regex: re.Pattern, select_index: int) -> str:
"""Extract the answer from the text."""
match_all = regex.findall(text)
if len(match_all) == 0:
return INVALID_ANS
match = match_all[select_index]
if isinstance(match, tuple):
match = next(m for m in match if m)
match_str: str = match.strip()
match_str = match_str.lstrip("$").rstrip(".").replace(",", "")
return match_str
def extract_ground_truth(text: str) -> str:
"""Extract the ground truth from the text."""
return extract_answer(text, re.compile(r"#### (\-?[0-9\.\,]+)"), 0)
def strict_extract_answer(text: str) -> str:
"""Strictly extract the answer from the text."""
return extract_answer(text, re.compile(r"The answer is \$?(\-?[0-9\.\,]+)."), 0)
def flexible_extract_answer(text: str) -> str:
"""Extract the last number from the text."""
return extract_answer(text, re.compile(r"(-?[$0-9.,]{2,})|(-?[0-9]+)"), -1)
def create_few_shot_prompt(n_shot: int, use_cot: bool, random_order=False) -> str:
"""
Create a prompt for the few-shot learning task.
Note
----
The examples are taken from the paper https://arxiv.org/pdf/2201.11903.pdf page 35.
"""
question, chain, answer = [], [], []
question.append(
"There are 15 trees in the grove. "
"Grove workers will plant trees in the grove today. "
"After they are done, there will be 21 trees. "
"How many trees did the grove workers plant today?"
)
chain.append(
"There are 15 trees originally. "
"Then there were 21 trees after some more were planted. "
"So there must have been 21 - 15 = 6."
)
answer.append("6")
question.append(
"If there are 3 cars in the parking lot and 2 more cars arrive, "
"how many cars are in the parking lot?"
)
chain.append("There are originally 3 cars. 2 more cars arrive. 3 + 2 = 5.")
answer.append("5")
question.append(
"Leah had 32 chocolates and her sister had 42. If they ate 35, "
"how many pieces do they have left in total?"
)
chain.append(
"Originally, Leah had 32 chocolates. "
"Her sister had 42. So in total they had 32 + 42 = 74. "
"After eating 35, they had 74 - 35 = 39."
)
answer.append("39")
question.append(
"Jason had 20 lollipops. He gave Denny some lollipops. Now Jason "
"has 12 lollipops. How many lollipops did Jason give to Denny?"
)
chain.append(
"Jason started with 20 lollipops. Then he had 12 after giving some "
"to Denny. So he gave Denny 20 - 12 = 8."
)
answer.append("8")
question.append(
"Shawn has five toys. For Christmas, he got two toys each from his "
"mom and dad. How many toys does he have now?"
)
chain.append(
"Shawn started with 5 toys. If he got 2 toys each from his mom and "
"dad, then that is 4 more toys. 5 + 4 = 9."
)
answer.append("9")
question.append(
"There were nine computers in the server room. Five more computers "
"were installed each day, from monday to thursday. "
"How many computers are now in the server room?"
)
chain.append(
"There were originally 9 computers. For each of 4 days, 5 more "
"computers were added. So 5 * 4 = 20 computers were added. "
"9 + 20 is 29."
)
answer.append("29")
question.append(
"Michael had 58 golf balls. On tuesday, he lost 23 golf balls. On "
"wednesday, he lost 2 more. "
"How many golf balls did he have at the end of wednesday?"
)
chain.append(
"Michael started with 58 golf balls. After losing 23 on tuesday, "
"he had 58 - 23 = 35. After losing 2 more, "
"he had 35 - 2 = 33 golf balls."
)
answer.append("33")
question.append(
"Olivia has $23. She bought five bagels for $3 each. How much money does she have left?"
)
chain.append(
"Olivia had 23 dollars. "
"5 bagels for 3 dollars each will be 5 x 3 = 15 dollars. "
"So she has 23 - 15 dollars left. 23 - 15 is 8."
)
answer.append("8")
index_list = list(range(len(question)))
if random_order:
random.shuffle(index_list)
prompt = ""
for i in index_list[:n_shot]:
if use_cot:
prompt += f"Q: {question[i]}\nA: {chain[i]} {ANSWER_TRIGGER} {answer[i]}.\n\n"
else:
prompt += f"Question: {question[i]}\nAnswer: {ANSWER_TRIGGER} {answer[i]}.\n\n"
return prompt
def create_prompt(question: str, n_shot: int, use_cot: bool, random_order: bool = False) -> str:
"""Create a prompt for the few-shot learning task."""
prompt = create_few_shot_prompt(n_shot, use_cot, random_order)
if use_cot:
prompt += f"Q: {question}\nA:"
else:
prompt += f"Question: {question}\nAnswer:"
return prompt
def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser()
parser.add_argument("--model", type=str, required=True)
parser.add_argument(
"--dataset", type=Path, required=True, help="Path to GSM8K test dataset home."
)
parser.add_argument("--device", type=str, choices=["auto", *DEVICES], default="auto")
parser.add_argument("--model-lib", type=str, default=None)
parser.add_argument("--n-shot", type=int, default=8)
parser.add_argument("--disable_cot", action="store_true", default=False)
parser.add_argument("-bs", "--batch-size", type=int, default=16)
parser.add_argument("--log-dir", type=Path, default=None)
return parser.parse_args()
async def send_request(
async_engine: AsyncMLCEngine,
prompts: List[str], # noqa: UP006
semaphore: asyncio.Semaphore,
):
"""Send the calibration requests to the engine."""
tasks = []
async def generate_task(prompt):
async with semaphore:
return await async_engine.completions.create(
prompt=prompt,
stream=False,
max_tokens=512,
stop=["Q:", "Question:"],
temperature=0.0,
)
for prompt in prompts:
task = asyncio.create_task(generate_task(prompt))
tasks.append(task)
return await tqdm.asyncio.tqdm.gather(*tasks)
async def evaluate(
model: str,
device: str,
dataset: Path,
model_lib: Optional[str],
n_shot: int,
use_cot: bool,
batch_size: int,
log_dir: Optional[Path],
):
"""Evaluate GSM8K for the model."""
mode: Literal["local", "interactive", "server"] = (
"server" if batch_size > 4 else "interactive" if batch_size == 1 else "local"
)
async_engine = AsyncMLCEngine(model, device=device, model_lib=model_lib, mode=mode)
with open(dataset / "test.jsonl", encoding="utf-8") as file:
tests = [json.loads(line) for line in file]
prompts = [create_prompt(test["question"], n_shot, use_cot) for test in tests]
responses = await send_request(async_engine, prompts, asyncio.Semaphore(batch_size))
assert len(responses) == len(tests)
num_strict_correct, num_flexible_correct = 0, 0
num_tests = len(tests)
logs = []
for response, test in zip(responses, tests):
response_text = response.choices[0].text.strip()
gt_answer = extract_ground_truth(test["answer"])
assert gt_answer != INVALID_ANS
strict_answer = strict_extract_answer(response_text)
flexible_answer = flexible_extract_answer(response_text)
if gt_answer == strict_extract_answer(response_text):
# If the answer is exactly the same as the response, then it is correct
num_strict_correct += 1
num_flexible_correct += 1
elif gt_answer == flexible_extract_answer(response_text):
# Try flexible extract if the strict match fails
num_flexible_correct += 1
logs.append(
{
"question": test["question"],
"response": response_text,
"ground_truth": gt_answer,
"strict_answer": strict_answer,
"flexible_answer": flexible_answer,
"strict_match": gt_answer == strict_answer,
"flexible_match": gt_answer == flexible_answer,
}
)
results = {
"config": {
"model": model,
"device": device,
"model_lib": model_lib,
"n_shot": n_shot,
"use_cot": use_cot,
},
"results": {
"strict_match": num_strict_correct,
"flexible_match": num_flexible_correct,
"total": num_tests,
},
}
print(
f"Strict Matching Accuracy: {num_strict_correct} / {num_tests} = "
f"{num_strict_correct / num_tests * 100:.2f}%"
)
print(
f"Flexible Matching Accuracy: {num_flexible_correct} / {num_tests} = "
f"{num_flexible_correct / num_tests * 100:.2f}%"
)
if log_dir:
with open(log_dir / "summary.json", "w", encoding="utf-8") as f:
json.dump(results, f, indent=2)
with open(log_dir / "logs.json", "w", encoding="utf-8") as f:
json.dump(logs, f, indent=2)
if __name__ == "__main__":
args = parse_args()
start_time = datetime.now()
log_dir: Optional[Path] = None
if args.log_dir is not None:
time_dir = start_time.strftime("%Y-%m-%d_%H-%M-%S")
log_dir = args.log_dir / time_dir
log_dir.mkdir(parents=True, exist_ok=True)
asyncio.run(
evaluate(
model=args.model,
device=args.device,
dataset=args.dataset,
model_lib=args.model_lib,
n_shot=args.n_shot,
use_cot=not args.disable_cot,
batch_size=args.batch_size,
log_dir=log_dir,
)
)
end_time = datetime.now()
print(f"Time used: {end_time - start_time}")
+245
View File
@@ -0,0 +1,245 @@
"""Eval MMLU with MLCEngine."""
import argparse
import asyncio
import csv
import json
import string
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional # noqa: UP035
import numpy as np
import tqdm
from mlc_llm import AsyncMLCEngine
SUBJECTS = [
"abstract_algebra",
"anatomy",
"astronomy",
"business_ethics",
"clinical_knowledge",
"college_biology",
"college_chemistry",
"college_computer_science",
"college_mathematics",
"college_medicine",
"college_physics",
"computer_security",
"conceptual_physics",
"econometrics",
"electrical_engineering",
"elementary_mathematics",
"formal_logic",
"global_facts",
"high_school_biology",
"high_school_chemistry",
"high_school_computer_science",
"high_school_european_history",
"high_school_geography",
"high_school_government_and_politics",
"high_school_macroeconomics",
"high_school_mathematics",
"high_school_microeconomics",
"high_school_physics",
"high_school_psychology",
"high_school_statistics",
"high_school_us_history",
"high_school_world_history",
"human_aging",
"human_sexuality",
"international_law",
"jurisprudence",
"logical_fallacies",
"machine_learning",
"management",
"marketing",
"medical_genetics",
"miscellaneous",
"moral_disputes",
"moral_scenarios",
"nutrition",
"philosophy",
"prehistory",
"professional_accounting",
"professional_law",
"professional_medicine",
"professional_psychology",
"public_relations",
"security_studies",
"sociology",
"us_foreign_policy",
"virology",
"world_religions",
]
PADDING_LEN = max(len(subject) for subject in SUBJECTS)
DEVICES = ["cuda", "rocm", "metal", "vulkan"]
PROMPT_TEMPLATE = string.Template("$Q\nA. $A\nB. $B\nC. $C\nD. $D\nAnswer:")
def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser()
parser.add_argument("--model", type=str, required=True)
parser.add_argument(
"--dataset", type=Path, required=True, help="Path to MMLU test dataset home."
)
parser.add_argument("--device", type=str, choices=["auto", *DEVICES], default="auto")
parser.add_argument("--model-lib", type=str, default=None)
parser.add_argument("-s", "--subject", nargs="+", type=str, choices=SUBJECTS, default=SUBJECTS)
parser.add_argument("-bs", "--batch-size", type=int, default=16)
parser.add_argument("--log-dir", type=Path, default=None)
return parser.parse_args()
async def send_request(
async_engine: AsyncMLCEngine,
prompts: List[str], # noqa: UP006
semaphore: asyncio.Semaphore,
subject: str,
):
"""Send the calibration requests to the engine."""
tasks = []
async def generate_task(prompt):
async with semaphore:
return await async_engine.completions.create(
prompt=prompt,
stream=False,
max_tokens=1,
temperature=1.0,
logprobs=True,
top_logprobs=5,
)
for prompt in prompts:
task = asyncio.create_task(generate_task(prompt))
tasks.append(task)
return await tqdm.asyncio.tqdm.gather(
*tasks,
desc=f"Running {subject.ljust(PADDING_LEN)}",
bar_format="{desc} {percentage:3.0f}%|{bar}{r_bar}",
)
async def evaluate(
model: str,
device: str,
dataset: Path,
model_lib: Optional[str],
subjects: List[str], # noqa: UP006
semaphore: asyncio.Semaphore,
log_dir: Optional[Path],
):
"""Evaluate MMLU for the model."""
async_engine = AsyncMLCEngine(model, device=device, model_lib=model_lib, mode="server")
results: Dict[str, Any] = {} # noqa: UP006
for subject in subjects:
with open(dataset / "test" / f"{subject}_test.csv", encoding="utf-8") as csvfile:
tests = list(csv.reader(csvfile, delimiter=",", quotechar='"'))
assert all(len(test) == 6 for test in tests)
logs = []
num_correct = 0
prompts = [
PROMPT_TEMPLATE.substitute(Q=test[0], A=test[1], B=test[2], C=test[3], D=test[4])
for test in tests
]
responses = await send_request(async_engine, prompts, semaphore, subject)
assert len(responses) == len(tests)
for response, test in zip(responses, tests):
token_logprobs = {}
logprobs = response.choices[0].logprobs.content[0].top_logprobs
for logprob in logprobs:
if logprob.token not in token_logprobs:
token_logprobs[logprob.token] = logprob.logprob
abcd_logprobs = {}
for choice in ["A", "B", "C", "D"]:
abcd_logprobs[choice] = token_logprobs[choice] if choice in token_logprobs else -100
pred = {0: "A", 1: "B", 2: "C", 3: "D"}[int(np.argmax(list(abcd_logprobs.values())))]
num_correct += pred == test[5]
logs.append(
{
"Question": {
"Q": test[0],
"A": test[1],
"B": test[2],
"C": test[3],
"D": test[4],
},
"Answer": test[5],
"Response": {
"pred": pred,
"logprobs": list(abcd_logprobs.values()),
},
}
)
results[subject] = {
"correct": num_correct,
"total": len(tests),
"accuracy": num_correct / len(tests),
}
if log_dir:
with open(log_dir / "subjects" / f"{subject}.json", "w", encoding="utf-8") as f:
json.dump(logs, f, indent=2)
total_correct, total_tests = 0, 0
for subject, v in results.items():
num_correct, num_tests, accuracy = v["correct"], v["total"], v["accuracy"]
print(f"{subject}: {num_correct} / {num_tests} = {accuracy * 100:.2f}%")
total_correct += num_correct
total_tests += num_tests
total_accuracy = total_correct / total_tests
results["total"] = {
"correct": total_correct,
"total": total_tests,
"accuracy": total_accuracy,
}
print(f"Total accuracy: {total_correct} / {total_tests} = {total_accuracy * 100:.2f}%")
if log_dir:
results = {
"config": {
"model": model,
"device": device,
"model_lib": model_lib,
"subjects": subjects,
},
"results": results,
}
with open(log_dir / "summary.json", "w", encoding="utf-8") as f:
json.dump(results, f, indent=2)
if __name__ == "__main__":
args = parse_args()
start_time = datetime.now()
log_dir: Optional[Path] = None
if args.log_dir is not None:
time_dir = start_time.strftime("%Y-%m-%d_%H-%M-%S")
log_dir = args.log_dir / time_dir
(log_dir / "subjects").mkdir(parents=True, exist_ok=True)
asyncio.run(
evaluate(
model=args.model,
device=args.device,
dataset=args.dataset,
model_lib=args.model_lib,
subjects=args.subject,
semaphore=asyncio.Semaphore(args.batch_size),
log_dir=log_dir,
)
)
end_time = datetime.now()
print(f"Time used: {end_time - start_time}")
+740
View File
@@ -0,0 +1,740 @@
"""MLC LLM Bench Request"""
import argparse
import asyncio
import concurrent.futures
import copy
import os
import random
import time
from typing import Any, Callable, Dict, List, Optional # noqa: UP035
import numpy as np
import requests
from tqdm import tqdm
from transformers import AutoTokenizer
from mlc_llm.bench.api_endpoint import APIEndPoint
from mlc_llm.bench.dataset import Dataset
from mlc_llm.bench.request_record import GroupedRequestRecord, RequestRecord
from mlc_llm.protocol.openai_api_protocol import (
ChatCompletionMessage,
ChatCompletionRequest,
DebugConfig,
)
from mlc_llm.support import logging
logger = logging.getLogger(__name__)
class RequestProcessor:
"""The request processor base class.
Each processor can take a list of RequestRecord, applying the process,
and returning the processed RequestRecord in the end.
"""
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
raise NotImplementedError()
class LogMessage(RequestProcessor):
"""The processor that prints the logger message."""
def __init__(self, message: str) -> None:
self.message = message
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
logger.info(self.message)
return request_records
class SampleRequests(RequestProcessor):
"""The processor that samples requests out from the given request list."""
def __init__(self, num_requests: int, take_first_x_requests: bool = False) -> None:
self.num_requests = num_requests
# If `take_first_x_requests` is True, the first `num_requests` requests
# are returned and sampling will not happen.
self.take_first_x_requests = take_first_x_requests
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
assert len(request_records) > 0, "Empty input request record."
# We expect the input request records to be all grouped or all plain.
if isinstance(request_records[0], GroupedRequestRecord):
assert all(isinstance(record, GroupedRequestRecord) for record in request_records)
return self._sample_from_grouped_request_records(request_records)
assert all(not isinstance(record, GroupedRequestRecord) for record in request_records)
return self._sample_from_plain_request_records(request_records)
def _sample_from_plain_request_records(
self,
request_records: List[RequestRecord], # noqa: UP006
) -> List[RequestRecord]: # noqa: UP006
samples: List[RequestRecord] = [] # noqa: UP006
if self.take_first_x_requests:
if len(request_records) < self.num_requests:
raise ValueError(
f"Insufficient requests. Requiring {self.num_requests} requests "
f"but only {len(request_records)} are available."
)
samples = copy.deepcopy(list(request_records[: self.num_requests]))
else:
while len(samples) < self.num_requests:
# Create a new list so that the in-place shuffle does not mutate the input list.
records = list(request_records)
random.shuffle(records)
samples += copy.deepcopy(records)
samples = samples[: self.num_requests]
for i, record in enumerate(samples):
record.request_id = i
return samples
def _sample_from_grouped_request_records(
self,
grouped_request_records: List[GroupedRequestRecord], # noqa: UP006
) -> List[RequestRecord]: # noqa: UP006
num_total_available_requests = sum(
len(record.records) for record in grouped_request_records
)
if self.num_requests > num_total_available_requests:
raise ValueError(
"Due to the existence of shared common prefixes, we do not allow "
"benchmarking with requests more than the available requests in the dataset. "
f"The required number of requests {self.num_requests} exceeds the "
f"number of total available requests {num_total_available_requests}."
)
# Create a new list so that the in-place shuffle does not mutate the input list.
records = list(grouped_request_records)
if not self.take_first_x_requests:
random.shuffle(records)
remaining = self.num_requests
samples: List[RequestRecord] = [] # noqa: UP006
for grouped_request_record in grouped_request_records:
num_used_requests = min(len(grouped_request_record.records), remaining)
samples += grouped_request_record.records[:num_used_requests]
remaining -= num_used_requests
if remaining == 0:
break
for i, record in enumerate(samples):
record.request_id = i
return samples
class AttachModelName(RequestProcessor):
"""The processor that attaches model name to requests."""
def __init__(self, model: str) -> None:
self.model = model
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
for request_record in request_records:
request_record.chat_cmpl.model = self.model
return request_records
class AttachRequestRateTimestamp(RequestProcessor):
"""The processor that applies timestamps to the requests."""
def __init__(self, request_rate: np.float32) -> None:
self.request_rate = request_rate
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
timestamp = 0.0
for request_record in request_records:
assert request_record.timestamp is None, "The request record already has a timestamp"
request_record.timestamp = timestamp
timestamp += float(np.random.exponential(1.0 / self.request_rate))
return request_records
class AttachExecutionFeature(RequestProcessor):
"""The processor that attaches execution features to all requests"""
def __init__(self, exec_feature: Dict[str, Any]) -> None: # noqa: UP006
self.exec_feature = exec_feature
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
for request_record in request_records:
assert request_record.metrics is not None
request_record.metrics.exec_feature = self.exec_feature
return request_records
class AttachStreamFlag(RequestProcessor):
"""The processor that attaches the stream flag to the requests."""
def __init__(self, stream: Optional[bool]) -> None:
self.stream = stream
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
if self.stream is None:
return request_records
for request_record in request_records:
request_record.chat_cmpl.stream = self.stream
return request_records
class AttachSamplingOptions(RequestProcessor):
"""The processor that attaches the stream flag to the requests."""
def __init__(self, temperature: float, top_p: float, ignore_eos: bool) -> None:
self.temperature = temperature
self.top_p = top_p
self.ignore_eos = ignore_eos
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
for request_record in request_records:
request_record.chat_cmpl.temperature = self.temperature
request_record.chat_cmpl.top_p = self.top_p
request_record.chat_cmpl.frequency_penalty = 0.0
request_record.chat_cmpl.presence_penalty = 0.0
request_record.chat_cmpl.tool_choice = "none"
if self.ignore_eos:
request_record.chat_cmpl.debug_config = DebugConfig(ignore_eos=True)
return request_records
class ScaleTimestamp(RequestProcessor):
"""Scale the timestamp of requests by the given scale factor."""
def __init__(self, timestamp_scale: float):
self.timestamp_scale = timestamp_scale
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
for request_record in request_records:
if request_record.timestamp is None:
raise ValueError(
f"The timestamp of request {request_record} has not been initialized."
)
request_record.timestamp *= self.timestamp_scale
return request_records
class MetricAnalyzer(RequestProcessor):
"""The processor that analyzes the raw benchmark results and computes more detailed metrics."""
def __init__(self, tokenizer: AutoTokenizer) -> None:
self.tokenizer = tokenizer
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
updated_records = []
for request_record in request_records:
metrics = request_record.metrics
if not metrics.success:
assert request_record.error_msg is not None
continue
metrics.output_tokens = len(
self.tokenizer.encode(request_record.output_str, add_special_tokens=False)
)
first_chunk_output_tokens = len(
self.tokenizer.encode(
request_record.first_chunk_output_str, add_special_tokens=False
)
)
if metrics.output_tokens <= first_chunk_output_tokens:
metrics.success = False
request_record.error_msg = (
f"Total output token num ({metrics.output_tokens}) equals "
f'the first chunk output token. Output text "{request_record.output_str}", '
f'first chunk output text "{request_record.first_chunk_output_str}"'
)
continue
assert metrics.input_tokens > 0, "Invalid prompt tokens"
metrics.inter_token_latency_s = metrics.end_to_end_latency_s / metrics.output_tokens
if metrics.time_to_first_token_s is None:
metrics.time_to_first_token_s = 0
metrics.time_per_output_token_s = (
metrics.end_to_end_latency_s - metrics.time_to_first_token_s
) / (metrics.output_tokens - first_chunk_output_tokens)
updated_records.append(request_record)
return updated_records
class WarmupAndRun(RequestProcessor):
"""The processor that runs warmup first and then runs the benchmark with the given pipeline."""
def __init__(
self,
num_warmup_requests: int,
num_benchmark_requests: int,
pipeline: RequestProcessor,
cuda_profile_url: Optional[str],
fake_warmup: bool = False,
) -> None:
self.num_warmup_requests = num_warmup_requests
self.num_benchmark_requests = num_benchmark_requests
self.pipeline = pipeline
self.cuda_profile_url = cuda_profile_url
self.fake_warmup = fake_warmup
def generate_fake_warmup_requests(
self, num_warmup_requests: int, example_request: RequestRecord
) -> List[RequestRecord]: # noqa: UP006
records = []
for _ in range(num_warmup_requests):
record = copy.deepcopy(example_request)
record.chat_cmpl = ChatCompletionRequest(
messages=[
{
"role": "user",
"content": "Please output arbitrary coherent sentences. Do not output eos token.", # noqa: E501
}
],
model="",
max_tokens=128,
)
records.append(record)
return records
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
# Warmup
if self.fake_warmup:
assert len(request_records) == self.num_benchmark_requests
benchmark_requests = request_records
example_request = benchmark_requests[0]
warmup_requests = self.generate_fake_warmup_requests(
self.num_warmup_requests, example_request=example_request
)
else:
assert len(request_records) == self.num_warmup_requests + self.num_benchmark_requests
benchmark_requests = request_records[: -self.num_warmup_requests]
warmup_requests = request_records[-self.num_warmup_requests :]
for request_record in warmup_requests:
request_record.timestamp = 0 if request_record.timestamp is not None else None
warmup_requests = self._process_warmup_requests(warmup_requests)
logger.info("Warmup with %d request(s)...", self.num_warmup_requests)
self.pipeline(warmup_requests)
# Then run benchmark
if self.cuda_profile_url is not None:
cuda_profiler_start_url = self.cuda_profile_url + "/debug/cuda_profiler_start"
cuda_profiler_start_response = requests.post(cuda_profiler_start_url, timeout=60)
assert cuda_profiler_start_response.status_code == 200
logger.info("Warmup finished. Start benchmarking...")
updated_request_records = self.pipeline(benchmark_requests)
if self.cuda_profile_url is not None:
cuda_profiler_stop_url = self.cuda_profile_url + "/debug/cuda_profiler_stop"
cuda_profiler_stop_response = requests.post(cuda_profiler_stop_url, timeout=60)
assert cuda_profiler_stop_response.status_code == 200
return updated_request_records
def _process_warmup_requests(self, warmup_requests: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
if len(warmup_requests) == 0:
return warmup_requests
# NOTE: to warm up the server for as more different batch sizes as possible,
# we usese 128 output tokens for the first request and use two more tokens
# for every followup request.
# Setting a high temperature and top-p to avoid early stop as much as possible.
warmup_requests[0].chat_cmpl.max_tokens = 128
for i in range(1, len(warmup_requests)):
warmup_requests[i].chat_cmpl.max_tokens = (
warmup_requests[i - 1].chat_cmpl.max_tokens + 1
)
warmup_requests[i].chat_cmpl.temperature = 2.0
warmup_requests[i].chat_cmpl.top_p = 1.0
return warmup_requests
class SequentialProcessor(RequestProcessor):
"""The processor that sequentially applies a list of processors in order."""
processors: List[RequestProcessor] # noqa: UP006
def __init__(self, *processors: RequestProcessor) -> None:
self.processors = list(processors)
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
for processor in self.processors:
request_records = processor(request_records)
return request_records
class Executor(RequestProcessor):
"""The executor base class, denoting the kind of benchmark mode."""
def __init__(
self,
f_create_api_endpoint: Callable[[], APIEndPoint],
num_processes: int,
disable_tqdm: bool,
) -> None:
self.f_create_api_endpoint = f_create_api_endpoint
self.disable_tqdm = disable_tqdm
self.num_processes = num_processes
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
raise NotImplementedError()
class FixedConcurrentRequestExecutor(Executor):
"""The benchmark executor of fixing the number of concurrent requests."""
def __init__(
self,
f_create_api_endpoint: Callable[[], APIEndPoint],
num_processes: Optional[int],
disable_tqdm: bool,
num_concurrent_requests: int,
multi_round: bool,
) -> None:
if num_processes is None:
# We assign each process at most 32 concurrent requests to send
# so that the asyncio pressure will not be too much.
num_processes = min((num_concurrent_requests + 31) // 32, 10)
super().__init__(f_create_api_endpoint, num_processes, disable_tqdm)
self.num_concurrent_requests = num_concurrent_requests
self.multi_round = multi_round
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
partitions: List[List[RequestRecord]] = [ # noqa: UP006
request_records[slice(i, len(request_records), self.num_processes)]
for i in range(self.num_processes)
]
# Package "tokenizers" reports warnings with multiprocessing.
# We disable "TOKENIZERS_PARALLELISM" to depress the warnings.
os.environ["TOKENIZERS_PARALLELISM"] = "false"
pbar = None if self.disable_tqdm else tqdm(total=len(request_records))
with concurrent.futures.ProcessPoolExecutor(max_workers=self.num_processes) as pool:
futures = [
pool.submit(
FixedConcurrentRequestExecutor._process_task,
self.f_create_api_endpoint,
partition,
self.num_concurrent_requests // self.num_processes
+ int(i < self.num_concurrent_requests % self.num_processes),
self.multi_round,
)
for i, partition in enumerate(partitions)
]
results: List[RequestRecord] = [] # noqa: UP006
for i, future in enumerate(concurrent.futures.as_completed(futures)):
results.extend(future.result())
if pbar is not None:
pbar.update(len(partitions[i]))
return results
@staticmethod
def _process_task(
f_create_api_endpoint: Callable[[], APIEndPoint],
request_records: List[RequestRecord], # noqa: UP006
num_concurrent_requests: int,
multi_round: bool,
) -> List[RequestRecord]: # noqa: UP006
if len(request_records) == 0:
return []
chat_history: List[List[ChatCompletionMessage]] = [ # noqa: UP006
[] for _ in range(num_concurrent_requests)
]
async def process_task_impl(
f_create_api_endpoint: Callable[[], APIEndPoint],
request_records: List[RequestRecord], # noqa: UP006
num_concurrent_requests: int,
multi_round: bool,
) -> List[RequestRecord]: # noqa: UP006
api_endpoint = f_create_api_endpoint()
updated_request_records: List[RequestRecord] = [None for _ in request_records] # noqa: UP006
async with api_endpoint:
num_sent_request = 0
async def _task(i: int) -> None:
nonlocal num_sent_request
while True:
if num_sent_request == len(request_records):
break
idx = num_sent_request
num_sent_request += 1
request = request_records[idx]
if multi_round:
request.chat_cmpl.messages = (
chat_history[i] + request.chat_cmpl.messages
)
updated_request_records[idx] = await api_endpoint(request)
if multi_round:
chat_history[i] = [
*updated_request_records[idx].chat_cmpl.messages,
ChatCompletionMessage(
content=updated_request_records[idx].output_str,
role="assistant",
),
]
tasks = [asyncio.create_task(_task(i)) for i in range(num_concurrent_requests)]
await asyncio.gather(*tasks)
return updated_request_records
return asyncio.run(
process_task_impl(
f_create_api_endpoint,
request_records,
num_concurrent_requests,
multi_round,
)
)
class FixTimestampExecutor(Executor):
"""The benchmark executor of fixing the timestamps of sending requests."""
def __init__(
self,
f_create_api_endpoint: Callable[[], APIEndPoint],
num_processes: Optional[int],
disable_tqdm: bool,
max_schedule_gap: float,
num_requests: int,
) -> None:
if num_processes is None:
# We assign each process at most 32 requests to send
# so that the asyncio pressure will not be too much.
num_processes = min((num_requests + 31) // 32, 10)
super().__init__(f_create_api_endpoint, num_processes, disable_tqdm)
self.max_schedule_gap = max_schedule_gap
self.num_requests = num_requests
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
assert len(request_records) > 0
assert all(request_record.timestamp is not None for request_record in request_records)
# Sort the request records in timestamp ascending order before partitioning.
request_records.sort(key=lambda request_record: request_record.timestamp)
base_timestamp = request_records[0].timestamp
partitions: List[List[RequestRecord]] = [ # noqa: UP006
request_records[slice(i, len(request_records), self.num_processes)]
for i in range(self.num_processes)
]
base_sys_time = time.time()
# Package "tokenizers" reports warnings with multiprocessing.
# We disable "TOKENIZERS_PARALLELISM" to depress the warnings.
os.environ["TOKENIZERS_PARALLELISM"] = "false"
pbar = None if self.disable_tqdm else tqdm(total=len(request_records))
with concurrent.futures.ProcessPoolExecutor(max_workers=self.num_processes) as pool:
futures = [
pool.submit(
FixTimestampExecutor._process_task,
self.f_create_api_endpoint,
partition,
base_timestamp,
base_sys_time,
self.max_schedule_gap,
)
for partition in partitions
]
results: List[RequestRecord] = [] # noqa: UP006
for i, future in enumerate(concurrent.futures.as_completed(futures)):
results.extend(future.result())
if pbar is not None:
pbar.update(len(partitions[i]))
return results
@staticmethod
def _process_task(
f_create_api_endpoint: Callable[[], APIEndPoint],
request_records: List[RequestRecord], # noqa: UP006
base_timestamp: float,
base_sys_time: float,
max_schedule_gap: float,
) -> List[RequestRecord]: # noqa: UP006
if len(request_records) == 0:
return []
async def process_task_impl(
f_create_api_endpoint: Callable[[], APIEndPoint],
request_records: List[RequestRecord], # noqa: UP006
base_timestamp: float,
base_sys_time: float,
max_schedule_gap: float,
) -> List[RequestRecord]: # noqa: UP006
api_endpoint = f_create_api_endpoint()
loop = asyncio.get_running_loop()
# Get the delta time to convert system time to the loop time.
# We must use the system time `time.time()` which is consistent across processes.
loop_sys_delta_time = loop.time() - time.time()
updated_request_records: List[RequestRecord] = [] # noqa: UP006
async with api_endpoint:
async def _task(request_record: RequestRecord) -> None:
updated_request_records.append(await api_endpoint(request_record))
tasks = []
for request_record in request_records:
launch_time = (
(request_record.timestamp - base_timestamp)
+ (base_sys_time + max_schedule_gap)
+ loop_sys_delta_time
)
loop.call_at(
launch_time,
lambda record: tasks.append(asyncio.create_task(_task(record))),
request_record,
)
# Sleep to allow runs of other scheduled tasks if any.
await asyncio.sleep(max(launch_time - loop.time() - max_schedule_gap, 0))
# Sleep until all the tasks are launched.
await asyncio.sleep(launch_time - loop.time() + max_schedule_gap)
# Wait for all tasks to be scheduled
assert len(tasks) == len(request_records)
await asyncio.gather(*tasks)
assert len(updated_request_records) == len(request_records)
return updated_request_records
return asyncio.run(
process_task_impl(
f_create_api_endpoint,
request_records,
base_timestamp,
base_sys_time,
max_schedule_gap,
)
)
def create_pipelines(
args: argparse.Namespace,
f_create_api_endpoint: Callable[[], APIEndPoint],
dataset: Dataset,
) -> List[RequestProcessor]: # noqa: UP006
"""Creating request processing pipelines with regard to the specified args."""
cuda_profile_url = f"http://{args.host}:{args.port}" if args.cuda_profile else None
pipelines: List[RequestProcessor] = [] # noqa: UP006
if args.num_concurrent_requests is not None:
if args.request_rate is not None:
raise ValueError(
'Both "num_concurrent_requests" and "request_rate" are specified. '
"Please specify only one of them."
)
if args.replay_timestamp_scale is not None:
raise ValueError(
"Dataset replay is unsupported when fixing number of concurrent requests."
)
for num_concurrent_requests in args.num_concurrent_requests:
num_warmup_requests = (
args.num_warmup_requests
if args.num_warmup_requests is not None
else num_concurrent_requests
)
pipelines.append(
SequentialProcessor(
LogMessage(f"Fixing number of concurrent requests: {num_concurrent_requests}"),
SampleRequests(args.num_requests + num_warmup_requests),
AttachModelName(args.tokenizer),
AttachStreamFlag(args.stream),
AttachSamplingOptions(args.temperature, args.top_p, args.ignore_eos),
AttachExecutionFeature({"num_concurrent_requests": num_concurrent_requests}),
WarmupAndRun(
num_warmup_requests=num_warmup_requests,
num_benchmark_requests=args.num_requests,
pipeline=FixedConcurrentRequestExecutor(
f_create_api_endpoint,
args.num_process_workers,
args.disable_tqdm,
num_concurrent_requests,
args.multi_round,
),
cuda_profile_url=cuda_profile_url,
fake_warmup=dataset.require_fake_warmup,
),
)
)
return pipelines
if args.request_rate is not None:
if args.num_warmup_requests is None:
raise ValueError(
"Please specify the number of warmup requests via "
'"--num-warmup-requests" when fixing request rate.'
)
if args.replay_timestamp_scale is not None:
raise ValueError("Dataset replay is unsupported when fixing request rates.")
num_total_requests = int(
args.num_requests if not args.per_gpu_workload else args.num_requests * args.num_gpus
)
if dataset.require_fake_warmup:
num_samples = num_total_requests
else:
num_samples = num_total_requests + args.num_warmup_requests
return [
SequentialProcessor(
LogMessage(f"Fixing request rate: {request_rate}"),
SampleRequests(num_samples),
AttachModelName(args.tokenizer),
AttachRequestRateTimestamp(
request_rate if not args.per_gpu_workload else request_rate * args.num_gpus
),
AttachStreamFlag(args.stream),
AttachSamplingOptions(args.temperature, args.top_p, args.ignore_eos),
AttachExecutionFeature({"request_rate": float(request_rate)}),
WarmupAndRun(
num_warmup_requests=args.num_warmup_requests,
num_benchmark_requests=num_total_requests,
pipeline=FixTimestampExecutor(
f_create_api_endpoint,
args.num_process_workers,
args.disable_tqdm,
args.max_schedule_gap,
args.num_requests,
),
cuda_profile_url=cuda_profile_url,
fake_warmup=dataset.require_fake_warmup,
),
)
for request_rate in args.request_rate
]
# Default: dataset replay mode
# The dataset must come with timestamps.
if not dataset.timestamp_available:
raise ValueError(
"The dataset does not have timestamps, so dataset replay is unsupported. "
'Please specify one of "num_concurrent_requests" '
'and "request_rate".'
)
if args.per_gpu_workload:
raise ValueError("Fixing per-GPU workload is not compatible with dataset replay.")
if args.num_warmup_requests is None:
raise ValueError(
"Please specify the number of warmup requests via "
'"--num-warmup-requests" for dataset replay.'
)
timestamp_scale = args.replay_timestamp_scale or 1.0
if dataset.require_fake_warmup:
num_samples = args.num_requests
else:
num_samples = args.num_requests + args.num_warmup_requests
return [
SequentialProcessor(
LogMessage(f"Dataset replay with time scaling of {timestamp_scale}"),
SampleRequests(num_samples, take_first_x_requests=True),
AttachModelName(args.tokenizer),
ScaleTimestamp(timestamp_scale),
AttachStreamFlag(args.stream),
AttachSamplingOptions(args.temperature, args.top_p, args.ignore_eos),
AttachExecutionFeature({"timestamp_scale": timestamp_scale}),
WarmupAndRun(
num_warmup_requests=args.num_warmup_requests,
num_benchmark_requests=args.num_requests,
pipeline=FixTimestampExecutor(
f_create_api_endpoint,
args.num_process_workers,
args.disable_tqdm,
args.max_schedule_gap,
args.num_requests,
),
cuda_profile_url=cuda_profile_url,
fake_warmup=dataset.require_fake_warmup,
),
)
]
+278
View File
@@ -0,0 +1,278 @@
"""MLC LLM Bench Request"""
from typing import Any, Dict, List, Optional, Tuple, Union # noqa: UP035
import pandas as pd
from pydantic import BaseModel
from mlc_llm.protocol.openai_api_protocol import ChatCompletionRequest
from mlc_llm.support import logging
logger = logging.getLogger(__name__)
class ServerMetrics(BaseModel):
"""The metrics from the server side."""
input_tokens: int
prefill_tokens: int
output_tokens: int
end_to_end_latency_s: float
prefill_tokens_per_s: float
inter_token_latency_s: float
time_per_output_token_s: float
time_to_first_token_s: Optional[float] = None
class Metrics(BaseModel):
"""The list of metric keys"""
success: bool
start_time: float
finish_time: float
end_to_end_latency_s: float
input_tokens: Optional[int] = None
output_tokens: Optional[int] = None
inter_token_latency_s: Optional[float] = None
time_per_output_token_s: Optional[float] = None
time_to_first_token_s: Optional[float] = None
server_metrics: Optional[ServerMetrics] = None
exec_feature: Optional[Dict[str, Any]] = None # noqa: UP006
class RequestRecord(BaseModel):
"""The request records collected from LLM inference requests."""
request_id: Optional[int] = None
chat_cmpl: ChatCompletionRequest
output_str: Optional[str] = None
first_chunk_output_str: str = ""
timestamp: Optional[float] = None
metrics: Optional[Metrics] = None
error_msg: Optional[str] = None
class GroupedRequestRecord(RequestRecord):
"""The data structure for request record groups.
For datasets that have common prefix sharing, the request records
that share a same common prefix will be wrapped in a GroupedRequestRecord
at the beginning.
"""
records: List[RequestRecord] # noqa: UP006
def generate_metrics_summary(
request_records: List[RequestRecord], # noqa: UP006
num_total_requests: int,
num_gpus: int,
) -> Dict[str, Any]: # noqa: UP006
"""Computes summary statistics across all metrics collected.
Return a dictionary as the report.
"""
num_completed_requests = len(request_records)
assert num_completed_requests <= num_total_requests
request_metrics = [record.metrics for record in request_records]
duration = (
max(metrics.finish_time for metrics in request_metrics)
- min(metrics.start_time for metrics in request_metrics)
if num_completed_requests > 0
else 1e-5
)
report = _compute_metrics_statistics(request_metrics)
report["num_gpus"] = num_gpus
report["duration"] = duration
report["num_total_requests"] = num_total_requests
report["num_completed_requests"] = num_completed_requests
report["request_throughput"] = num_completed_requests / duration
total_input_tokens = sum(metric.input_tokens for metric in request_metrics)
total_output_tokens = sum(metric.output_tokens for metric in request_metrics)
report["total_input_tokens"] = total_input_tokens
report["total_output_tokens"] = total_output_tokens
report["input_token_throughput"] = total_input_tokens / duration
report["input_token_throughput_per_gpu"] = report["input_token_throughput"] / num_gpus
report["output_token_throughput"] = total_output_tokens / duration
report["output_token_throughput_per_gpu"] = report["output_token_throughput"] / num_gpus
# Generate the server metrics statistics
server_metrics = [metric.server_metrics for metric in request_metrics if metric.server_metrics]
server_report = _compute_metrics_statistics(server_metrics)
if server_report is not None and len(server_report) > 0:
report["server_metrics"] = server_report
report = {
"exec_feature": (
request_records[0].metrics.exec_feature if num_completed_requests > 0 else None
),
**report,
}
return report
def _compute_metrics_statistics(
metrics: List[Union[Metrics, ServerMetrics]], # noqa: UP006
) -> Dict[str, Any]: # noqa: UP006
"""
Compute the statistics of the metrics.
Parameters
----------
metrics : List[Union[Metrics, ServerMetrics]]
The list of metrics to get the statistics.
Returns
-------
report : Dict
The statistics of the metrics.
"""
if not metrics:
return {}
report: Dict = {} # noqa: UP006
df = pd.DataFrame([metric.model_dump() for metric in metrics])
for key, _ in metrics[0].model_fields.items():
if key in [
"success",
"start_time",
"finish_time",
"server_metrics",
"exec_feature",
]:
continue
if key in df.columns:
series = df[key].dropna()
report[key] = {
"quantiles": {
f"p{int(q * 100)}": v
for q, v in series.quantile([0.25, 0.5, 0.75, 0.9, 0.95, 0.99]).items()
},
"mean": series.mean(),
"min": series.min(),
"max": series.max(),
"stddev": series.std(),
}
return report
def convert_reports_to_df(reports: List[Dict[str, Any]]) -> pd.DataFrame: # noqa: UP006
"""Convert benchmark reports to pandas DataFrame."""
def _flatten_dict(d: Dict[str, Any], parent_key: str = "") -> Dict[str, Any]: # noqa: UP006
items: List[Tuple[str, Any]] = [] # noqa: UP006
for key, value in d.items():
new_key = f"{parent_key}.{key}" if parent_key != "" else key
if isinstance(value, dict):
items.extend(_flatten_dict(value, new_key).items())
else:
items.append((new_key, value))
return dict(items)
return pd.DataFrame([_flatten_dict(report) for report in reports])
def pretty_print_report(report: Dict[str, Any]) -> None: # noqa: UP006
"""Pretty print the metrics report."""
def _print(report: Dict[str, Any], server_metrics: bool): # noqa: UP006
# fmt: off
title = "Benchmark Result"
if server_metrics:
title += " (server side)"
print(f" {title} ".center(50, "="))
if not server_metrics:
print(f"{'Total requests:':<40} {report['num_total_requests']:<10}")
print(f"{'Completed requests:':<40} {report['num_completed_requests']:<10}")
print(f"{'Duration (s):':<40} {report['duration']:<10.2f}")
print(f"{'Num GPUs:':<40} {report['num_gpus']:<10}")
print(f"{'Total input tokens:':<40} {report['total_input_tokens']:<10}")
print(f"{'Total output tokens:':<40} {report['total_output_tokens']:<10}")
print(f"{'Request throughput (req/s):':<40} {report['request_throughput']:<10.2f}")
print(f"{'Input token throughput (tok/s):':<40} {report['input_token_throughput']:<10.2f}") # noqa: E501
print(f"{'Input token throughput per GPU (tok/s):':<40} {report['input_token_throughput_per_gpu']:<10.2f}") # noqa: E501
print(f"{'Output token throughput (tok/s):':<40} {report['output_token_throughput']:<10.2f}") # noqa: E501
print(f"{'Output token throughput per GPU (tok/s):':<40} {report['output_token_throughput_per_gpu']:<10.2f}") # noqa: E501
if report["num_completed_requests"] == 0:
return
ttft = report["time_to_first_token_s"]
print(" Time to First Token (TTFT, ms) ".center(50, "-"))
print(f"{'Mean:':<40} {ttft['mean'] * 1000:<10.2f}")
print(f"{'Stddev:':<40} {ttft['stddev'] * 1000:<10.2f}")
print(f"{'P25:':<40} {ttft['quantiles']['p25'] * 1000:<10.2f}")
print(f"{'P50:':<40} {ttft['quantiles']['p50'] * 1000:<10.2f}")
print(f"{'P75:':<40} {ttft['quantiles']['p75'] * 1000:<10.2f}")
print(f"{'P90:':<40} {ttft['quantiles']['p90'] * 1000:<10.2f}")
print(f"{'P95:':<40} {ttft['quantiles']['p95'] * 1000:<10.2f}")
print(f"{'P99:':<40} {ttft['quantiles']['p99'] * 1000:<10.2f}")
print(f"{'Min:':<40} {ttft['min'] * 1000:<10.2f}")
print(f"{'Max:':<40} {ttft['max'] * 1000:<10.2f}")
tpot = report["time_per_output_token_s"]
print(" Time per Output Token (TPOT, ms) ".center(50, "-"))
print(f"{'Mean:':<40} {tpot['mean'] * 1000:<10.2f}")
print(f"{'Stddev:':<40} {tpot['stddev'] * 1000:<10.2f}")
print(f"{'P25:':<40} {tpot['quantiles']['p25'] * 1000:<10.2f}")
print(f"{'P50:':<40} {tpot['quantiles']['p50'] * 1000:<10.2f}")
print(f"{'P75:':<40} {tpot['quantiles']['p75'] * 1000:<10.2f}")
print(f"{'P90:':<40} {tpot['quantiles']['p90'] * 1000:<10.2f}")
print(f"{'P95:':<40} {tpot['quantiles']['p95'] * 1000:<10.2f}")
print(f"{'P99:':<40} {tpot['quantiles']['p99'] * 1000:<10.2f}")
print(f"{'Min:':<40} {tpot['min'] * 1000:<10.2f}")
print(f"{'Max:':<40} {tpot['max'] * 1000:<10.2f}")
itl = report["inter_token_latency_s"]
print(" Inter-Token Latency (ms) ".center(50, "-"))
print(f"{'Mean:':<40} {itl['mean'] * 1000:<10.2f}")
print(f"{'Stddev:':<40} {itl['stddev'] * 1000:<10.2f}")
print(f"{'P25:':<40} {itl['quantiles']['p25'] * 1000:<10.2f}")
print(f"{'P50:':<40} {itl['quantiles']['p50'] * 1000:<10.2f}")
print(f"{'P75:':<40} {itl['quantiles']['p75'] * 1000:<10.2f}")
print(f"{'P90:':<40} {itl['quantiles']['p90'] * 1000:<10.2f}")
print(f"{'P95:':<40} {itl['quantiles']['p95'] * 1000:<10.2f}")
print(f"{'P99:':<40} {itl['quantiles']['p99'] * 1000:<10.2f}")
print(f"{'Min:':<40} {itl['min'] * 1000:<10.2f}")
print(f"{'Max:':<40} {itl['max'] * 1000:<10.2f}")
e2e_latency = report["end_to_end_latency_s"]
print(" End-to-End Latency (ms) ".center(50, "-"))
print(f"{'Mean:':<40} {e2e_latency['mean'] * 1000:<10.2f}")
print(f"{'Stddev:':<40} {e2e_latency['stddev'] * 1000:<10.2f}")
print(f"{'P25:':<40} {e2e_latency['quantiles']['p25'] * 1000:<10.2f}")
print(f"{'P50:':<40} {e2e_latency['quantiles']['p50'] * 1000:<10.2f}")
print(f"{'P75:':<40} {e2e_latency['quantiles']['p75'] * 1000:<10.2f}")
print(f"{'P90:':<40} {e2e_latency['quantiles']['p90'] * 1000:<10.2f}")
print(f"{'P95:':<40} {e2e_latency['quantiles']['p95'] * 1000:<10.2f}")
print(f"{'P99:':<40} {e2e_latency['quantiles']['p99'] * 1000:<10.2f}")
print(f"{'Min:':<40} {e2e_latency['min'] * 1000:<10.2f}")
print(f"{'Max:':<40} {e2e_latency['max'] * 1000:<10.2f}")
input_tokens = report["input_tokens"]
print(" Input Tokens ".center(50, "-"))
print(f"{'Mean:':<40} {input_tokens['mean']:<1}")
print(f"{'Stddev:':<40} {input_tokens['stddev']:<1}")
print(f"{'P25:':<40} {input_tokens['quantiles']['p25']:<1}")
print(f"{'P50:':<40} {input_tokens['quantiles']['p50']:<1}")
print(f"{'P95:':<40} {input_tokens['quantiles']['p95']:<1}")
print(f"{'Min:':<40} {input_tokens['min']:<1}")
print(f"{'Max:':<40} {input_tokens['max']:<1}")
output_tokens = report["output_tokens"]
print(" Output Tokens ".center(50, "-"))
print(f"{'Mean:':<40} {output_tokens['mean']:<1}")
print(f"{'Stddev:':<40} {output_tokens['stddev']:<1}")
print(f"{'P25:':<40} {output_tokens['quantiles']['p25']:<1}")
print(f"{'P50:':<40} {output_tokens['quantiles']['p50']:<1}")
print(f"{'P95:':<40} {output_tokens['quantiles']['p95']:<1}")
print(f"{'Min:':<40} {output_tokens['min']:<1}")
print(f"{'Max:':<40} {output_tokens['max']:<1}")
print("=" * 50)
# fmt: on
_print(report, server_metrics=False)
if "server_metrics" in report:
_print(report["server_metrics"], server_metrics=True)