Files
sgl-project--sglang/python/sglang/benchmark/one_batch_server.py
T
wehub-resource-sync 94057c3d3e
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:38:16 +08:00

1254 lines
48 KiB
Python

"""
Benchmark the latency of running a single batch with a server.
This script launches a server and uses the HTTP interface.
It accepts server arguments (the same as launch_server.py) and benchmark arguments (e.g., batch size, input lengths).
Usage:
python3 -m sglang.benchmark.one_batch_server --model meta-llama/Meta-Llama-3.1-8B --batch-size 1 16 64 --input-len 1024 --output-len 8
python3 -m sglang.benchmark.one_batch_server --model None --base-url http://localhost:30000 --batch-size 16 --input-len 1024 --output-len 8
python3 -m sglang.benchmark.one_batch_server --model None --base-url http://localhost:30000 --batch-size 16 --input-len 1024 --output-len 8 --show-report --profile --profile-by-stage
python3 -m sglang.benchmark.one_batch_server --model None --base-url http://localhost:30000 --batch-size 16 --input-len 1024 --output-len 8 --result-filename results.jsonl --profile
"""
import argparse
import dataclasses
import itertools
import json
import random
import re
import time
from functools import lru_cache
from types import SimpleNamespace
from typing import Callable, List, Optional, Tuple
import numpy as np
import requests
from pydantic import BaseModel
from tabulate import tabulate
from transformers import AutoProcessor, PreTrainedTokenizer
from sglang.benchmark.datasets import get_dataset
from sglang.benchmark.endpoint import acquire_endpoint
from sglang.benchmark.utils import get_processor, get_tokenizer
from sglang.profiler import run_profile
from sglang.srt.disaggregation.utils import FAKE_BOOTSTRAP_HOST
from sglang.srt.entrypoints.http_server import launch_server
from sglang.srt.server_args import ServerArgs
from sglang.srt.utils import is_blackwell
from sglang.test.nightly_bench_utils import save_results_as_pydantic_models
from sglang.test.test_utils import is_in_ci, write_github_step_summary
DEFAULT_TIMEOUT = 600
def get_cache_tokens_from_metrics(url: str) -> Optional[tuple]:
"""
Get cached_tokens_total and prompt_tokens_total from Prometheus /metrics endpoint.
Returns (cached_tokens_total, prompt_tokens_total) or None if metrics are not available.
"""
try:
response = requests.get(url + "/metrics", timeout=5)
try:
response.raise_for_status()
except requests.exceptions.HTTPError:
return None
# Parse Prometheus text format
# Looking for: sglang:cached_tokens_total{...} <value>
# sglang:prompt_tokens_total{...} <value>
cached_tokens_total = 0.0
prompt_tokens_total = 0.0
for line in response.text.split("\n"):
if line.startswith("sglang:cached_tokens_total{"):
match = re.search(
r"sglang:cached_tokens_total\{[^}]*\}\s+([\d.eE+-]+)", line
)
if match:
cached_tokens_total += float(match.group(1))
elif line.startswith("sglang:prompt_tokens_total{"):
match = re.search(
r"sglang:prompt_tokens_total\{[^}]*\}\s+([\d.eE+-]+)", line
)
if match:
prompt_tokens_total += float(match.group(1))
return (cached_tokens_total, prompt_tokens_total)
except Exception as e:
print(f"Warning: Failed to get cache tokens from metrics: {e}")
return None
def calculate_cache_hit_rate(
before: Optional[tuple], after: Optional[tuple]
) -> Optional[float]:
"""
Calculate cache hit rate from before/after metrics snapshots.
Returns cached_tokens_delta / prompt_tokens_delta for the benchmark run.
"""
if before is None or after is None:
return None
cached_delta = after[0] - before[0]
prompt_delta = after[1] - before[1]
if prompt_delta > 0:
return cached_delta / prompt_delta
return None
@dataclasses.dataclass
class BenchArgs:
run_name: str = "default"
batch_size: Tuple[int] = (1,)
input_len: Tuple[int] = (1024,)
output_len: Tuple[int] = (16,)
temperature: float = 0.0
return_logprob: bool = False
client_stream_interval: int = 1
input_len_step_percentage: float = 0.0
base_url: str = ""
local_tokenizer_path: str = ""
skip_warmup: bool = False
show_report: bool = False
profile: bool = False
profile_activities: Tuple[str] = ("CPU", "GPU")
profile_start_step: Optional[int] = None
profile_steps: int = 5
profile_by_stage: bool = False
profile_prefix: Optional[str] = None
profile_output_dir: Optional[str] = None
dataset_path: str = ""
dataset_name: str = "random"
fixed_prompt_file: str = ""
apply_chat_template: bool = False
gsp_num_groups: int = 1
gsp_system_prompt_len: int = 2048
gsp_question_len: int = 128
gsp_output_len: int = 256
parallel_batch: bool = False
result_filename: str = "result.jsonl"
pydantic_result_filename: Optional[str] = None
append_to_github_summary: bool = True
seed: int = 42
cache_hit_rate: float = 0.0
backend: str = "sglang"
fake_prefill: bool = False
server_args_for_metrics: Optional[List[str]] = None
lora_name: Optional[List[str]] = None
lora_request_distribution: str = "uniform"
lora_zipf_alpha: float = 1.1
enable_multi_batch: bool = False
@staticmethod
def add_cli_args(parser: argparse.ArgumentParser):
parser.add_argument("--run-name", type=str, default=BenchArgs.run_name)
parser.add_argument(
"--batch-size", type=int, nargs="+", default=BenchArgs.batch_size
)
parser.add_argument(
"--input-len", type=int, nargs="+", default=BenchArgs.input_len
)
parser.add_argument(
"--output-len", type=int, nargs="+", default=BenchArgs.output_len
)
parser.add_argument("--temperature", type=float, default=BenchArgs.temperature)
parser.add_argument("--return-logprob", action="store_true")
parser.add_argument(
"--client-stream-interval",
type=int,
default=BenchArgs.client_stream_interval,
)
parser.add_argument(
"--input-len-step-percentage",
type=float,
default=BenchArgs.input_len_step_percentage,
)
parser.add_argument("--base-url", type=str, default=BenchArgs.base_url)
parser.add_argument(
"--local-tokenizer-path",
type=str,
default=BenchArgs.local_tokenizer_path,
help=(
"Local tokenizer path to use when benchmarking an external "
"SGLang server via --base-url. Defaults to the tokenizer path "
"reported by /server_info."
),
)
parser.add_argument("--skip-warmup", action="store_true")
parser.add_argument("--show-report", action="store_true")
parser.add_argument("--profile", action="store_true")
parser.add_argument(
"--profile-activities",
type=str,
nargs="+",
default=("CPU", "GPU"),
choices=["CPU", "GPU", "XPU"],
help="Profiler activities: CPU, GPU, XPU. use torch profiler.",
)
parser.add_argument(
"--profile-start-step",
type=int,
default=BenchArgs.profile_start_step,
help="Start profiling after this many forward steps. Useful for warmup.",
)
parser.add_argument(
"--profile-steps", type=int, default=BenchArgs.profile_steps
)
parser.add_argument("--profile-by-stage", action="store_true")
parser.add_argument(
"--profile-prefix",
type=str,
default=BenchArgs.profile_prefix,
)
parser.add_argument(
"--profile-output-dir",
type=str,
default=BenchArgs.profile_output_dir,
)
parser.add_argument(
"--dataset-path",
type=str,
default=BenchArgs.dataset_path,
help="Path to the dataset.",
)
parser.add_argument(
"--dataset-name",
type=str,
default=BenchArgs.dataset_name,
choices=["mmmu", "random", "random-ids", "generated-shared-prefix"],
help="Name of the dataset to benchmark on.",
)
parser.add_argument(
"--fixed-prompt-file",
type=str,
default=BenchArgs.fixed_prompt_file,
help="Use this file's prompt for every request in the batch, "
"bypassing --dataset-name.",
)
parser.add_argument(
"--apply-chat-template",
action="store_true",
help="Encode the prompt as a single user message through the "
"model's chat template. Requires --fixed-prompt-file.",
)
parser.add_argument(
"--gsp-num-groups",
type=int,
default=BenchArgs.gsp_num_groups,
help="Number of shared prefix groups. batch_size requests are distributed across groups.",
)
parser.add_argument(
"--gsp-system-prompt-len",
type=int,
default=BenchArgs.gsp_system_prompt_len,
help="Length of the shared system prompt in tokens per group.",
)
parser.add_argument(
"--gsp-question-len",
type=int,
default=BenchArgs.gsp_question_len,
help="Length of the unique question suffix in tokens per request.",
)
parser.add_argument(
"--gsp-output-len",
type=int,
default=BenchArgs.gsp_output_len,
help="Output length in tokens for generated-shared-prefix requests.",
)
parser.add_argument("--parallel-batch", action="store_true")
parser.add_argument(
"--result-filename",
type=str,
default=BenchArgs.result_filename,
help="Store the results line by line in the JSON Line format to this file.",
)
parser.add_argument(
"--pydantic-result-filename",
type=str,
default=BenchArgs.pydantic_result_filename,
help="Store the results as pydantic models in the JSON format to this file.",
)
parser.add_argument(
"--no-append-to-github-summary",
action="store_false",
dest="append_to_github_summary",
help="Disable appending the output of this run to github ci summary",
)
parser.add_argument("--seed", type=int, default=BenchArgs.seed)
parser.add_argument(
"--cache-hit-rate",
type=float,
default=BenchArgs.cache_hit_rate,
help="Cache hit rate for benchmarking (0.0-1.0). "
"0.0 means no cache hits (flush all), 0.4 means 40%% of input tokens are cached.",
)
parser.add_argument(
"--backend",
type=str,
default=BenchArgs.backend,
choices=["sglang", "vllm"],
help="Backend server type (sglang or vllm).",
)
parser.add_argument(
"--fake-prefill",
action="store_true",
default=BenchArgs.fake_prefill,
help="Enable fake prefill mode for decode-only benchmarking. "
"Use with a decode server running --disaggregation-transfer-backend fake "
"to benchmark pure decode performance without a real prefill node.",
)
parser.add_argument(
"--server-args-for-metrics",
type=str,
nargs="*",
default=None,
help="Server launch arguments to record in metrics output (for tracking configurations).",
)
parser.add_argument(
"--lora-name",
type=str,
nargs="*",
default=BenchArgs.lora_name,
help="Name(s) of pre-loaded LoRA adapter(s) to apply to the batch "
"(sent as `lora_path` in the SGLang /generate payload). Requires "
"the server to be launched with --enable-lora and --lora-paths "
"<name>=<path> for every name listed here. Pass one name to apply "
"a single adapter to every prompt, or multiple names to sample a "
"per-prompt adapter per --lora-request-distribution.",
)
parser.add_argument(
"--lora-request-distribution",
type=str,
default=BenchArgs.lora_request_distribution,
choices=["uniform", "distinct", "skewed"],
help="How to sample a LoRA adapter per prompt when more than one "
"is listed in --lora-name. Mirrors serving.py. "
"'uniform' picks uniformly at random, 'distinct' round-robins so "
"consecutive prompts get different adapters, 'skewed' samples "
"from a Zipf distribution over --lora-name (alpha controls the "
"skew; see --lora-zipf-alpha).",
)
parser.add_argument(
"--lora-zipf-alpha",
type=float,
default=BenchArgs.lora_zipf_alpha,
help="Zipf exponent for 'skewed' LoRA sampling: the number of "
"requests to adapter i is alpha times the number to adapter i+1. "
"Must be > 1. Only used when --lora-request-distribution=skewed.",
)
parser.add_argument(
"--enable-multi-batch",
action="store_true",
help=(
"Allow --batch-size to exceed the server's "
"effective_max_running_requests_per_dp * dp_size. The surplus "
"requests are queued by the scheduler and promoted as slots "
"free, so the batch is served as multiple sequential batches "
"at the running-batch cap. Useful for stabilizing throughput "
"measurements: driving more total prompts through a "
"fixed running batch amortizes per-request prefill and "
"first-step transients into steady-state decode. "
"NOTE: only `overall_throughput` (= total_tokens / wall_time) "
"is meaningful in this mode; input_throughput, "
"output_throughput, last_ttft, and ITL assume one-shot "
"batching and will be misleading."
),
)
@classmethod
def from_cli_args(cls, args: argparse.Namespace):
attrs = [attr.name for attr in dataclasses.fields(cls)]
return cls(**{attr: getattr(args, attr) for attr in attrs})
class BenchOneCaseResult(BaseModel):
run_name: str
batch_size: int
input_len: int
output_len: int
latency: float
input_throughput: float
output_throughput: float
overall_throughput: float
last_ttft: float
last_gen_throughput: float
acc_length: float
cache_hit_rate: Optional[float] = None
profile_link: Optional[str] = None
def dump_to_jsonl(self, result_filename: str):
with open(result_filename, "a") as fout:
res = {
"run_name": self.run_name,
"batch_size": self.batch_size,
"input_len": self.input_len,
"output_len": self.output_len,
"latency": round(self.latency, 4),
"input_throughput": round(self.input_throughput, 2),
"output_throughput": round(self.output_throughput, 2),
"overall_throughput": round(self.overall_throughput, 2),
"last_ttft": round(self.last_ttft, 4),
"last_gen_throughput": round(self.last_gen_throughput, 2),
"acc_length": round(self.acc_length, 2),
"cache_hit_rate": (
round(self.cache_hit_rate, 4)
if self.cache_hit_rate is not None
else None
),
}
fout.write(json.dumps(res) + "\n")
def _warmup_cache(
url: str,
input_ids: List[List[int]],
input_len: int,
cache_hit_rate: float,
dataset_name: str = "random",
image_data: Optional[List] = None,
backend: str = "sglang",
model_name: Optional[str] = None,
):
"""Warm up the cache by sending prefix tokens to populate the radix/prefix cache.
Args:
url: Server URL
input_ids: List of input token id lists
input_len: Length of input tokens
cache_hit_rate: Fraction of input tokens to cache (0.0-1.0)
dataset_name: Name of the dataset (used to determine if image data should be included)
image_data: Optional image data for VLM models
backend: Backend server type ("sglang" or "vllm")
model_name: Model name (required for vllm backend)
"""
cached_token_len = int(input_len * cache_hit_rate)
if cached_token_len <= 0:
return
print(
f"Warming up cache with {cache_hit_rate*100:.1f}% hit rate "
f"({cached_token_len} tokens per request)"
)
# Create prefix input_ids for cache warming
cache_warmup_input_ids = [ids[:cached_token_len] for ids in input_ids]
if backend == "vllm":
cache_warmup_payload = {
"model": model_name,
"prompt": cache_warmup_input_ids,
"max_tokens": 1,
"temperature": 0.0,
"stream": False,
}
gen_url = url + "/v1/completions"
else:
cache_warmup_payload = {
"input_ids": cache_warmup_input_ids,
"sampling_params": {
"temperature": 0.0,
"max_new_tokens": 1, # Minimal output, just to populate cache
"ignore_eos": True,
},
"stream": False,
}
if dataset_name == "mmmu" and image_data is not None:
# include image data in cache warmup
cache_warmup_payload["image_data"] = image_data
gen_url = url + "/generate"
warmup_response = requests.post(
gen_url,
json=cache_warmup_payload,
timeout=DEFAULT_TIMEOUT,
)
warmup_response.raise_for_status()
print("Cache warmup completed")
def _flush_cache_with_retry(url: str, endpoint: str, max_retries: int = 3):
"""Post to a cache flush endpoint with retries on failure."""
for attempt in range(max_retries):
try:
response = requests.post(url + endpoint, timeout=DEFAULT_TIMEOUT)
if response.status_code == 200:
return
if attempt >= max_retries - 1:
response.raise_for_status()
except requests.RequestException:
if attempt >= max_retries - 1:
raise
time.sleep(2)
@lru_cache(maxsize=None)
def _load_hf_config(name_or_path: str):
if not name_or_path:
return None
from transformers import AutoConfig
try:
return AutoConfig.from_pretrained(name_or_path, trust_remote_code=True)
except Exception as e:
print(
f"Warning: could not load config for {name_or_path!r} ({e}); "
"falling back to the HF chat template for --apply-chat-template."
)
return None
def _encode_fixed_prompt(
tok_inner, prompt_text: str, apply_chat_template: bool
) -> List[int]:
if not apply_chat_template:
return tok_inner.encode(prompt_text)
from sglang.srt.entrypoints.openai.chat_encoding import (
encode_simple_chat,
resolve_chat_encoding_spec,
)
hf_config = _load_hf_config(getattr(tok_inner, "name_or_path", "") or "")
spec = (
resolve_chat_encoding_spec(hf_config=hf_config, tokenizer=tok_inner)
if hf_config is not None
else None
)
return encode_simple_chat(
tokenizer=tok_inner,
spec=spec,
messages=[{"role": "user", "content": prompt_text}],
)
def run_one_case(
url: str,
batch_size: int,
input_len: int,
output_len: int,
temperature: float,
return_logprob: bool,
stream_interval: int,
input_len_step_percentage: float,
run_name: str,
result_filename: str,
tokenizer: PreTrainedTokenizer | AutoProcessor,
profile: bool = False,
profile_activities: Tuple[str] = ("CPU", "GPU"),
profile_start_step: Optional[int] = None,
profile_steps: int = BenchArgs.profile_steps,
profile_by_stage: bool = False,
profile_prefix: Optional[str] = BenchArgs.profile_prefix,
profile_output_dir: Optional[str] = BenchArgs.profile_output_dir,
dataset_name: str = BenchArgs.dataset_name,
dataset_path: str = BenchArgs.dataset_path,
parallel_batch: bool = False,
cache_hit_rate: float = BenchArgs.cache_hit_rate,
backend: str = "sglang",
model_name: Optional[str] = None,
gsp_num_groups: int = BenchArgs.gsp_num_groups,
gsp_system_prompt_len: int = BenchArgs.gsp_system_prompt_len,
gsp_question_len: int = BenchArgs.gsp_question_len,
gsp_output_len: int = BenchArgs.gsp_output_len,
fake_prefill: bool = False,
lora_name: Optional[List[str]] = None,
lora_request_distribution: str = BenchArgs.lora_request_distribution,
lora_zipf_alpha: float = BenchArgs.lora_zipf_alpha,
fixed_prompt_file: str = "",
apply_chat_template: bool = False,
):
if backend == "vllm":
# You need to have export VLLM_SERVER_DEV_MODE=1 in your environment to use this endpoint.
_flush_cache_with_retry(url, "/reset_prefix_cache")
else:
_flush_cache_with_retry(url, "/flush_cache")
if fixed_prompt_file:
tok_inner = getattr(tokenizer, "tokenizer", tokenizer)
with open(fixed_prompt_file) as f:
prompt_ids = _encode_fixed_prompt(tok_inner, f.read(), apply_chat_template)
input_ids = [list(prompt_ids) for _ in range(batch_size)]
input_len = len(prompt_ids)
image_data = None
else:
# Load input token ids via benchmark.datasets.get_dataset
supported_datasets = ("random", "random-ids", "mmmu", "generated-shared-prefix")
if dataset_name not in supported_datasets:
raise ValueError(
f"Unsupported dataset for batch benchmark: {dataset_name}. "
f"Supported: {supported_datasets}"
)
actual_gsp_groups = min(gsp_num_groups, batch_size)
dataset_args = SimpleNamespace(
dataset_name=dataset_name,
num_prompts=batch_size,
random_input_len=input_len,
random_output_len=output_len,
random_range_ratio=1.0,
dataset_path=dataset_path,
tokenize_prompt=dataset_name not in ("mmmu", "generated-shared-prefix"),
backend=backend,
seed=BenchArgs.seed,
gsp_num_groups=actual_gsp_groups,
gsp_prompts_per_group=(batch_size + actual_gsp_groups - 1)
// actual_gsp_groups,
gsp_system_prompt_len=gsp_system_prompt_len,
gsp_question_len=gsp_question_len,
gsp_output_len=gsp_output_len,
# The generated-shared-prefix dataset's from_args requires these; the
# batch-bench path only ever uses the uniform group distribution.
gsp_group_distribution="uniform",
gsp_zipf_alpha=None,
)
tok_inner = getattr(tokenizer, "tokenizer", tokenizer)
dataset_model_id = model_name or getattr(tok_inner, "name_or_path", None)
input_requests = get_dataset(dataset_args, tokenizer, model_id=dataset_model_id)
if dataset_name == "generated-shared-prefix":
input_requests = input_requests[:batch_size]
input_ids = [tokenizer.encode(req.prompt) for req in input_requests]
input_len = sum(len(ids) for ids in input_ids) // len(input_ids)
output_len = gsp_output_len
image_data = None
elif dataset_name == "mmmu":
input_ids = [tok_inner.encode(req.prompt) for req in input_requests]
image_data = [req.image_data for req in input_requests]
else:
input_ids = [req.prompt for req in input_requests]
image_data = None
# Build payload based on backend
if backend == "vllm":
payload = {
"model": model_name,
"prompt": input_ids,
"max_tokens": output_len,
"temperature": temperature,
"stream": True,
"ignore_eos": True,
}
if return_logprob:
payload["logprobs"] = 1
gen_url = url + "/v1/completions"
else:
# Load sampling parameters
use_structured_outputs = False
if use_structured_outputs:
texts = []
for _ in range(batch_size):
texts.append(
"Human: What is the capital city of france? can you give as many trivial information as possible about that city? answer in json.\n"
* 50
+ "Assistant:"
)
json_schema = "$$ANY$$"
else:
json_schema = None
payload = {
"sampling_params": {
"temperature": temperature,
"max_new_tokens": output_len,
"ignore_eos": True,
"json_schema": json_schema,
"stream_interval": stream_interval,
},
"return_logprob": return_logprob,
"stream": True,
**({"parallel_batch": parallel_batch} if parallel_batch else {}),
}
payload["input_ids"] = input_ids
if image_data is not None:
payload["image_data"] = image_data
if fake_prefill:
payload["bootstrap_host"] = FAKE_BOOTSTRAP_HOST
payload["bootstrap_room"] = 0
if lora_name:
# SGLang /generate accepts lora_path as either a string (applied
# to every prompt) or a list matching the batch size (per-prompt
# adapter). See io_struct.GenerateReqInput._normalize_lora_path.
if len(lora_name) == 1:
payload["lora_path"] = lora_name[0]
elif lora_request_distribution == "uniform":
payload["lora_path"] = [
random.choice(lora_name) for _ in range(batch_size)
]
elif lora_request_distribution == "distinct":
payload["lora_path"] = [
lora_name[i % len(lora_name)] for i in range(batch_size)
]
elif lora_request_distribution == "skewed":
weights = np.array([lora_zipf_alpha**-i for i in range(len(lora_name))])
probs = weights / np.sum(weights)
payload["lora_path"] = list(
np.random.choice(lora_name, size=batch_size, p=probs)
)
else:
raise ValueError(
f"Unexpected lora_request_distribution: "
f"{lora_request_distribution!r}"
)
gen_url = url + "/generate"
# Warm up cache if cache_hit_rate > 0.0
if cache_hit_rate > 0.0:
_warmup_cache(
url=url,
input_ids=input_ids,
input_len=input_len,
cache_hit_rate=cache_hit_rate,
dataset_name=dataset_name,
image_data=image_data,
backend=backend,
model_name=model_name,
)
# Turn on profiler
profile_link = None
if profile:
profile_link: str = run_profile(
url=url,
num_steps=profile_steps,
activities=profile_activities,
output_dir=profile_output_dir,
profile_by_stage=profile_by_stage,
profile_prefix=profile_prefix,
start_step=profile_start_step,
)
# Get metrics before the request (for cache hit rate calculation)
metrics_before = get_cache_tokens_from_metrics(url)
# Run the request
tic = time.perf_counter()
with requests.post(
gen_url,
json=payload,
stream=True,
timeout=DEFAULT_TIMEOUT,
) as response:
response.raise_for_status()
# Get the TTFT of the last request in the batch
last_ttft = 0.0
if backend == "vllm":
# Parse OpenAI-compatible streaming format from vLLM
first_token_indices = set()
for chunk in response.iter_lines(decode_unicode=False):
chunk = chunk.decode("utf-8")
if chunk and chunk.startswith("data:"):
data_str = chunk[5:].strip()
if data_str == "[DONE]":
break
data = json.loads(data_str)
if "error" in data:
raise RuntimeError(f"Request has failed. {data}.")
for choice in data.get("choices", []):
idx = choice["index"]
if idx not in first_token_indices:
first_token_indices.add(idx)
if len(first_token_indices) == batch_size:
last_ttft = time.perf_counter() - tic
else:
for chunk in response.iter_lines(decode_unicode=False):
chunk = chunk.decode("utf-8")
if chunk and chunk.startswith("data:"):
if chunk == "data: [DONE]":
break
data = json.loads(chunk[5:].strip("\n"))
if "error" in data:
raise RuntimeError(f"Request has failed. {data}.")
assert (
data["meta_info"]["finish_reason"] is None
or data["meta_info"]["finish_reason"]["type"] == "length"
)
if data["meta_info"]["completion_tokens"] == 1:
last_ttft = time.perf_counter() - tic
# Compute metrics
latency = time.perf_counter() - tic
input_throughput = batch_size * input_len / last_ttft
output_throughput = batch_size * output_len / (latency - last_ttft)
overall_throughput = batch_size * (input_len + output_len) / latency
if backend == "vllm":
# vLLM does not expose these metrics via API
last_gen_throughput = -1
acc_length = -1
else:
response = requests.get(url + "/server_info", timeout=DEFAULT_TIMEOUT)
response.raise_for_status()
server_info = response.json()
internal_states = server_info.get("internal_states", [])
acc_length = -1
last_gen_throughput = -1
for internal_state in internal_states:
val_acc = internal_state.get("avg_spec_accept_length")
if val_acc is not None:
acc_length = val_acc
val_thr = internal_state.get("last_gen_throughput")
if val_thr is not None:
last_gen_throughput = val_thr
# Calculate cache hit rate from before/after metrics delta
metrics_after = get_cache_tokens_from_metrics(url)
metrics_cache_hit_rate = calculate_cache_hit_rate(metrics_before, metrics_after)
# Print results
print(f"batch size: {batch_size}")
print(f"input_len: {input_len}")
print(f"output_len: {output_len}")
print(f"latency: {latency:.2f} s")
print(f"input throughput: {input_throughput:.2f} tok/s")
if output_len != 1:
print(f"output throughput: {output_throughput:.2f} tok/s")
print(f"last_ttft: {last_ttft:.2f} s")
print(f"last generation throughput: {last_gen_throughput:.2f} tok/s")
if acc_length > 0:
print(f"acc_length: {acc_length:.2f} ")
if metrics_cache_hit_rate is not None:
print(f"cache hit rate: {metrics_cache_hit_rate:.4f}")
# Dump results
result = BenchOneCaseResult(
run_name=run_name,
batch_size=batch_size,
input_len=input_len,
output_len=output_len,
latency=latency,
input_throughput=input_throughput,
output_throughput=output_throughput,
overall_throughput=overall_throughput,
last_ttft=last_ttft,
last_gen_throughput=last_gen_throughput,
acc_length=acc_length,
cache_hit_rate=metrics_cache_hit_rate,
profile_link=profile_link,
)
# Save and return the results
if result_filename:
result.dump_to_jsonl(result_filename)
return result
def should_skip_due_to_token_capacity(
batch_size, input_len, output_len, skip_token_capacity_threshold
):
if batch_size * (input_len + output_len) > skip_token_capacity_threshold:
print(
"=" * 8
+ f"Skip benchmark {batch_size=} * ({input_len=} + {output_len=}) = {batch_size * (input_len + output_len)} > {skip_token_capacity_threshold=} due to kv cache limit."
+ "=" * 8
)
return True
return False
def should_skip_due_to_max_running_requests(
batch_size, skip_max_running_requests_threshold
):
if batch_size > skip_max_running_requests_threshold:
print(
"=" * 8
+ f"Skip benchmark {batch_size=} > {skip_max_running_requests_threshold=} due to max running requests limit."
+ "=" * 8
)
return True
return False
def get_report_summary(
results: List[BenchOneCaseResult], bench_args: BenchArgs, server_args: ServerArgs
):
summary = (
f"\nInput lens: {bench_args.input_len}. Output lens: {bench_args.output_len}."
)
if bench_args.cache_hit_rate > 0.0:
summary += f" Cache hit rate: {bench_args.cache_hit_rate*100:.1f}%."
summary += "\n"
if is_blackwell():
hourly_cost_per_gpu = 4 # $4/hour for one B200
else:
hourly_cost_per_gpu = 2 # $2/hour for one H100
input_util = 0.7
# sort result by input_len
results.sort(key=lambda x: x.input_len)
rows = []
headers = [
"batch size",
"input len",
"latency (s)",
"input throughput (tok/s)",
"output throughput (tok/s)",
"acc length",
"ITL (ms)",
"input cost ($/1M)",
"output cost ($/1M)",
"cache hit rate",
]
if bench_args.profile:
headers.append("profile")
for res in results:
hourly_cost = hourly_cost_per_gpu * server_args.tp_size
accept_length = f"{res.acc_length:.2f}" if res.acc_length > 0 else "n/a"
itl_ms = 1000 * res.batch_size / res.output_throughput
input_cost = 1e6 / (res.input_throughput * input_util) / 3600 * hourly_cost
output_cost = 1e6 / res.output_throughput / 3600 * hourly_cost
cache_hit_rate = (
f"{res.cache_hit_rate:.4f}" if res.cache_hit_rate is not None else "n/a"
)
row = [
res.batch_size,
res.input_len,
f"{res.latency:.2f}",
f"{res.input_throughput:.2f}",
f"{res.output_throughput:.2f}",
accept_length,
f"{itl_ms:.2f}",
f"{input_cost:.2f}",
f"{output_cost:.2f}",
cache_hit_rate,
]
if bench_args.profile:
if res.profile_link:
row.append(f"[Profile]({res.profile_link})")
else:
row.append("n/a")
rows.append(row)
summary += tabulate(rows, headers=headers, tablefmt="github")
summary += "\n"
return summary
def run_benchmark_internal(
server_args: ServerArgs,
bench_args: BenchArgs,
launch_server_func: Callable = launch_server,
):
# set random seed
random.seed(bench_args.seed)
np.random.seed(bench_args.seed)
# Resolve the benchmark target: launch a server, or connect to --base-url.
endpoint = acquire_endpoint(server_args, bench_args.base_url, launch_server_func)
base_url = endpoint.base_url
# Get tokenizer and server info
if bench_args.backend == "vllm":
# For vLLM, get model name from /v1/models endpoint
print(f"Connecting to vLLM server at {base_url}...")
response = requests.get(base_url + "/v1/models", timeout=DEFAULT_TIMEOUT)
response.raise_for_status()
model_list = response.json().get("data", [])
if not model_list:
raise RuntimeError("No models found on vLLM server via /v1/models")
model_name = model_list[0]["id"]
print(f"Found model: {model_name}")
print(f"Loading tokenizer for {model_name}...")
if bench_args.dataset_name == "mmmu":
tokenizer = get_processor(model_name)
else:
tokenizer = get_tokenizer(model_name)
print("Tokenizer loaded.")
server_info = {"model_name": model_name}
# vLLM does not expose token capacity or max running requests via API
skip_token_capacity_threshold = float("inf")
skip_max_running_requests_threshold = float("inf")
else:
model_name = None
response = requests.get(base_url + "/server_info", timeout=DEFAULT_TIMEOUT)
response.raise_for_status()
server_info = response.json()
if bench_args.local_tokenizer_path:
tokenizer_path = bench_args.local_tokenizer_path
elif "tokenizer_path" in server_info:
tokenizer_path = server_info["tokenizer_path"]
elif "prefill" in server_info:
tokenizer_path = server_info["prefill"][0]["tokenizer_path"]
if bench_args.dataset_name == "mmmu":
# mmmu implies this is a MLLM
tokenizer = get_processor(tokenizer_path)
else:
tokenizer = get_tokenizer(tokenizer_path)
internal_states = server_info.get("internal_states", [])
internal_state = internal_states[0] if internal_states else {}
dp_size = internal_state.get("dp_size", None) or 1
# Get effective max running requests
max_running_requests_per_dp = internal_state.get(
"effective_max_running_requests_per_dp", -1
)
# Get token capacity
skip_token_capacity_threshold = 0
for state in internal_states:
skip_token_capacity_threshold += state.get("memory_usage", {}).get(
"token_capacity", 1000000000
)
assert (
max_running_requests_per_dp > 0
), f"effective_max_running_requests_per_dp is not set, {max_running_requests_per_dp=}"
skip_max_running_requests_threshold = max_running_requests_per_dp * dp_size
print(f"{max_running_requests_per_dp=}")
print(f"{dp_size=}")
print(f"{skip_max_running_requests_threshold=}")
print(f"{skip_token_capacity_threshold=}")
# Under --enable-multi-batch the client intentionally sends more prompts
# than the server's running cap; surplus requests are queued (no KV
# reservation) and promoted batch-by-batch. Peak live KV footprint is
# bounded by the running cap, not by bs, so re-scope both guards:
# * max_running_requests: disabled (the whole point of the flag).
# * token_capacity: check against min(bs, running_cap) * (il + ol).
effective_running_cap: Optional[int] = None
if bench_args.enable_multi_batch:
if skip_max_running_requests_threshold != float("inf"):
effective_running_cap = skip_max_running_requests_threshold
skip_max_running_requests_threshold = float("inf")
# Multi-batch only kicks in when the client sends strictly more prompts
# than the server's running cap; otherwise every prompt fits in a
# single wave and the flag is a no-op for that case (but its metric
# caveats — misleading input/output throughput and TTFT — still apply).
# Warn loudly so the user can fix the batch-size sweep.
if effective_running_cap is not None:
noop_bs = sorted(
{bs for bs in bench_args.batch_size if bs <= effective_running_cap}
)
if noop_bs:
print(
f"WARNING: --enable-multi-batch is set but batch size(s) "
f"{noop_bs} are <= running cap ({effective_running_cap}); "
f"those cases will run as a single wave and the flag is a "
f"no-op for them. Use batch_size > {effective_running_cap} "
f"to actually exercise multi-batch."
)
# LoRA distribution args: mirror serving.py semantics so multi-LoRA
# benchmarks behave consistently across harnesses.
if bench_args.lora_request_distribution in ("distinct", "skewed"):
assert bench_args.lora_name is not None and len(bench_args.lora_name) > 1, (
"--lora-request-distribution=distinct/skewed requires more than "
"one adapter via --lora-name."
)
assert (
bench_args.lora_zipf_alpha > 1
), f"--lora-zipf-alpha must be > 1, got {bench_args.lora_zipf_alpha}"
if bench_args.apply_chat_template and not bench_args.fixed_prompt_file:
raise ValueError(
"--apply-chat-template requires --fixed-prompt-file: the other "
"datasets generate token ids directly, so there is no prompt text "
"to run through a chat template."
)
gsp_kwargs = dict(
gsp_num_groups=bench_args.gsp_num_groups,
gsp_system_prompt_len=bench_args.gsp_system_prompt_len,
gsp_question_len=bench_args.gsp_question_len,
gsp_output_len=bench_args.gsp_output_len,
)
# Warmup
if not bench_args.skip_warmup:
batch_size_unique = list(set(bench_args.batch_size))
print("=" * 8 + " Warmup Begin " + "=" * 8)
print(f"Warmup with batch_size={batch_size_unique}")
for bs in batch_size_unique:
run_one_case(
base_url,
batch_size=bs,
input_len=1024,
output_len=16,
temperature=bench_args.temperature,
return_logprob=bench_args.return_logprob,
stream_interval=bench_args.client_stream_interval,
input_len_step_percentage=bench_args.input_len_step_percentage,
run_name="",
result_filename="",
tokenizer=tokenizer,
dataset_name=bench_args.dataset_name,
dataset_path=bench_args.dataset_path,
parallel_batch=bench_args.parallel_batch,
backend=bench_args.backend,
model_name=model_name,
fake_prefill=bench_args.fake_prefill,
lora_name=bench_args.lora_name,
lora_request_distribution=bench_args.lora_request_distribution,
lora_zipf_alpha=bench_args.lora_zipf_alpha,
fixed_prompt_file=bench_args.fixed_prompt_file,
apply_chat_template=bench_args.apply_chat_template,
**gsp_kwargs,
)
print("=" * 8 + " Warmup End " + "=" * 8 + "\n")
results = []
profile_results = []
try:
# Benchmark all cases
for bs, il, ol in itertools.product(
bench_args.batch_size, bench_args.input_len, bench_args.output_len
):
kv_footprint_bs = (
bs if effective_running_cap is None else min(bs, effective_running_cap)
)
if should_skip_due_to_max_running_requests(
bs, skip_max_running_requests_threshold
) or should_skip_due_to_token_capacity(
kv_footprint_bs, il, ol, skip_token_capacity_threshold
):
continue
results.append(
run_one_case(
base_url,
bs,
il,
ol,
temperature=bench_args.temperature,
return_logprob=bench_args.return_logprob,
stream_interval=bench_args.client_stream_interval,
input_len_step_percentage=bench_args.input_len_step_percentage,
run_name=bench_args.run_name,
result_filename=bench_args.result_filename,
tokenizer=tokenizer,
dataset_name=bench_args.dataset_name,
dataset_path=bench_args.dataset_path,
parallel_batch=bench_args.parallel_batch,
cache_hit_rate=bench_args.cache_hit_rate,
backend=bench_args.backend,
model_name=model_name,
fake_prefill=bench_args.fake_prefill,
lora_name=bench_args.lora_name,
lora_request_distribution=bench_args.lora_request_distribution,
lora_zipf_alpha=bench_args.lora_zipf_alpha,
fixed_prompt_file=bench_args.fixed_prompt_file,
apply_chat_template=bench_args.apply_chat_template,
**gsp_kwargs,
)
)
# Profile all cases
if bench_args.profile:
try:
for bs, il, ol in itertools.product(
bench_args.batch_size, bench_args.input_len, bench_args.output_len
):
kv_footprint_bs = (
bs
if effective_running_cap is None
else min(bs, effective_running_cap)
)
if should_skip_due_to_max_running_requests(
bs, skip_max_running_requests_threshold
) or should_skip_due_to_token_capacity(
kv_footprint_bs, il, ol, skip_token_capacity_threshold
):
continue
profile_prefix = (
bench_args.profile_prefix or ""
) + f"bs-{bs}-il-{il}"
profile_results.append(
run_one_case(
base_url,
bs,
il,
ol,
temperature=bench_args.temperature,
return_logprob=bench_args.return_logprob,
stream_interval=bench_args.client_stream_interval,
input_len_step_percentage=bench_args.input_len_step_percentage,
run_name=bench_args.run_name,
result_filename=bench_args.result_filename,
tokenizer=tokenizer,
dataset_name=bench_args.dataset_name,
dataset_path=bench_args.dataset_path,
parallel_batch=bench_args.parallel_batch,
cache_hit_rate=bench_args.cache_hit_rate,
profile=bench_args.profile,
profile_activities=bench_args.profile_activities,
profile_start_step=bench_args.profile_start_step,
profile_steps=bench_args.profile_steps,
profile_by_stage=bench_args.profile_by_stage,
profile_prefix=profile_prefix,
profile_output_dir=bench_args.profile_output_dir,
backend=bench_args.backend,
model_name=model_name,
fake_prefill=bench_args.fake_prefill,
lora_name=bench_args.lora_name,
lora_request_distribution=bench_args.lora_request_distribution,
lora_zipf_alpha=bench_args.lora_zipf_alpha,
**gsp_kwargs,
)
)
except Exception as e:
print(f"Error profiling, some profile traces may not be dumped: {e}")
# Replace the profile link for any successful profile results
for res, profile_res in zip(results, profile_results, strict=False):
res.profile_link = profile_res.profile_link
finally:
endpoint.close()
print(f"\nResults are saved to {bench_args.result_filename}")
if not bench_args.show_report:
return results, server_info
# Print summary
summary = get_report_summary(results, bench_args, server_args)
print(summary)
if is_in_ci() and bench_args.append_to_github_summary:
write_github_step_summary(summary)
return results, server_info
def run_benchmark(server_args: ServerArgs, bench_args: BenchArgs):
results, server_info = run_benchmark_internal(server_args, bench_args)
# Save results as pydantic models in the JSON format
if bench_args.pydantic_result_filename:
save_results_as_pydantic_models(
results,
pydantic_result_filename=bench_args.pydantic_result_filename,
model_path=server_args.model_path,
server_args=bench_args.server_args_for_metrics,
)
return results, server_info
def cli_main():
parser = argparse.ArgumentParser()
ServerArgs.add_cli_args(parser)
BenchArgs.add_cli_args(parser)
args = parser.parse_args()
server_args = ServerArgs.from_cli_args(args)
bench_args = BenchArgs.from_cli_args(args)
run_benchmark(server_args, bench_args)
if __name__ == "__main__":
cli_main()