Files
wehub-resource-sync 59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:31 +08:00

2061 lines
75 KiB
Python
Executable File

# Copyright (c) 2026 LightSeek Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
r"""Benchmark online serving throughput.
On the server side, launch a TokenSpeed OpenAI-compatible API server:
tokenspeed serve --model <your_model> <engine arguments>
On the client side, run:
tokenspeed bench serve \
--backend <backend or endpoint type. Default 'openai'> \
--label <benchmark result label. Default using backend> \
--model <your_model. Optional, defaults to first model from server> \
--dataset-name <dataset_name. Default 'random'> \
--input-len <general input length. Optional, maps to dataset-specific args> \
--output-len <general output length. Optional, maps to dataset-specific args> \
--request-rate <request_rate. Default inf> \
--num-prompts <num_prompts. Default 1000>
"""
from __future__ import annotations
import argparse
import asyncio
import codecs
import contextlib
import json
import logging
import math
import os
import random
import resource
import ssl
import sys
import time
import traceback
import warnings
from collections.abc import AsyncGenerator, Coroutine
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Literal
from urllib.parse import urlparse
import aiohttp
import numpy as np
import requests
from tqdm.asyncio import tqdm
from transformers import AutoTokenizer, PreTrainedTokenizerBase
from tokenspeed.runtime.utils.env import envs
# Streaming HTTP timeouts. ``total=6h`` keeps the session umbrella generous so
# whole-run benches don't get cut off; the per-socket sub-timeouts catch a
# legitimately stuck stream without false-failing slow legitimate prefills.
#
# ``sock_read`` defaults to 30 minutes — well above the largest TTFT one would
# expect on real hardware (a 64k-context prefill on a single-GPU consumer card
# is still well under 10 minutes) yet far below ``total``, so an indefinitely
# silent socket still surfaces as a ``aiohttp.ServerTimeoutError`` rather than
# blocking the outer ``asyncio.gather`` at high concurrency. Long-haul or
# pathologically large prefill workloads can bump it via env. ``sock_connect``
# is the dial-tone timeout for the TCP handshake itself.
AIOHTTP_TOTAL_TIMEOUT_SEC = float(
os.environ.get("TOKENSPEED_BENCH_TOTAL_TIMEOUT_SEC", str(6 * 60 * 60))
)
AIOHTTP_SOCK_CONNECT_TIMEOUT_SEC = float(
os.environ.get("TOKENSPEED_BENCH_SOCK_CONNECT_TIMEOUT_SEC", "30")
)
AIOHTTP_SOCK_READ_TIMEOUT_SEC = float(
os.environ.get("TOKENSPEED_BENCH_SOCK_READ_TIMEOUT_SEC", str(30 * 60))
)
AIOHTTP_TIMEOUT = aiohttp.ClientTimeout(
total=AIOHTTP_TOTAL_TIMEOUT_SEC,
sock_connect=AIOHTTP_SOCK_CONNECT_TIMEOUT_SEC,
sock_read=AIOHTTP_SOCK_READ_TIMEOUT_SEC,
)
# Per-request hard ceiling so a single misbehaving stream cannot block the
# whole gather. 1h is generous enough for the longest practical decode and
# still bounded for CI / smoke benches. Override via env when running
# unusually long sequences.
PER_REQUEST_TIMEOUT_SEC = float(
os.environ.get("TOKENSPEED_BENCH_PER_REQUEST_TIMEOUT_SEC", str(60 * 60))
)
DEFAULT_NUM_PROMPTS = 1000
MILLISECONDS_TO_SECONDS_CONVERSION = 1000
SHAREGPT_URL = "https://huggingface.co/datasets/anon8231489123/ShareGPT_Vicuna_unfiltered/resolve/main/ShareGPT_V3_unfiltered_cleaned_split.json"
OPENAI_COMPATIBLE_BACKENDS = frozenset({"openai", "tokenspeed"})
logger = logging.getLogger(__name__)
# Type alias: a single float applies to both ISL and OSL; a dict allows
# specifying them independently via ``{"input": ..., "output": ...}``.
RangeRatio = float | dict[str, float]
def _print_section_header(title: str, fill: str) -> None:
print(f"{title:{fill}^50}")
def _print_metric_row(label: str, value: Any, precision: int | None = None) -> None:
formatted_value = (
f"{value:<10}" if precision is None else f"{value:<10.{precision}f}"
)
print(f"{label:<40} {formatted_value}")
class StreamedResponseHandler:
"""Accumulate SSE bytes until complete `data:` messages are available."""
def __init__(self) -> None:
self.buffer = ""
self._decoder = codecs.getincrementaldecoder("utf-8")()
def add_chunk(self, chunk_bytes: bytes) -> list[str]:
self.buffer += self._decoder.decode(chunk_bytes)
messages: list[str] = []
while "\n\n" in self.buffer:
message, self.buffer = self.buffer.split("\n\n", 1)
message = message.strip()
if message:
messages.append(message)
if self.buffer.startswith("data: "):
message_content = self.buffer.removeprefix("data: ").strip()
if message_content == "[DONE]":
messages.append(self.buffer.strip())
self.buffer = ""
elif message_content:
try:
json.loads(message_content)
except json.JSONDecodeError:
pass
else:
messages.append(self.buffer.strip())
self.buffer = ""
return messages
@dataclass
class SampleRequest:
prompt: str
prompt_len: int
expected_output_len: int
multi_modal_data: dict | list[dict] | None = None
lora_request: Any | None = None
request_id: str | None = None
@dataclass
class RequestFuncInput:
"""The input for the request function."""
prompt: str | list[str]
api_url: str
prompt_len: int
output_len: int
model: str
model_name: str | None = None
logprobs: int | None = None
extra_headers: dict | None = None
extra_body: dict | None = None
multi_modal_content: dict | list[dict] | None = None
ignore_eos: bool = False
language: str | None = None
request_id: str | None = None
@dataclass
class RequestFuncOutput:
"""The output of the request function including metrics."""
generated_text: str = ""
success: bool = False
latency: float = 0.0
output_tokens: int = 0
ttft: float = 0.0 # Time to first token
itl: list[float] = field(default_factory=list) # list of inter-token latencies
tpot: float = 0.0 # avg next-token latencies
prompt_len: int = 0
error: str = ""
start_time: float = 0.0
input_audio_duration: float = 0.0 # in seconds
async def await_with_per_request_timeout(
coro: Coroutine[Any, Any, RequestFuncOutput],
*,
prompt_len: int,
pbar: tqdm | None = None,
) -> RequestFuncOutput:
"""Run a request coroutine under :data:`PER_REQUEST_TIMEOUT_SEC`.
Wraps the per-request ``asyncio.wait_for`` so a single stuck stream
cannot deadlock the outer ``asyncio.gather`` in :func:`benchmark`. On
:class:`asyncio.TimeoutError`, returns a standard
:class:`RequestFuncOutput` with ``success=False`` so the gather can
complete and the metrics output reports the failure normally.
"""
try:
return await asyncio.wait_for(coro, timeout=PER_REQUEST_TIMEOUT_SEC)
except asyncio.TimeoutError:
output = RequestFuncOutput()
output.prompt_len = prompt_len
output.success = False
output.error = (
f"per-request timeout {PER_REQUEST_TIMEOUT_SEC:.1f}s "
"(TOKENSPEED_BENCH_PER_REQUEST_TIMEOUT_SEC)"
)
if pbar is not None:
pbar.update(1)
return output
class TaskType(Enum):
GENERATION = "generation"
@dataclass
class BenchmarkMetrics:
completed: int
failed: int
total_input: int
total_output: int
request_throughput: float
request_goodput: float
output_throughput: float
total_token_throughput: float
mean_ttft_ms: float
median_ttft_ms: float
std_ttft_ms: float
percentiles_ttft_ms: list[tuple[float, float]]
mean_tpot_ms: float
median_tpot_ms: float
std_tpot_ms: float
percentiles_tpot_ms: list[tuple[float, float]]
mean_itl_ms: float
median_itl_ms: float
std_itl_ms: float
percentiles_itl_ms: list[tuple[float, float]]
mean_e2el_ms: float
median_e2el_ms: float
std_e2el_ms: float
percentiles_e2el_ms: list[tuple[float, float]]
max_output_tokens_per_s: float
max_concurrent_requests: int
def set_ulimit(target_soft_limit: int = 65535) -> None:
resource_type = resource.RLIMIT_NOFILE
current_soft, current_hard = resource.getrlimit(resource_type)
if current_soft < target_soft_limit:
try:
resource.setrlimit(resource_type, (target_soft_limit, current_hard))
except ValueError as e:
print(f"Fail to set RLIMIT_NOFILE: {e}")
def join_host_port(host: str, port: int) -> str:
return (
f"[{host}]:{port}"
if ":" in host and not host.startswith("[")
else f"{host}:{port}"
)
def _validate_api_url(
api_url: str,
api_name: str,
expected_suffixes: str | set[str],
) -> None:
if isinstance(expected_suffixes, str):
expected_suffixes = {expected_suffixes}
expected_suffixes = {*expected_suffixes, "profile"}
if not api_url.endswith(tuple(expected_suffixes)):
raise ValueError(f"{api_name} URL must end with one of: {expected_suffixes}.")
def _update_payload_common(
payload: dict[str, Any],
request_func_input: RequestFuncInput,
) -> None:
if request_func_input.ignore_eos:
payload["ignore_eos"] = request_func_input.ignore_eos
if request_func_input.extra_body:
payload.update(request_func_input.extra_body)
def _update_headers_common(
headers: dict[str, Any],
request_func_input: RequestFuncInput,
) -> None:
if request_func_input.extra_headers:
headers |= request_func_input.extra_headers
if request_func_input.request_id:
headers["x-request-id"] = request_func_input.request_id
def _get_headers(content_type: str | None = None) -> dict[str, str]:
headers = {}
if content_type:
headers["Content-Type"] = content_type
api_key = os.environ.get("OPENAI_API_KEY")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
return headers
async def async_request_openai_completions(
request_func_input: RequestFuncInput,
session: aiohttp.ClientSession,
pbar: tqdm | None = None,
) -> RequestFuncOutput:
"""The async request function for the OpenAI Completions API.
Args:
request_func_input: The input for the request function.
pbar: The progress bar to display the progress.
Returns:
The output of the request function.
"""
api_url = request_func_input.api_url
_validate_api_url(api_url, "OpenAI Completions API", "completions")
payload = {
"model": (
request_func_input.model_name
if request_func_input.model_name
else request_func_input.model
),
"prompt": request_func_input.prompt,
"repetition_penalty": 1.0,
"max_tokens": request_func_input.output_len,
"logprobs": request_func_input.logprobs,
"stream": True,
"stream_options": {
"include_usage": True,
},
}
_update_payload_common(payload, request_func_input)
headers = _get_headers()
_update_headers_common(headers, request_func_input)
output = RequestFuncOutput()
output.prompt_len = request_func_input.prompt_len
generated_text = ""
st = time.perf_counter()
output.start_time = st
most_recent_timestamp = st
try:
async with session.post(url=api_url, json=payload, headers=headers) as response:
if response.status == 200:
first_chunk_received = False
handler = StreamedResponseHandler()
async for chunk_bytes in response.content.iter_any():
chunk_bytes = chunk_bytes.strip()
if not chunk_bytes:
continue
messages = handler.add_chunk(chunk_bytes)
for message in messages:
if message.startswith(":"):
continue
chunk = message.removeprefix("data: ")
if chunk != "[DONE]":
data = json.loads(chunk)
if choices := data.get("choices"):
text = choices[0].get("text")
timestamp = time.perf_counter()
if not first_chunk_received:
first_chunk_received = True
ttft = time.perf_counter() - st
output.ttft = ttft
else:
output.itl.append(timestamp - most_recent_timestamp)
most_recent_timestamp = timestamp
generated_text += text or ""
elif usage := data.get("usage"):
output.output_tokens = usage.get("completion_tokens")
if (pt := usage.get("prompt_tokens")) is not None:
output.prompt_len = pt
if first_chunk_received:
output.success = True
else:
output.success = False
output.error = (
"Never received a valid chunk to calculate TTFT."
"This response will be marked as failed!"
)
output.generated_text = generated_text
output.latency = most_recent_timestamp - st
else:
output.error = response.reason or ""
output.success = False
except Exception:
output.success = False
exc_info = sys.exc_info()
output.error = "".join(traceback.format_exception(*exc_info))
if pbar:
pbar.update(1)
return output
def _get_chat_content(
request_func_input: RequestFuncInput,
mm_position: Literal["first", "last"] = "last",
) -> list[dict[str, Any]]:
text_contents = [{"type": "text", "text": request_func_input.prompt}]
mm_contents = []
if request_func_input.multi_modal_content:
mm_content = request_func_input.multi_modal_content
if isinstance(mm_content, list):
mm_contents.extend(request_func_input.multi_modal_content)
elif isinstance(mm_content, dict):
mm_contents.append(request_func_input.multi_modal_content)
else:
raise TypeError(
"multi_modal_content must be a dict or list[dict] for openai-chat"
)
if mm_position == "first":
return mm_contents + text_contents
return text_contents + mm_contents
async def async_request_openai_chat_completions(
request_func_input: RequestFuncInput,
session: aiohttp.ClientSession,
pbar: tqdm | None = None,
mm_position: Literal["first", "last"] = "last",
) -> RequestFuncOutput:
api_url = request_func_input.api_url
_validate_api_url(api_url, "OpenAI Chat Completions API", "chat/completions")
content = _get_chat_content(request_func_input, mm_position=mm_position)
payload = {
"model": (
request_func_input.model_name
if request_func_input.model_name
else request_func_input.model
),
"messages": [
{"role": "user", "content": content},
],
"max_completion_tokens": request_func_input.output_len,
"stream": True,
"stream_options": {
"include_usage": True,
},
}
_update_payload_common(payload, request_func_input)
headers = _get_headers("application/json")
_update_headers_common(headers, request_func_input)
output = RequestFuncOutput()
output.prompt_len = request_func_input.prompt_len
generated_text = ""
ttft = 0.0
st = time.perf_counter()
output.start_time = st
most_recent_timestamp = st
try:
async with session.post(url=api_url, json=payload, headers=headers) as response:
if response.status == 200:
handler = StreamedResponseHandler()
async for chunk_bytes in response.content.iter_any():
chunk_bytes = chunk_bytes.strip()
if not chunk_bytes:
continue
messages = handler.add_chunk(chunk_bytes)
for message in messages:
if message.startswith(":"):
continue
chunk = message.removeprefix("data: ")
if chunk != "[DONE]":
timestamp = time.perf_counter()
data = json.loads(chunk)
if choices := data.get("choices"):
content = choices[0]["delta"].get("content")
if ttft == 0.0:
ttft = timestamp - st
output.ttft = ttft
else:
output.itl.append(timestamp - most_recent_timestamp)
generated_text += content or ""
elif usage := data.get("usage"):
output.output_tokens = usage.get("completion_tokens")
if (pt := usage.get("prompt_tokens")) is not None:
output.prompt_len = pt
most_recent_timestamp = timestamp
output.generated_text = generated_text
output.success = True
output.latency = most_recent_timestamp - st
else:
output.error = response.reason or ""
output.success = False
except Exception:
output.success = False
exc_info = sys.exc_info()
output.error = "".join(traceback.format_exception(*exc_info))
if pbar:
pbar.update(1)
return output
ASYNC_REQUEST_FUNCS = {
"openai": async_request_openai_completions,
"tokenspeed": async_request_openai_completions,
"openai-chat": async_request_openai_chat_completions,
}
def get_model(pretrained_model_name_or_path: str) -> str:
if envs.TOKENSPEED_USE_MODELSCOPE.get():
import huggingface_hub.constants
from modelscope import snapshot_download
return snapshot_download(
model_id=pretrained_model_name_or_path,
local_files_only=huggingface_hub.constants.HF_HUB_OFFLINE,
ignore_file_pattern=[".*.pt", ".*.safetensors", ".*.bin"],
)
return pretrained_model_name_or_path
def get_tokenizer(
pretrained_model_name_or_path: str,
) -> PreTrainedTokenizerBase:
if pretrained_model_name_or_path is not None and not os.path.exists(
pretrained_model_name_or_path
):
pretrained_model_name_or_path = get_model(pretrained_model_name_or_path)
return AutoTokenizer.from_pretrained(
pretrained_model_name_or_path, trust_remote_code=True
)
def download_and_cache_file(url: str, filename: str | None = None) -> str:
if filename is None:
filename = os.path.join("/tmp", os.path.basename(urlparse(url).path))
if os.path.exists(filename):
return filename
print(f"Downloading from {url} to {filename}")
response = requests.get(url, stream=True)
response.raise_for_status()
total_size = int(response.headers.get("content-length", 0))
with open(filename, "wb") as f, tqdm(
desc=filename,
total=total_size,
unit="B",
unit_scale=True,
unit_divisor=1024,
) as bar:
for chunk in response.iter_content(chunk_size=1024):
f.write(chunk)
bar.update(len(chunk))
return filename
def is_valid_sequence(
prompt_len: int,
output_len: int,
max_model_len: int | None,
skip_min_tokens_check: bool,
) -> bool:
if not skip_min_tokens_check and (prompt_len < 4 or output_len < 4):
return False
if max_model_len is not None and prompt_len + output_len > max_model_len:
return False
return True
def _resolve_range_ratios(
range_ratio: RangeRatio,
) -> tuple[float, float]:
"""Return ``(input_range_ratio, output_range_ratio)`` from *range_ratio*.
*range_ratio* is either a single float (used for both input and output)
or a dict with ``"input"`` and ``"output"`` keys.
"""
if isinstance(range_ratio, dict):
try:
return float(range_ratio["input"]), float(range_ratio["output"])
except KeyError as exc:
raise ValueError(
"When range_ratio is a dict it must contain 'input' and "
f"'output' keys, got: {sorted(range_ratio)}"
) from exc
ratio = float(range_ratio)
return ratio, ratio
def get_sampling_params(
rng: np.random.Generator,
num_requests: int,
range_ratio: RangeRatio,
input_len: int,
output_len: int,
tokenizer: PreTrainedTokenizerBase,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Sample per-request input/output token lengths and vocab offsets.
Lengths are drawn uniformly from integer ranges around the configured
means, controlled by *range_ratio*. It may be a single ``float``
(applied to both input and output) or a ``dict`` with ``"input"`` and
``"output"`` keys for independent control.
Tokenizer special tokens are subtracted from ``input_len`` before
computing the sampling interval.
Returns:
(input_lens, output_lens, offsets) - three 1-D ``np.ndarray`` of
shape ``(num_requests,)``.
"""
input_range_ratio, output_range_ratio = _resolve_range_ratios(range_ratio)
if not (0.0 <= input_range_ratio < 1.0):
raise ValueError("input_range_ratio must be in [0, 1).")
if not (0.0 <= output_range_ratio < 1.0):
raise ValueError("output_range_ratio must be in [0, 1).")
num_special_tokens = int(tokenizer.num_special_tokens_to_add())
real_input_len = max(0, int(input_len) - num_special_tokens)
input_low = math.floor(real_input_len * (1 - input_range_ratio))
input_high = math.ceil(real_input_len * (1 + input_range_ratio))
output_low = math.floor(output_len * (1 - output_range_ratio))
output_high = math.ceil(output_len * (1 + output_range_ratio))
# Ensure the lower bound for output length is at least 1 to
# prevent sampling 0 tokens.
output_low = max(output_low, 1)
output_high = max(output_high, 1)
if input_low > input_high:
raise ValueError(
f"Invalid input sampling interval: low={input_low} > high={input_high}"
)
if output_low > output_high:
raise ValueError(
f"Invalid output sampling interval: low={output_low} > high={output_high}"
)
logger.info(
"Sampling input_len from [%s, %s] and output_len from [%s, %s]",
input_low,
input_high,
output_low,
output_high,
)
input_lens = rng.integers(input_low, input_high + 1, size=num_requests)
output_lens = rng.integers(output_low, output_high + 1, size=num_requests)
offsets = rng.integers(0, tokenizer.vocab_size, size=num_requests)
return input_lens, output_lens, offsets
def gen_prompt_decode_to_target_len(
tokenizer: PreTrainedTokenizerBase,
token_sequence: list[int],
target_token_len: int,
max_retry: int = 10,
add_special_tokens: bool = False,
rng: np.random.Generator | None = None,
) -> tuple[str, list[int], int]:
"""
Ensure decoded-then-encoded prompt length matches the target token length.
This function decodes an initial token sequence to text and re-encodes it
, iteratively adjusting the token sequence length to match a target.
This is necessary because some tokenizers do not guarantee a 1:1 mapping
between consecutive tokens and the decoded-then-encoded sequence length.
For example, for GPT2Tokenizer:
[6880, 6881] -> ['Ġcalls', 'here'] ->
[1650, 939, 486] -> ['Ġcall', 'sh', 'ere']
Returns a tuple of the final prompt string, the adjusted token sequence,
and the token mismatch (final_len - target_token_len) if the retry budget
is exhausted.
"""
remain_num_try = max_retry
token_mismatch = 0
while True:
prompt = tokenizer.decode(token_sequence)
token_sequence = tokenizer.encode(prompt, add_special_tokens=add_special_tokens)
if remain_num_try <= 0:
if len(token_sequence) != target_token_len:
token_mismatch = len(token_sequence) - target_token_len
break
if len(token_sequence) == target_token_len:
break
elif len(token_sequence) < target_token_len:
if rng is not None:
extra_tokens = rng.integers(
0,
tokenizer.vocab_size,
size=target_token_len - len(token_sequence),
).tolist()
else:
extra_tokens = np.random.randint(
0,
tokenizer.vocab_size,
size=target_token_len - len(token_sequence),
).tolist()
token_sequence.extend(extra_tokens)
elif len(token_sequence) > target_token_len:
token_sequence = token_sequence[:target_token_len]
remain_num_try -= 1
return prompt, token_sequence, token_mismatch
class BenchmarkDataset:
DEFAULT_SEED = 0
def __init__(
self,
dataset_path: str | None = None,
random_seed: int = DEFAULT_SEED,
disable_shuffle: bool = False,
**kwargs,
) -> None:
"""
Initialize the BenchmarkDataset with an optional dataset path and random
seed.
"""
self.dataset_path = dataset_path
self.random_seed = random_seed if random_seed is not None else self.DEFAULT_SEED
self.disable_shuffle = disable_shuffle
self.data: Any | None = None
def get_lora_request(
self,
index: int,
max_loras: int | None = None,
lora_path: str | None = None,
lora_assignment: str = "random",
) -> None:
return None
# fmt: off
class RandomDataset(BenchmarkDataset):
"""
Synthetic text-only dataset for serving/throughput benchmarks.
Strategy:
- Sample input/output token lengths per request from integer-uniform ranges
around configured means (controlled by range_ratio).
- Prepend a fixed random prefix of length prefix_len.
- Generate the remaining tokens as a reproducible sequence:
(offset + index + arange(input_len)) % vocab_size.
- Decode then re-encode/truncate to ensure prompt token counts match.
- Uses numpy.default_rng seeded with random_seed for reproducible sampling.
"""
DEFAULT_PREFIX_LEN = 0
DEFAULT_RANGE_RATIO = 0.0
DEFAULT_INPUT_LEN = 1024
DEFAULT_OUTPUT_LEN = 128
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
# Use numpy's default_rng for deterministic sampling
# Do not use random.seed() or np.random.seed() elsewhere in this class.
# This ensures that the RNG is isolated from global RNG state.
self._rng = np.random.default_rng(self.random_seed)
def sample(
self,
tokenizer: PreTrainedTokenizerBase,
num_requests: int,
request_id_prefix: str = "",
no_oversample: bool = False,
prefix_len: int = DEFAULT_PREFIX_LEN,
range_ratio: RangeRatio = DEFAULT_RANGE_RATIO,
input_len: int = DEFAULT_INPUT_LEN,
output_len: int = DEFAULT_OUTPUT_LEN,
batchsize: int = 1,
max_loras: int | None = None,
lora_path: str | None = None,
lora_assignment: str = "random",
**kwargs,
) -> list[SampleRequest]:
resolved_input_rr, _ = _resolve_range_ratios(range_ratio)
num_special = int(tokenizer.num_special_tokens_to_add())
real_input_len = max(0, int(input_len) - num_special)
min_sampled_input = math.floor(
real_input_len * (1.0 - float(resolved_input_rr))
)
min_total_input = int(prefix_len) + min_sampled_input
if min_total_input < 1:
raise ValueError(
"--random-input-len is too small: with tokenizer special "
f"tokens {num_special} and "
f"input range ratio {resolved_input_rr}, "
"the minimum possible total input tokens (prefix + sampled) is "
f"{min_total_input}. Increase --random-input-len and/or "
"--random-prefix-len, or decrease the input range ratio "
"so that prefix_len + floor(max(0, random_input_len - "
"num_special)) * (1 - input_range_ratio) >= 1."
)
input_lens, output_lens, offsets = get_sampling_params(
self._rng,
num_requests,
range_ratio,
input_len,
output_len,
tokenizer,
)
vocab_size = tokenizer.vocab_size
prohibited_tokens = tokenizer.all_special_ids
all_tokens = np.arange(vocab_size)
allowed_tokens = np.array(list(set(all_tokens) - set(prohibited_tokens)))
# Generate prefix once
prefix_token_ids = self.get_prefix(tokenizer, allowed_tokens, prefix_len)
requests = []
token_mismatch_total = 0
for i in range(num_requests):
prompt, total_input_len, token_mismatch = self.generate_token_sequence( # noqa: E501
tokenizer=tokenizer,
prefix_token_ids=prefix_token_ids,
prefix_len=prefix_len,
vocab_size=vocab_size,
input_len=int(input_lens[i]),
offset=int(offsets[i]),
index=i,
allowed_tokens=allowed_tokens,
)
token_mismatch_total += token_mismatch
lora_req = self.get_lora_request(
index=i,
max_loras=max_loras,
lora_path=lora_path,
lora_assignment=lora_assignment,
)
requests.append(
SampleRequest(
prompt=prompt,
prompt_len=total_input_len,
expected_output_len=int(output_lens[i]),
lora_request=lora_req,
request_id=request_id_prefix + str(i),
)
)
# only used for embeddings benchmark.
if batchsize > 1:
batch_requests = []
# Create batched requests
for i in range(0, num_requests, batchsize):
batch = requests[i : i + batchsize]
batch_requests.append(
SampleRequest(
prompt=[req.prompt for req in batch],
prompt_len=sum(req.prompt_len for req in batch),
expected_output_len=0,
request_id=request_id_prefix + str(i // batchsize),
)
)
requests = batch_requests
if token_mismatch_total != 0:
sign = "more" if token_mismatch_total > 0 else "fewer"
logger.warning(
"Across all generated prompts, there were %d %s tokens "
"than expected after decoding and re-encoding. This is "
"expected due to the imperfect nature of the sampling "
"procedure.",
abs(token_mismatch_total),
sign,
)
return requests
def get_prefix(
self,
tokenizer: PreTrainedTokenizerBase,
allowed_tokens: np.ndarray,
prefix_len: int,
) -> list[int]:
"""
Get the prefix for the dataset.
"""
if prefix_len <= 0:
return []
prefix_tokens = allowed_tokens[
self._rng.integers(0, len(allowed_tokens), size=prefix_len)
].tolist()
_, adjusted_tokens, token_mismatch = gen_prompt_decode_to_target_len(
tokenizer=tokenizer,
token_sequence=prefix_tokens,
target_token_len=prefix_len,
add_special_tokens=False,
rng=self._rng,
)
if token_mismatch != 0:
sign = "more" if token_mismatch > 0 else "fewer"
logger.warning(
"Prefix tokenization produced %d %s tokens than expected "
"after decoding and re-encoding. This is expected due to "
"the imperfect nature of the sampling procedure",
abs(token_mismatch),
sign,
)
return adjusted_tokens
def generate_token_sequence(
self,
*,
tokenizer: PreTrainedTokenizerBase,
prefix_token_ids: list[int],
prefix_len: int,
vocab_size: int,
input_len: int,
offset: int,
index: int,
allowed_tokens: np.ndarray,
) -> tuple[str, int, int]:
"""
Returns (prompt, total_input_len).
NOTE: After decoding the prompt we have to encode and decode it again.
This is done because in some cases N consecutive tokens
give a string tokenized into != N number of tokens.
For example for GPT2Tokenizer:
[6880, 6881] -> ['Ġcalls', 'here'] ->
[1650, 939, 486] -> ['Ġcall', 'sh', 'ere']
To avoid uncontrolled change of the prompt length,
the encoded sequence is truncated before being decoded again.
"""
# Build the inner sequence by sampling
# sequentially from the allowed tokens
inner_seq = allowed_tokens[
(offset + index + np.arange(input_len)) % len(allowed_tokens)
].tolist()
token_sequence = prefix_token_ids + inner_seq
# Decode, then re-encode and truncate to preserve token count invariants
total_input_len = prefix_len + int(input_len)
prompt, adjusted_token_sequence, token_mismatch = (
gen_prompt_decode_to_target_len(
tokenizer=tokenizer,
token_sequence=token_sequence,
target_token_len=total_input_len,
add_special_tokens=False,
rng=self._rng,
)
)
total_input_len = len(adjusted_token_sequence)
return prompt, total_input_len, token_mismatch
# fmt: on
def sample_sharegpt_requests(
dataset_path: str | None,
num_requests: int,
tokenizer: PreTrainedTokenizerBase,
fixed_output_len: int | None = None,
max_model_len: int | None = None,
apply_chat_template: bool = False,
skip_min_tokens_check: bool = False,
) -> list[SampleRequest]:
if fixed_output_len is not None and fixed_output_len < 4:
raise ValueError("output_len too small")
if not dataset_path:
dataset_path = download_and_cache_file(SHAREGPT_URL)
with open(dataset_path, encoding="utf-8") as f:
dataset = json.load(f)
conversations = []
for data in dataset:
turns = data.get("conversations", data.get("conversation", []))
if len(turns) >= 2:
conversations.append((turns[0]["value"], turns[1]["value"]))
random.shuffle(conversations)
samples: list[SampleRequest] = []
for prompt, completion in conversations:
if len(samples) == num_requests:
break
if apply_chat_template:
prompt = tokenizer.apply_chat_template(
[{"role": "user", "content": prompt}],
add_generation_prompt=True,
tokenize=False,
)
if tokenizer.bos_token:
prompt = prompt.replace(tokenizer.bos_token, "")
prompt_len = len(tokenizer.encode(prompt))
output_len = fixed_output_len or len(tokenizer.encode(completion))
if not is_valid_sequence(
prompt_len, output_len, max_model_len, skip_min_tokens_check
):
continue
samples.append(SampleRequest(prompt, prompt_len, output_len))
print(f"#Input tokens: {sum(x.prompt_len for x in samples)}")
print(f"#Output tokens: {sum(x.expected_output_len for x in samples)}")
return samples
def sample_random_requests(
input_len: int,
output_len: int,
num_prompts: int,
range_ratio: float,
tokenizer: PreTrainedTokenizerBase,
dataset_path: str | None,
prefix_len: int = 0,
random_seed: int = 0,
request_id_prefix: str = "",
) -> list[SampleRequest]:
if dataset_path is not None:
raise ValueError("Cannot use 'random' dataset with --dataset-path.")
samples = RandomDataset(random_seed=random_seed).sample(
tokenizer=tokenizer,
num_requests=num_prompts,
request_id_prefix=request_id_prefix,
prefix_len=prefix_len,
range_ratio=range_ratio,
input_len=input_len,
output_len=output_len,
)
print(f"#Input tokens: {sum(x.prompt_len for x in samples)}")
print(f"#Output tokens: {sum(x.expected_output_len for x in samples)}")
return samples
def get_samples(
args: argparse.Namespace, tokenizer: PreTrainedTokenizerBase
) -> list[SampleRequest]:
if args.dataset_name == "sharegpt":
return sample_sharegpt_requests(
dataset_path=args.dataset_path,
num_requests=args.num_prompts,
tokenizer=tokenizer,
fixed_output_len=args.sharegpt_output_len,
max_model_len=args.max_model_len,
apply_chat_template=args.apply_chat_template,
skip_min_tokens_check=args.skip_min_tokens_check,
)
if args.dataset_name == "random":
return sample_random_requests(
input_len=args.random_input_len,
output_len=args.random_output_len,
num_prompts=args.num_prompts,
range_ratio=args.random_range_ratio,
tokenizer=tokenizer,
dataset_path=args.dataset_path,
prefix_len=args.random_prefix_len,
random_seed=args.seed,
request_id_prefix=args.request_id_prefix,
)
raise ValueError(f"Unknown dataset: {args.dataset_name}")
def get_current_request_rate(
ramp_up_strategy: Literal["linear", "exponential"] | None,
ramp_up_start_rps: int | None,
ramp_up_end_rps: int | None,
request_index: int,
total_requests: int,
request_rate: float,
) -> float:
if (
ramp_up_strategy
and ramp_up_start_rps is not None
and ramp_up_end_rps is not None
):
progress = request_index / max(total_requests - 1, 1)
if ramp_up_strategy == "linear":
return ramp_up_start_rps + (ramp_up_end_rps - ramp_up_start_rps) * progress
if ramp_up_strategy == "exponential":
ratio = ramp_up_end_rps / ramp_up_start_rps
return ramp_up_start_rps * (ratio**progress)
raise ValueError(f"Unknown ramp-up strategy: {ramp_up_strategy}")
return request_rate
async def get_request(
input_requests: list[SampleRequest],
request_rate: float,
burstiness: float = 1.0,
ramp_up_strategy: Literal["linear", "exponential"] | None = None,
ramp_up_start_rps: int | None = None,
ramp_up_end_rps: int | None = None,
) -> AsyncGenerator[tuple[SampleRequest, float], None]:
assert (
burstiness > 0
), f"A positive burstiness factor is expected, got {burstiness}."
total_requests = len(input_requests)
assert total_requests > 0, "No requests provided."
delay_ts = []
request_rates = []
for request_index, _request in enumerate(input_requests):
current_request_rate = get_current_request_rate(
ramp_up_strategy,
ramp_up_start_rps,
ramp_up_end_rps,
request_index,
total_requests,
request_rate,
)
assert (
current_request_rate > 0.0
), f"Non-positive request rate {current_request_rate}."
request_rates.append(current_request_rate)
if current_request_rate == float("inf"):
delay_ts.append(0.0)
elif burstiness == float("inf"):
delay_ts.append(1.0 / current_request_rate)
else:
theta = 1.0 / (current_request_rate * burstiness)
delay_ts.append(float(np.random.gamma(shape=burstiness, scale=theta)))
for i in range(1, len(delay_ts)):
delay_ts[i] += delay_ts[i - 1]
if ramp_up_strategy is None and delay_ts[-1] != 0:
target_total_delay_s = total_requests / request_rate
normalize_factor = target_total_delay_s / delay_ts[-1]
delay_ts = [delay * normalize_factor for delay in delay_ts]
start_ts = time.time()
for request_index, request in enumerate(input_requests):
if delay_ts[request_index] > 0:
sleep_interval_s = start_ts + delay_ts[request_index] - time.time()
if sleep_interval_s > 0:
await asyncio.sleep(sleep_interval_s)
yield request, request_rates[request_index]
async def get_first_model_from_server(
base_url: str,
headers: dict[str, str] | None = None,
ssl_context: ssl.SSLContext | bool | None = None,
) -> tuple[str, str]:
connector = aiohttp.TCPConnector(ssl=ssl_context)
async with aiohttp.ClientSession(connector=connector) as session:
models_url = f"{base_url}/v1/models"
try:
async with session.get(models_url, headers=headers) as response:
response.raise_for_status()
data = await response.json()
if data.get("data"):
model = data["data"][0]
return model["id"], model.get("root", model["id"])
raise ValueError(f"No models found on the server at {base_url}.")
except (aiohttp.ClientError, json.JSONDecodeError) as e:
raise RuntimeError(f"Failed to fetch models from {models_url}: {e}") from e
async def wait_for_endpoint(
request_func,
test_input: RequestFuncInput,
session: aiohttp.ClientSession,
timeout_seconds: int = 600,
retry_interval: int = 5,
) -> RequestFuncOutput:
deadline = time.perf_counter() + timeout_seconds
output = RequestFuncOutput(success=False)
print(f"Waiting for endpoint to become up in {timeout_seconds} seconds")
with tqdm(
total=timeout_seconds,
bar_format="{desc} |{bar}| {elapsed} elapsed, {remaining} remaining",
unit="s",
) as pbar:
while True:
remaining = deadline - time.perf_counter()
elapsed = timeout_seconds - remaining
pbar.update(min(elapsed - pbar.n, timeout_seconds - pbar.n))
pbar.refresh()
if remaining <= 0:
break
try:
output = await request_func(test_input, session=session)
if output.success:
return output
err_last_line = str(output.error).rstrip().rsplit("\n", 1)[-1]
print(f"Endpoint is not ready. Error='{err_last_line}'")
except aiohttp.ClientConnectorError:
pass
await asyncio.sleep(min(retry_interval, max(remaining, 0)))
return output
def calculate_metrics(
input_requests: list[SampleRequest],
outputs: list[RequestFuncOutput],
dur_s: float,
tokenizer: PreTrainedTokenizerBase | None,
selected_percentiles: list[float],
goodput_config_dict: dict[str, float],
) -> tuple[BenchmarkMetrics, list[int]]:
actual_output_lens: list[int] = []
total_input = 0
completed = 0
good_completed = 0
itls: list[float] = []
tpots: list[float] = []
all_tpots: list[float] = []
ttfts: list[float] = []
e2els: list[float] = []
for output in outputs:
if output.success:
output_len = output.output_tokens
if not output_len:
output_len = (
len(
tokenizer.encode(
output.generated_text, add_special_tokens=False
)
)
if tokenizer
else 1
)
actual_output_lens.append(output_len)
total_input += output.prompt_len
tpot = 0.0
if output_len > 1:
tpot = (output.latency - output.ttft) / (output_len - 1)
tpots.append(tpot)
all_tpots.append(tpot)
itls.extend(output.itl)
ttfts.append(output.ttft)
e2els.append(output.latency)
completed += 1
else:
actual_output_lens.append(0)
if goodput_config_dict:
valid_metrics = []
slo_values = []
if "ttft" in goodput_config_dict:
valid_metrics.append(ttfts)
slo_values.append(
goodput_config_dict["ttft"] / MILLISECONDS_TO_SECONDS_CONVERSION
)
if "tpot" in goodput_config_dict:
valid_metrics.append(all_tpots)
slo_values.append(
goodput_config_dict["tpot"] / MILLISECONDS_TO_SECONDS_CONVERSION
)
if "e2el" in goodput_config_dict:
valid_metrics.append(e2els)
slo_values.append(
goodput_config_dict["e2el"] / MILLISECONDS_TO_SECONDS_CONVERSION
)
for req_metric in zip(*valid_metrics):
if all(slo >= metric for slo, metric in zip(slo_values, req_metric)):
good_completed += 1
if completed == 0:
warnings.warn(
"All requests failed. This is likely due to a misconfiguration on the benchmark arguments.",
stacklevel=2,
)
successful_outputs = [output for output in outputs if output.success]
failed_outputs = [output for output in outputs if not output.success]
if failed_outputs:
print("Failed requests during benchmark run detected (capping to 10):")
for i, err in enumerate(failed_outputs[:10]):
print(f"Error {i}: {err.error}")
max_output_tokens_per_s = 0.0
max_concurrent_requests = 0
if successful_outputs:
min_start_time = min(output.start_time for output in successful_outputs)
max_end_time = max(
output.start_time + output.latency for output in successful_outputs
)
duration_seconds = int(np.ceil(max_end_time - min_start_time)) + 1
tokens_per_second = np.zeros(duration_seconds)
concurrent_requests_per_second = np.zeros(duration_seconds)
for output in successful_outputs:
token_times = [output.start_time + output.ttft]
current_time = token_times[0]
for itl_value in output.itl:
current_time += itl_value
token_times.append(current_time)
for token_time in token_times:
second_bucket = int(token_time - min_start_time)
if 0 <= second_bucket < duration_seconds:
tokens_per_second[second_bucket] += 1
request_start_second = int(output.start_time - min_start_time)
request_end_second = int(
(output.start_time + output.latency) - min_start_time
)
for second in range(request_start_second, request_end_second + 1):
concurrent_requests_per_second[second] += 1
max_output_tokens_per_s = (
float(np.max(tokens_per_second)) if len(tokens_per_second) else 0.0
)
max_concurrent_requests = (
int(np.max(concurrent_requests_per_second))
if len(concurrent_requests_per_second)
else 0
)
metrics = BenchmarkMetrics(
completed=completed,
failed=len(failed_outputs),
total_input=total_input,
total_output=sum(actual_output_lens),
request_throughput=completed / dur_s,
request_goodput=good_completed / dur_s,
output_throughput=sum(actual_output_lens) / dur_s,
total_token_throughput=(total_input + sum(actual_output_lens)) / dur_s,
mean_ttft_ms=np.mean(ttfts or 0) * 1000,
std_ttft_ms=np.std(ttfts or 0) * 1000,
median_ttft_ms=np.median(ttfts or 0) * 1000,
percentiles_ttft_ms=[
(p, np.percentile(ttfts or 0, p) * 1000) for p in selected_percentiles
],
mean_tpot_ms=np.mean(tpots or 0) * 1000,
std_tpot_ms=np.std(tpots or 0) * 1000,
median_tpot_ms=np.median(tpots or 0) * 1000,
percentiles_tpot_ms=[
(p, np.percentile(tpots or 0, p) * 1000) for p in selected_percentiles
],
mean_itl_ms=np.mean(itls or 0) * 1000,
std_itl_ms=np.std(itls or 0) * 1000,
median_itl_ms=np.median(itls or 0) * 1000,
percentiles_itl_ms=[
(p, np.percentile(itls or 0, p) * 1000) for p in selected_percentiles
],
mean_e2el_ms=np.mean(e2els or 0) * 1000,
std_e2el_ms=np.std(e2els or 0) * 1000,
median_e2el_ms=np.median(e2els or 0) * 1000,
percentiles_e2el_ms=[
(p, np.percentile(e2els or 0, p) * 1000) for p in selected_percentiles
],
max_output_tokens_per_s=max_output_tokens_per_s,
max_concurrent_requests=max_concurrent_requests,
)
return metrics, actual_output_lens
async def benchmark(
task_type: TaskType,
backend: str,
api_url: str,
base_url: str,
model_id: str,
model_name: str | None,
tokenizer: PreTrainedTokenizerBase | None,
input_requests: list[SampleRequest],
logprobs: int | None,
request_rate: float,
burstiness: float,
disable_tqdm: bool,
num_warmups: int,
profile: bool,
profile_num_steps: int | None,
selected_percentile_metrics: list[str],
selected_percentiles: list[float],
ignore_eos: bool,
goodput_config_dict: dict[str, float],
max_concurrency: int | None,
extra_headers: dict[str, str] | None,
extra_body: dict[str, Any] | None,
ramp_up_strategy: Literal["linear", "exponential"] | None = None,
ramp_up_start_rps: int | None = None,
ramp_up_end_rps: int | None = None,
ready_check_timeout_sec: int = 600,
ssl_context: ssl.SSLContext | bool | None = None,
) -> dict[str, Any]:
try:
request_func = ASYNC_REQUEST_FUNCS[backend]
except KeyError:
raise ValueError(f"Unknown backend: {backend}") from None
connector = aiohttp.TCPConnector(ssl=ssl_context)
session = aiohttp.ClientSession(
connector=connector, trust_env=True, timeout=AIOHTTP_TIMEOUT
)
test_request = input_requests[0]
test_input = RequestFuncInput(
model=model_id,
model_name=model_name,
prompt=test_request.prompt,
api_url=api_url,
prompt_len=test_request.prompt_len,
output_len=test_request.expected_output_len,
logprobs=logprobs,
ignore_eos=ignore_eos,
extra_headers=extra_headers,
extra_body=extra_body,
)
if ready_check_timeout_sec > 0:
print("Starting initial single prompt test run...")
test_output = await wait_for_endpoint(
request_func, test_input, session, timeout_seconds=ready_check_timeout_sec
)
if not test_output.success:
raise ValueError(
"Initial test run failed - Please make sure benchmark arguments are correctly specified. "
f"Error: {test_output.error}"
)
print("Initial test run completed.")
else:
print("Skipping endpoint ready check.")
if num_warmups > 0:
print(f"Warming up with {num_warmups} requests...")
warmup_pbar = None if disable_tqdm else tqdm(total=num_warmups)
warmup_semaphore = (
asyncio.Semaphore(max_concurrency)
if max_concurrency
else contextlib.nullcontext()
)
async def warmup_limited_request_func():
async with warmup_semaphore:
return await request_func(test_input, session=session, pbar=warmup_pbar)
await asyncio.gather(
*(
asyncio.create_task(warmup_limited_request_func())
for _ in range(num_warmups)
)
)
if warmup_pbar:
warmup_pbar.close()
print("Warmup run completed.")
if profile:
if profile_num_steps is None:
print("Starting profiler...")
else:
print(f"Starting profiler for {profile_num_steps} steps...")
extra_body = dict(extra_body or {})
if profile_num_steps is not None:
extra_body["num_steps"] = profile_num_steps
profile_input = RequestFuncInput(
model=model_id,
model_name=model_name,
prompt=test_request.prompt,
api_url=base_url + "/start_profile",
prompt_len=test_request.prompt_len,
output_len=test_request.expected_output_len,
logprobs=logprobs,
ignore_eos=ignore_eos,
extra_headers=extra_headers,
extra_body=extra_body,
)
profile_output = await request_func(profile_input, session=session)
if profile_output.success:
print("Profiler started")
distribution = "Poisson process" if burstiness == 1.0 else "Gamma distribution"
if ramp_up_strategy:
print(f"Traffic ramp-up strategy: {ramp_up_strategy}.")
print(f"Will increase RPS from {ramp_up_start_rps} to {ramp_up_end_rps} RPS.")
else:
print(f"Traffic request rate: {request_rate}")
print(f"Burstiness factor: {burstiness} ({distribution})")
print(f"Maximum request concurrency: {max_concurrency}")
pbar = None if disable_tqdm else tqdm(total=len(input_requests))
semaphore = (
asyncio.Semaphore(max_concurrency)
if max_concurrency
else contextlib.nullcontext()
)
async def limited_request_func(request_func_input, session, pbar):
async with semaphore:
coro = request_func(request_func_input, session=session, pbar=pbar)
return await await_with_per_request_timeout(
coro,
prompt_len=request_func_input.prompt_len,
pbar=pbar,
)
print("Starting main benchmark run...")
benchmark_start_time = time.perf_counter()
tasks: list[asyncio.Task] = []
rps_change_events = []
last_int_rps = -1
if ramp_up_strategy is not None and ramp_up_start_rps is not None:
last_int_rps = ramp_up_start_rps
rps_change_events.append(
{"rps": last_int_rps, "timestamp": datetime.now().isoformat()}
)
async for request, current_request_rate in get_request(
input_requests,
request_rate,
burstiness,
ramp_up_strategy,
ramp_up_start_rps,
ramp_up_end_rps,
):
if ramp_up_strategy is not None:
current_int_rps = int(current_request_rate)
if current_int_rps > last_int_rps:
timestamp = datetime.now().isoformat()
for rps_val in range(last_int_rps + 1, current_int_rps + 1):
rps_change_events.append({"rps": rps_val, "timestamp": timestamp})
last_int_rps = current_int_rps
request_func_input = RequestFuncInput(
model=model_id,
model_name=model_name,
prompt=request.prompt,
api_url=api_url,
prompt_len=request.prompt_len,
output_len=request.expected_output_len,
logprobs=logprobs,
ignore_eos=ignore_eos,
extra_headers=extra_headers,
extra_body=extra_body,
request_id=request.request_id,
)
tasks.append(
asyncio.create_task(limited_request_func(request_func_input, session, pbar))
)
outputs = await asyncio.gather(*tasks)
if pbar:
pbar.close()
benchmark_duration = time.perf_counter() - benchmark_start_time
metrics, actual_output_lens = calculate_metrics(
input_requests,
outputs,
benchmark_duration,
tokenizer,
selected_percentiles,
goodput_config_dict,
)
_print_section_header(" Serving Benchmark Result ", "=")
_print_metric_row("Successful requests:", metrics.completed)
_print_metric_row("Failed requests:", metrics.failed)
if max_concurrency is not None:
_print_metric_row("Maximum request concurrency:", max_concurrency)
if request_rate != float("inf"):
_print_metric_row("Request rate configured (RPS):", request_rate, precision=2)
_print_metric_row("Benchmark duration (s):", benchmark_duration, precision=2)
_print_metric_row("Total input tokens:", metrics.total_input)
_print_metric_row("Total generated tokens:", metrics.total_output)
_print_metric_row(
"Request throughput (req/s):", metrics.request_throughput, precision=2
)
if goodput_config_dict:
_print_metric_row(
"Request goodput (req/s):", metrics.request_goodput, precision=2
)
_print_metric_row(
"Output token throughput (tok/s):", metrics.output_throughput, precision=2
)
_print_metric_row(
"Peak output token throughput (tok/s):",
metrics.max_output_tokens_per_s,
precision=2,
)
_print_metric_row(
"Peak concurrent requests:", metrics.max_concurrent_requests, precision=2
)
_print_metric_row(
"Total token throughput (tok/s):",
metrics.total_token_throughput,
precision=2,
)
result: dict[str, Any] = {
"duration": benchmark_duration,
"completed": metrics.completed,
"failed": metrics.failed,
"total_input_tokens": metrics.total_input,
"total_output_tokens": metrics.total_output,
"request_throughput": metrics.request_throughput,
"request_goodput": metrics.request_goodput if goodput_config_dict else None,
"output_throughput": metrics.output_throughput,
"total_token_throughput": metrics.total_token_throughput,
"input_lens": [output.prompt_len for output in outputs],
"output_lens": actual_output_lens,
"ttfts": [output.ttft for output in outputs],
"itls": [output.itl for output in outputs],
"start_times": [output.start_time for output in outputs],
"generated_texts": [output.generated_text for output in outputs],
"errors": [output.error for output in outputs],
"max_output_tokens_per_s": metrics.max_output_tokens_per_s,
"max_concurrent_requests": metrics.max_concurrent_requests,
}
if rps_change_events:
result["rps_change_events"] = rps_change_events
def process_one_metric(
metric_attribute_name: str, metric_name: str, metric_header: str
) -> None:
if metric_attribute_name not in selected_percentile_metrics:
return
_print_section_header(metric_header, "-")
_print_metric_row(
f"Mean {metric_name} (ms):",
getattr(metrics, f"mean_{metric_attribute_name}_ms"),
precision=2,
)
_print_metric_row(
f"Median {metric_name} (ms):",
getattr(metrics, f"median_{metric_attribute_name}_ms"),
precision=2,
)
result[f"mean_{metric_attribute_name}_ms"] = getattr(
metrics, f"mean_{metric_attribute_name}_ms"
)
result[f"median_{metric_attribute_name}_ms"] = getattr(
metrics, f"median_{metric_attribute_name}_ms"
)
result[f"std_{metric_attribute_name}_ms"] = getattr(
metrics, f"std_{metric_attribute_name}_ms"
)
for p, value in getattr(metrics, f"percentiles_{metric_attribute_name}_ms"):
p_word = str(int(p)) if int(p) == p else str(p)
_print_metric_row(f"P{p_word} {metric_name} (ms):", value, precision=2)
result[f"p{p_word}_{metric_attribute_name}_ms"] = value
process_one_metric("ttft", "TTFT", "Time to First Token")
process_one_metric("tpot", "TPOT", "Time per Output Token (excl. 1st token)")
process_one_metric("itl", "ITL", "Inter-token Latency")
process_one_metric("e2el", "E2EL", "End-to-end Latency")
print("=" * 50)
if profile and profile_num_steps is None:
print("Stopping profiler...")
profile_input = RequestFuncInput(
model=model_id,
model_name=model_name,
prompt=test_request.prompt,
api_url=base_url + "/stop_profile",
prompt_len=test_request.prompt_len,
output_len=test_request.expected_output_len,
logprobs=logprobs,
ignore_eos=ignore_eos,
)
profile_output = await request_func(profile_input, session=session)
if profile_output.success:
print("Profiler stopped")
await session.close()
return result
def parse_goodput(slo_pairs: list[str] | None) -> dict[str, float]:
goodput_config_dict: dict[str, float] = {}
if not slo_pairs:
return goodput_config_dict
try:
for slo_pair in slo_pairs:
slo_name, slo_val = slo_pair.split(":")
goodput_config_dict[slo_name] = float(slo_val)
except ValueError as err:
raise argparse.ArgumentTypeError(
'Specify service level objectives for goodput as "KEY:VALUE" pairs.'
) from err
for slo_name, slo_val in goodput_config_dict.items():
if slo_name not in {"ttft", "tpot", "e2el"}:
raise ValueError(f"Invalid goodput metric {slo_name!r}.")
if slo_val < 0:
raise ValueError(f"Goodput SLO {slo_name!r} must be non-negative.")
return goodput_config_dict
def compute_result_filename(
args: argparse.Namespace, model_id: str, label: str | None, current_dt: str
) -> str | None:
if not (args.save_result or args.append_result or args.output_file):
return None
if args.output_file:
return args.output_file
base_model_id = model_id.split("/")[-1]
max_concurrency_str = (
f"-concurrency{args.max_concurrency}"
if args.max_concurrency is not None
else ""
)
result_label = label or args.backend
if args.ramp_up_strategy is not None:
file_name = f"{result_label}-ramp-up-{args.ramp_up_strategy}-{args.ramp_up_start_rps}qps-{args.ramp_up_end_rps}qps{max_concurrency_str}-{base_model_id}-{current_dt}.json"
else:
file_name = f"{result_label}-{args.request_rate}qps{max_concurrency_str}-{base_model_id}-{current_dt}.json"
if args.result_dir:
os.makedirs(args.result_dir, exist_ok=True)
file_name = os.path.join(args.result_dir, file_name)
return file_name
def add_dataset_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--dataset-name",
type=str,
default="random",
choices=["sharegpt", "random"],
help="Name of the dataset to benchmark on.",
)
parser.add_argument(
"--dataset-path", type=str, default=None, help="Path to the dataset."
)
parser.add_argument("--num-prompts", type=int, default=DEFAULT_NUM_PROMPTS)
parser.add_argument("--input-len", type=int, default=None)
parser.add_argument("--output-len", type=int, default=None)
parser.add_argument("--max-model-len", type=int, default=None)
parser.add_argument("--skip-min-tokens-check", action="store_true")
parser.add_argument("--sharegpt-output-len", type=int, default=None)
parser.add_argument("--random-input-len", type=int, default=1024)
parser.add_argument("--random-output-len", type=int, default=128)
parser.add_argument("--random-range-ratio", type=float, default=0.0)
parser.add_argument("--random-prefix-len", type=int, default=0)
parser.add_argument("--request-id-prefix", type=str, default="bench-")
def add_serving_cli_args(parser: argparse.ArgumentParser) -> None:
add_dataset_parser(parser)
parser.add_argument("--label", type=str, default=None)
parser.add_argument(
"--backend",
type=str,
default="openai",
choices=list(ASYNC_REQUEST_FUNCS.keys()),
help="The backend type to use for the benchmark.",
)
parser.add_argument("--base-url", type=str, default=None)
parser.add_argument("--host", type=str, default="127.0.0.1")
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--endpoint", type=str, default="/v1/completions")
parser.add_argument("--header", metavar="KEY=VALUE", nargs="*")
parser.add_argument("--model", type=str, default=None)
parser.add_argument("--served-model-name", type=str, default=None)
parser.add_argument("--tokenizer", type=str, default=None)
parser.add_argument("--skip-tokenizer-init", action="store_true")
parser.add_argument("--trust-remote-code", action="store_true", default=True)
parser.add_argument("--request-rate", type=float, default=float("inf"))
parser.add_argument("--burstiness", type=float, default=1.0)
parser.add_argument("--max-concurrency", type=int, default=None)
parser.add_argument("--num-warmups", type=int, default=0)
parser.add_argument("--ready-check-timeout-sec", type=int, default=600)
parser.add_argument("--disable-tqdm", action="store_true")
parser.add_argument("--profile", action="store_true")
parser.add_argument("--profile-num-steps", type=int, default=None)
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--ignore-eos", action="store_true")
parser.add_argument("--disable-ignore-eos", action="store_true")
parser.add_argument("--apply-chat-template", action="store_true")
parser.add_argument("--logprobs", type=int, default=None)
parser.add_argument("--extra-body", type=json.loads, default={})
parser.add_argument("--extra-request-body", type=json.loads, default=None)
parser.add_argument("--goodput", nargs="*", default=None)
parser.add_argument("--percentile-metrics", type=str, default=None)
parser.add_argument("--metric-percentiles", type=str, default="99")
parser.add_argument(
"--ramp-up-strategy", choices=["linear", "exponential"], default=None
)
parser.add_argument("--ramp-up-start-rps", type=int, default=None)
parser.add_argument("--ramp-up-end-rps", type=int, default=None)
parser.add_argument("--insecure", action="store_true")
parser.add_argument("--save-result", action="store_true")
parser.add_argument("--append-result", action="store_true")
parser.add_argument("--save-detailed", action="store_true")
parser.add_argument("--result-dir", type=str, default=None)
parser.add_argument("--output-file", type=str, default=None)
parser.set_defaults(dispatch_function=BenchmarkServingSubcommand.cmd)
async def main_async(args: argparse.Namespace) -> dict[str, Any]:
print(args)
set_ulimit()
random.seed(args.seed)
np.random.seed(args.seed)
if args.disable_ignore_eos:
args.ignore_eos = False
if args.extra_request_body is not None:
args.extra_body = args.extra_request_body
if args.profile_num_steps is not None:
if args.profile_num_steps <= 0:
raise ValueError("--profile-num-steps must be positive.")
if not args.profile:
raise ValueError("--profile-num-steps requires --profile.")
if args.input_len is not None:
args.random_input_len = args.input_len
if args.output_len is not None:
args.random_output_len = args.output_len
args.sharegpt_output_len = args.output_len
if args.ramp_up_strategy is not None:
if args.request_rate != float("inf"):
raise ValueError("When using ramp-up, do not specify --request-rate.")
if args.ramp_up_start_rps is None or args.ramp_up_end_rps is None:
raise ValueError(
"Ramp-up requires --ramp-up-start-rps and --ramp-up-end-rps."
)
if args.ramp_up_start_rps > args.ramp_up_end_rps:
raise ValueError("Ramp-up start RPS must be less than end RPS.")
if args.ramp_up_strategy == "exponential" and args.ramp_up_start_rps == 0:
raise ValueError("For exponential ramp-up, start RPS cannot be 0.")
if args.base_url is not None:
api_url = f"{args.base_url}{args.endpoint}"
base_url = args.base_url
else:
host_port = join_host_port(args.host, args.port)
api_url = f"http://{host_port}{args.endpoint}"
base_url = f"http://{host_port}"
headers = None
if args.header:
headers = {}
for item in args.header:
if "=" not in item:
raise ValueError("Invalid header format. Please use KEY=VALUE format.")
key, value = item.split("=", 1)
headers[key.strip()] = value.strip()
ssl_context: ssl.SSLContext | bool | None = (
False if args.insecure else True if base_url.startswith("https://") else None
)
if args.model is None:
print("Model not specified, fetching first model from server...")
model_name, model_id = await get_first_model_from_server(
base_url, headers, ssl_context
)
print(f"First model name: {model_name}, first model id: {model_id}")
else:
model_name = args.served_model_name
model_id = args.model
tokenizer = None
tokenizer_id = None
if not args.skip_tokenizer_init:
tokenizer_id = args.tokenizer or model_id
tokenizer = get_tokenizer(tokenizer_id)
if args.dataset_name == "random" and args.backend in OPENAI_COMPATIBLE_BACKENDS:
args.ignore_eos = True
input_requests = get_samples(args, tokenizer)
goodput_config_dict = parse_goodput(args.goodput)
extra_body = args.extra_body or {}
percentile_metrics = args.percentile_metrics or "ttft,tpot,itl"
if "temperature" not in extra_body:
print(
"WARNING: tokenspeed bench serve no longer sets temperature==0 in requests by default. "
"The server decides its own default. Include --extra-body '{\"temperature\": 0}' for greedy decoding."
)
benchmark_result = await benchmark(
task_type=TaskType.GENERATION,
backend=args.backend,
api_url=api_url,
base_url=base_url,
model_id=model_id,
model_name=model_name,
tokenizer=tokenizer,
input_requests=input_requests,
logprobs=args.logprobs,
request_rate=args.request_rate,
burstiness=args.burstiness,
disable_tqdm=args.disable_tqdm,
num_warmups=args.num_warmups,
profile=args.profile,
profile_num_steps=args.profile_num_steps,
selected_percentile_metrics=percentile_metrics.split(","),
selected_percentiles=[float(p) for p in args.metric_percentiles.split(",")],
ignore_eos=args.ignore_eos,
goodput_config_dict=goodput_config_dict,
max_concurrency=args.max_concurrency,
extra_headers=headers,
extra_body=extra_body,
ramp_up_strategy=args.ramp_up_strategy,
ramp_up_start_rps=args.ramp_up_start_rps,
ramp_up_end_rps=args.ramp_up_end_rps,
ready_check_timeout_sec=args.ready_check_timeout_sec,
ssl_context=ssl_context,
)
current_dt = datetime.now().strftime("%Y%m%d-%H%M%S")
result_json = {
"date": current_dt,
"backend": args.backend,
"label": args.label,
"model_id": model_id,
"tokenizer_id": tokenizer_id,
"num_prompts": args.num_prompts,
"request_rate": (
args.request_rate if args.request_rate < float("inf") else "inf"
),
"burstiness": args.burstiness,
"max_concurrency": args.max_concurrency,
**benchmark_result,
}
if not args.save_detailed:
for field_name in [
"input_lens",
"output_lens",
"start_times",
"ttfts",
"itls",
"generated_texts",
"errors",
]:
result_json.pop(field_name, None)
file_name = compute_result_filename(args, model_id, args.label, current_dt)
if file_name:
with open(
file_name, mode="a+" if args.append_result else "w", encoding="utf-8"
) as outfile:
if args.append_result and outfile.tell() != 0:
outfile.write("\n")
json.dump(result_json, outfile)
return result_json
def run_benchmark(args: argparse.Namespace) -> dict[str, Any]:
return asyncio.run(main_async(args))
class BenchmarkSubcommandBase:
help: str
name: str
@classmethod
def add_cli_args(cls, parser: argparse.ArgumentParser) -> None:
raise NotImplementedError
@staticmethod
def cmd(args: argparse.Namespace) -> None:
raise NotImplementedError
class BenchmarkServingSubcommand(BenchmarkSubcommandBase):
name = "serve"
help = "Benchmark online serving throughput."
@classmethod
def add_cli_args(cls, parser: argparse.ArgumentParser) -> None:
add_serving_cli_args(parser)
@staticmethod
def cmd(args: argparse.Namespace) -> None:
run_benchmark(args)
class BenchmarkSubcommand:
name = "bench"
help = "TokenSpeed bench subcommand."
@staticmethod
def cmd(args: argparse.Namespace) -> None:
args.dispatch_function(args)
def subparser_init(
self, subparsers: argparse._SubParsersAction
) -> argparse.ArgumentParser:
bench_parser = subparsers.add_parser(
self.name,
help=self.help,
description=self.help,
usage=f"tokenspeed {self.name} <bench_type> [options]",
)
bench_subparsers = bench_parser.add_subparsers(required=True, dest="bench_type")
for cmd_cls in BenchmarkSubcommandBase.__subclasses__():
cmd_subparser = bench_subparsers.add_parser(
cmd_cls.name,
help=cmd_cls.help,
description=cmd_cls.help,
usage=f"tokenspeed {self.name} {cmd_cls.name} [options]",
)
cmd_subparser.set_defaults(dispatch_function=cmd_cls.cmd)
cmd_cls.add_cli_args(cmd_subparser)
return bench_parser
def is_legacy_serving_args(argv: list[str]) -> bool:
return bool(argv) and argv[0].startswith("-") and argv[0] not in ("-h", "--help")
def main(argv: list[str] | None = None) -> None:
argv = list(sys.argv[1:] if argv is None else argv)
if is_legacy_serving_args(argv):
parser = argparse.ArgumentParser(description=BenchmarkServingSubcommand.help)
BenchmarkServingSubcommand.add_cli_args(parser)
args = parser.parse_args(argv)
BenchmarkServingSubcommand.cmd(args)
return
parser = argparse.ArgumentParser(
prog="tokenspeed", description="TokenSpeed benchmark commands."
)
subparsers = parser.add_subparsers(required=True, dest="command")
BenchmarkSubcommand().subparser_init(subparsers)
args = parser.parse_args(["bench", *argv])
BenchmarkSubcommand.cmd(args)
if __name__ == "__main__":
main()