chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:16 +08:00
commit 94057c3d3e
7152 changed files with 2120455 additions and 0 deletions
@@ -0,0 +1,540 @@
"""
Benchmark offline throughput for multimodal generation models (Image/Video Generation).
This script benchmarks generation throughput without running a server, using low-level APIs.
It provides detailed metrics on throughput, latency, and resource utilization.
# Usage Examples
## Text-to-Video with VBench dataset
python -m sglang.multimodal_gen.benchmarks.bench_offline_throughput \\
--model-path Wan-AI/Wan2.1-T2V-1.3B-Diffusers \\
--dataset vbench \\
--num-prompts 20 \\
--batch-size 1 \\
--width 512 --height 512 --num-frames 16
## Random dataset for stress testing
python -m sglang.multimodal_gen.benchmarks.bench_offline_throughput \\
--model-path Wan-AI/Wan2.1-T2V-1.3B-Diffusers \\
--dataset random \\
--num-prompts 100 \\
--batch-size 1 \\
--num-inference-steps 20 \\
--output-file results.json
"""
import argparse
import dataclasses
import json
import time
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
import torch
from tqdm import tqdm
from sglang.multimodal_gen.benchmarks.datasets import RandomDataset, VBenchDataset
from sglang.multimodal_gen.runtime.entrypoints.diffusion_generator import DiffGenerator
from sglang.multimodal_gen.runtime.server_args import ServerArgs, set_global_server_args
from sglang.multimodal_gen.runtime.utils.logging_utils import (
configure_logger,
init_logger,
)
from sglang.multimodal_gen.test.test_utils import print_divider, print_value_formatted
logger = init_logger(__name__)
@dataclass
class BatchOutput:
"""Container for batch generation results."""
latency: float = 0.0
latency_per_sample: float = 0.0
num_samples: int = 0
total_frames: int = 0
peak_memory_mb: float = 0.0
success: bool = False
error: str = ""
@dataclass
class BenchArgs:
"""Benchmark configuration for multimodal generation."""
# Diffusion Model Configuration
num_inference_steps: int = 20
guidance_scale: float = 7.5
seed: int = 42
disable_safety_checker: bool = False
# Output Configuration
width: int = 32
height: int = 32
num_frames: int = 1
fps: int = 24
# Dataset & Benchmark
dataset: str = "random"
dataset_path: str = ""
task_name: str = "unknown"
num_prompts: int = 10
num_outputs_per_prompt: int = 1
batch_size: int = 1
random_request_config: str = None
random_request_seed: int = 42
# Benchmark Execution
skip_warmup: bool = False
output_file: str = ""
disable_tqdm: bool = False
# Profiling
profile: bool = False
num_profiled_timesteps: int = 5
profile_all_stages: bool = False
@staticmethod
def add_cli_args(parser: argparse.ArgumentParser):
"""Add benchmark-specific CLI arguments."""
# Diffusion Model Configuration
parser.add_argument(
"--num-inference-steps",
type=int,
default=20,
help="Number of denoising steps",
)
parser.add_argument(
"--guidance-scale",
type=float,
default=7.5,
help="Classifier-free guidance scale",
)
parser.add_argument("--seed", type=int, default=42, help="Random seed")
parser.add_argument(
"--disable-safety-checker",
action="store_true",
help="Disable NSFW detection",
)
# Output Configuration
parser.add_argument("--width", type=int, default=32, help="Image/video width")
parser.add_argument("--height", type=int, default=32, help="Image/video height")
parser.add_argument(
"--num-frames", type=int, default=1, help="Number of frames for video"
)
parser.add_argument("--fps", type=int, default=24, help="FPS for video")
# Dataset & Benchmark
parser.add_argument(
"--dataset",
type=str,
default="random",
choices=["vbench", "random"],
help="Dataset to use",
)
parser.add_argument(
"--dataset-path",
type=str,
default="",
help="Path to dataset (prompts file or image directory)",
)
parser.add_argument(
"--task-name",
type=str,
default="unknown",
help="Task name for benchmark identification",
)
parser.add_argument(
"--num-prompts",
type=int,
default=10,
help="Total number of prompts to benchmark",
)
parser.add_argument(
"--num-outputs-per-prompt",
type=int,
default=1,
help="Number of generated outputs requested per prompt",
)
parser.add_argument(
"--batch-size",
type=int,
default=1,
help="Batch size per generation call (currently only bs=1 is supported)",
)
parser.add_argument(
"--random-request-config",
type=str,
default=None,
help=(
"JSON string defining random request profiles. "
"Each profile may contain: width, height, num_inference_steps, etc. "
"The 'weight' field controls sampling probability (relative weight)."
),
)
parser.add_argument(
"--random-request-seed",
type=int,
default=42,
help="Random seed for sampling request profiles (default: 42).",
)
# Benchmark Execution
parser.add_argument(
"--skip-warmup", action="store_true", help="Skip warmup batch"
)
parser.add_argument(
"--output-file",
type=str,
default="",
help="Output JSON file for results (append mode)",
)
parser.add_argument(
"--disable-tqdm",
action="store_true",
help="Disable progress bar",
)
parser.add_argument(
"--profile",
action="store_true",
help=(
"Enable PyTorch profiler for diffusion generation. "
"Set SGLANG_DIFFUSION_TORCH_PROFILER_DIR to control trace output directory."
),
)
parser.add_argument(
"--num-profiled-timesteps",
type=int,
default=5,
help=(
"Number of denoising timesteps to profile after warmup. "
"Use -1 to profile all denoising timesteps."
),
)
parser.add_argument(
"--profile-all-stages",
action="store_true",
help="Profile all diffusion pipeline stages instead of only denoising steps.",
)
@classmethod
def from_cli_args(cls, args: argparse.Namespace):
"""Create BenchArgs from parsed CLI arguments."""
attrs = [attr.name for attr in dataclasses.fields(cls)]
return cls(**{attr: getattr(args, attr) for attr in attrs})
def initialize_engine(server_args: ServerArgs) -> DiffGenerator:
"""Initialize diffusion pipeline engine."""
logger.info("Initializing engine...")
engine = DiffGenerator.from_server_args(server_args, local_mode=True)
logger.info("Engine initialized successfully")
return engine
def generate_batch(
engine: DiffGenerator,
bench_args: BenchArgs,
prompts: List[str],
user_sampling_params: List[Dict[str, Any]],
) -> BatchOutput:
"""Generate batch of images/videos synchronously."""
assert len(user_sampling_params) == len(prompts), (
f"user_sampling_params length ({len(user_sampling_params)}) must match "
f"prompts length ({len(prompts)})"
)
output = BatchOutput()
start_time = time.perf_counter()
torch.get_device_module().reset_peak_memory_stats()
for prompt, params in zip(prompts, user_sampling_params):
try:
sampling_params_kwargs = dict(params)
sampling_params_kwargs["prompt"] = prompt
result = engine.generate(sampling_params_kwargs=sampling_params_kwargs)
if result is not None:
if isinstance(result, list):
output.total_frames += len(result)
else:
output.total_frames += 1
output.num_samples += 1
except Exception as e:
logger.error(f"Generation failed for prompt '{prompt[:50]}...': {e}")
output.error = str(e)
output.latency = time.perf_counter() - start_time
output.latency_per_sample = output.latency / len(prompts) if prompts else 0.0
output.success = output.num_samples > 0
output.peak_memory_mb = torch.get_device_module().max_memory_allocated() / (
1024 * 1024
)
logger.debug(
f"Batch generated: {output.num_samples}/{len(prompts)} samples in {output.latency:.2f}s"
)
return output
def calculate_metrics(
outputs: List[BatchOutput],
total_duration: float,
resolution: Tuple[int, int, int],
num_requests: int,
all_sampling_params: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
"""Calculate generation-specific throughput metrics."""
successful = [o for o in outputs if o.success]
num_success = sum(o.num_samples for o in successful)
total_frames = sum(o.total_frames for o in successful)
peak_memory = max((o.peak_memory_mb for o in outputs), default=0)
width, height, frames = resolution
if all_sampling_params:
total_pixels = sum(
p.get("width", width)
* p.get("height", height)
* p.get("num_frames", frames)
for p in all_sampling_params[:num_success]
)
else:
total_pixels = num_success * width * height * frames
metrics = {
"num_requests": num_requests,
"successful_requests": num_success,
"failed_requests": num_requests - num_success,
"total_duration_seconds": total_duration,
"total_frames_generated": total_frames,
"total_pixels_generated": total_pixels,
"images_per_second": num_success / total_duration if total_duration > 0 else 0,
"frames_per_second": total_frames / total_duration if total_duration > 0 else 0,
"megapixels_per_second": (
total_pixels / (total_duration * 1e6) if total_duration > 0 else 0
),
"requests_per_second": (
num_success / total_duration if total_duration > 0 else 0
),
"latency_per_request_seconds": (
total_duration / num_success if num_success > 0 else 0
),
"peak_memory_mb": peak_memory,
}
return metrics
def throughput_test(
server_args: ServerArgs,
bench_args: BenchArgs,
) -> Dict[str, Any]:
"""Main throughput benchmark function."""
configure_logger(server_args=server_args)
logger.info("Starting offline throughput benchmark...")
engine = initialize_engine(server_args)
if bench_args.random_request_config and bench_args.dataset != "random":
raise ValueError(
"--random-request-config can only be used with --dataset random"
)
if bench_args.num_outputs_per_prompt != 1:
raise ValueError(
"bench_offline_throughput currently supports only --num-outputs-per-prompt 1"
)
logger.info(f"Loading {bench_args.dataset} dataset...")
if bench_args.dataset == "vbench":
bench_args.task_name = str(engine.server_args.pipeline_config.task_type)
dataset = VBenchDataset(bench_args)
elif bench_args.dataset == "random":
dataset = RandomDataset(bench_args)
else:
raise ValueError(f"Unknown dataset: {bench_args.dataset}")
_sampling_params = {
"guidance_scale": bench_args.guidance_scale,
"num_inference_steps": bench_args.num_inference_steps,
"height": bench_args.height,
"width": bench_args.width,
"num_frames": bench_args.num_frames,
"num_outputs_per_prompt": bench_args.num_outputs_per_prompt,
"seed": bench_args.seed,
"profile": bench_args.profile,
"num_profiled_timesteps": bench_args.num_profiled_timesteps,
"profile_all_stages": bench_args.profile_all_stages,
}
if bench_args.disable_safety_checker:
_sampling_params["safety_checker"] = None
total_count = min(bench_args.num_prompts, len(dataset))
all_prompts = [dataset[i].prompt for i in range(total_count)]
if bench_args.random_request_config:
all_sampling_params = []
for i in range(total_count):
params = dict(_sampling_params)
params.update(dataset.get_sampling_params(i))
all_sampling_params.append(params)
else:
all_sampling_params = [_sampling_params] * total_count
if not bench_args.skip_warmup:
logger.info("Running warmup batch...")
warmup_count = min(bench_args.batch_size, total_count)
warmup_prompts = all_prompts[:warmup_count]
warmup_sampling_params = [
{**p, "profile": False} for p in all_sampling_params[:warmup_count]
]
generate_batch(engine, bench_args, warmup_prompts, warmup_sampling_params)
logger.info(f"Running benchmark with {bench_args.num_prompts} prompts...")
outputs: List[BatchOutput] = []
start_time = time.perf_counter()
num_batches = (total_count + bench_args.batch_size - 1) // bench_args.batch_size
pbar = tqdm(
total=num_batches,
disable=bench_args.disable_tqdm,
desc="Benchmark",
)
for batch_start in range(0, total_count, bench_args.batch_size):
batch_end = min(batch_start + bench_args.batch_size, total_count)
batch_prompts = all_prompts[batch_start:batch_end]
batch_sampling_params = all_sampling_params[batch_start:batch_end]
batch_output = generate_batch(
engine, bench_args, batch_prompts, batch_sampling_params
)
outputs.append(batch_output)
pbar.update(1)
pbar.close()
total_duration = time.perf_counter() - start_time
resolution = (bench_args.width, bench_args.height, bench_args.num_frames)
metrics = calculate_metrics(
outputs,
total_duration,
resolution=resolution,
num_requests=total_count,
all_sampling_params=all_sampling_params,
)
display_results(
metrics,
bench_args,
model_path=server_args.model_path,
)
if bench_args.output_file:
save_results(metrics, bench_args, server_args)
return metrics
def display_results(
metrics: Dict[str, Any],
bench_args: BenchArgs,
model_path: str,
):
"""Display benchmark results in console."""
print(
"\n{s:{c}^{n}}".format(s=" Offline Throughput Benchmark Result ", n=110, c="=")
)
print_value_formatted("Model:", model_path)
print_value_formatted("Dataset:", bench_args.dataset)
print_value_formatted(
"Resolution:",
f"{bench_args.width}x{bench_args.height}x{bench_args.num_frames}",
)
print_value_formatted("Num Inference Steps:", bench_args.num_inference_steps)
print_divider(75)
print_value_formatted("Total Requests:", metrics["num_requests"])
print_value_formatted("Successful Requests:", metrics["successful_requests"])
print_value_formatted("Failed Requests:", metrics["failed_requests"])
print_value_formatted(
"Total Duration (seconds):", metrics["total_duration_seconds"]
)
print_divider(75)
print_value_formatted("Frames Generated:", metrics["total_frames_generated"])
print_value_formatted(
"Megapixels Generated:", metrics["total_pixels_generated"] / 1e6
)
print_divider(75)
print_value_formatted(
"Frame Throughput (frames/sec):", metrics["frames_per_second"]
)
print_value_formatted("MP Throughput (MP/sec):", metrics["megapixels_per_second"])
print_value_formatted("Requests Per Second:", metrics["requests_per_second"])
print_value_formatted(
"Latency Per Request (sec):", metrics["latency_per_request_seconds"]
)
print_value_formatted("Peak Memory (MB):", metrics["peak_memory_mb"])
print_divider(110, "=")
def save_results(
metrics: Dict[str, Any],
bench_args: BenchArgs,
server_args: ServerArgs,
):
"""Save benchmark results to JSON file."""
result = {
"metadata": {
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
"model_path": server_args.model_path,
"task_type": bench_args.task_name,
"backend": "engine",
},
"configuration": {
"num_inference_steps": bench_args.num_inference_steps,
"guidance_scale": bench_args.guidance_scale,
"seed": bench_args.seed,
"batch_size": bench_args.batch_size,
"num_prompts": bench_args.num_prompts,
"resolution": f"{bench_args.width}x{bench_args.height}x{bench_args.num_frames}",
"dataset": bench_args.dataset,
},
"results": metrics,
}
with open(bench_args.output_file, "a") as f:
f.write(json.dumps(result) + "\n")
logger.info(f"Results saved to {bench_args.output_file}")
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Offline throughput benchmark for multimodal generation models"
)
ServerArgs.add_cli_args(parser)
BenchArgs.add_cli_args(parser)
args, unknown_args = parser.parse_known_args()
server_args = ServerArgs.from_cli_args(args, unknown_args)
bench_args = BenchArgs.from_cli_args(args)
set_global_server_args(server_args)
result = throughput_test(server_args, bench_args)
return result
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,842 @@
"""
Benchmark online serving for diffusion models (Image/Video Generation).
Usage:
# launch a server and benchmark on it
# T2V or T2I or any other multimodal generation model
sglang serve --model-path Wan-AI/Wan2.2-T2V-A14B-Diffusers --num-gpus 1 --port 1231
# benchmark it and make sure the port is the same as the server's port
python3 -m sglang.multimodal_gen.benchmarks.bench_serving --dataset vbench --num-prompts 20 --port 1231
# benchmark with SLO metrics enabled
python3 -m sglang.multimodal_gen.benchmarks.bench_serving --dataset vbench --num-prompts 20 --port 1231 --slo --slo-scale 3.0 --warmup-requests 2
"""
import argparse
import asyncio
import json
import os
import time
from dataclasses import replace
from typing import Any, Dict, List, Optional
import aiohttp
import numpy as np
import requests
from tqdm.asyncio import tqdm
from sglang.multimodal_gen.benchmarks.datasets import (
RandomDataset,
RequestFuncInput,
RequestFuncOutput,
VBenchDataset,
)
from sglang.multimodal_gen.runtime.utils.logging_utils import (
configure_logger,
init_logger,
)
from sglang.multimodal_gen.test.test_utils import print_divider, print_value_formatted
from sglang.srt.utils.network import NetworkAddress
logger = init_logger(__name__)
# Patch size used for computing area units (e.g. in latent diffusion models).
PATCH_SIZE = 16
PATCH_AREA = PATCH_SIZE * PATCH_SIZE
def _get_response_output_count(resp_json: Dict[str, Any]) -> int:
if isinstance(resp_json.get("num_outputs"), int):
return resp_json["num_outputs"]
if isinstance(resp_json.get("data"), list):
return len(resp_json["data"])
if isinstance(resp_json.get("file_paths"), list):
return len(resp_json["file_paths"])
if isinstance(resp_json.get("urls"), list):
return len(resp_json["urls"])
if resp_json.get("file_path") or resp_json.get("url"):
return 1
return 0
def _compute_scale_factor(req: RequestFuncInput, args) -> Optional[float]:
"""Computes the composite scale factor (area × frames × steps) for a request."""
width = req.width or args.width
height = req.height or args.height
if None in (width, height):
return None
frames = req.num_frames or args.num_frames
steps = req.num_inference_steps or args.num_inference_steps
frame_scale = frames if isinstance(frames, int) and frames > 0 else 1
step_scale = steps if isinstance(steps, int) and steps > 0 else 1
area_units = max((float(width) * float(height)) / float(PATCH_AREA), 1.0)
return area_units * float(frame_scale) * float(step_scale)
def _compute_expected_latency_ms_from_base(
req: RequestFuncInput, args, base_time_ms: Optional[float]
) -> Optional[float]:
"""Scales latency linearly by pixel area, frame count, and inference steps."""
if base_time_ms is None:
return None
scale = _compute_scale_factor(req, args)
if scale is None:
return None
return float(base_time_ms) * scale
def _infer_slo_base_time_ms_from_warmups(
warmup_pairs: List[tuple], args
) -> Optional[float]:
"""Derives median base latency from successful warmup runs."""
candidates_ms: List[float] = []
for req, out in warmup_pairs:
if not out.success or out.latency <= 0:
logger.warning(
f"Skipping warmup result: success={out.success}, latency={out.latency:.3f}"
)
continue
scale = _compute_scale_factor(req, args)
if scale is None or scale <= 0:
continue
candidates_ms.append((out.latency * 1000.0) / scale)
return float(np.median(candidates_ms)) if candidates_ms else None
def _populate_slo_ms_from_warmups(
requests_list: List[RequestFuncInput], warmup_pairs: List[tuple], args
) -> List[RequestFuncInput]:
"""Assigns estimated SLO targets to requests lacking them."""
if not any(req.slo_ms is None for req in requests_list):
return requests_list
base_time_ms = _infer_slo_base_time_ms_from_warmups(warmup_pairs, args)
if base_time_ms is None:
return requests_list
slo_scale = float(getattr(args, "slo_scale", 3.0))
if slo_scale <= 0:
raise ValueError(f"slo_scale must be positive, got {slo_scale}.")
updated: List[RequestFuncInput] = []
for req in requests_list:
if req.slo_ms is not None:
updated.append(req)
continue
expected_ms = _compute_expected_latency_ms_from_base(req, args, base_time_ms)
if expected_ms is not None:
# Create a new RequestFuncInput with updated slo_ms
updated.append(replace(req, slo_ms=expected_ms * slo_scale))
else:
updated.append(req)
return updated
async def async_request_image_sglang(
input: RequestFuncInput,
session: aiohttp.ClientSession,
pbar: Optional[tqdm] = None,
) -> RequestFuncOutput:
output = RequestFuncOutput()
output.start_time = time.perf_counter()
# Check if we need to use multipart (for image edits with input images)
if input.image_paths and len(input.image_paths) > 0:
# Use multipart/form-data for image edits
data = aiohttp.FormData()
data.add_field("model", input.model)
data.add_field("prompt", input.prompt)
data.add_field("response_format", "b64_json")
data.add_field("n", str(input.num_outputs_per_prompt))
if input.width and input.height:
data.add_field("size", f"{input.width}x{input.height}")
# Merge extra parameters
for key, value in input.extra_body.items():
data.add_field(key, str(value))
# Add image file(s)
for idx, img_path in enumerate(input.image_paths):
if os.path.exists(img_path):
data.add_field(
"image",
open(img_path, "rb"),
filename=os.path.basename(img_path),
content_type="application/octet-stream",
)
else:
output.error = f"Image file not found: {img_path}"
output.success = False
if pbar:
pbar.update(1)
return output
try:
async with session.post(input.api_url, data=data) as response:
if response.status == 200:
resp_json = await response.json()
output.response_body = resp_json
output.success = True
output.output_count = _get_response_output_count(resp_json)
if "peak_memory_mb" in resp_json:
output.peak_memory_mb = resp_json["peak_memory_mb"]
else:
output.error = f"HTTP {response.status}: {await response.text()}"
output.success = False
except Exception as e:
output.error = str(e)
output.success = False
else:
# Use JSON for text-to-image generation
payload = {
"model": input.model,
"prompt": input.prompt,
"n": input.num_outputs_per_prompt,
"response_format": "b64_json",
}
if input.width and input.height:
payload["size"] = f"{input.width}x{input.height}"
if input.num_inference_steps:
payload["num_inference_steps"] = input.num_inference_steps
# Merge extra parameters
payload.update(input.extra_body)
try:
async with session.post(input.api_url, json=payload) as response:
if response.status == 200:
resp_json = await response.json()
output.response_body = resp_json
output.success = True
output.output_count = _get_response_output_count(resp_json)
if "peak_memory_mb" in resp_json:
output.peak_memory_mb = resp_json["peak_memory_mb"]
else:
output.error = f"HTTP {response.status}: {await response.text()}"
output.success = False
except Exception as e:
output.error = str(e)
output.success = False
output.latency = time.perf_counter() - output.start_time
# Check SLO if defined
if input.slo_ms is not None and output.success:
output.slo_achieved = (output.latency * 1000.0) <= input.slo_ms
if pbar:
pbar.update(1)
return output
async def async_request_video_sglang(
input: RequestFuncInput,
session: aiohttp.ClientSession,
pbar: Optional[tqdm] = None,
) -> RequestFuncOutput:
output = RequestFuncOutput()
output.start_time = time.perf_counter()
# 1. Submit Job
job_id = None
# Check if we need to upload images (Multipart) or just send JSON
if input.image_paths and len(input.image_paths) > 0:
# Use multipart/form-data
data = aiohttp.FormData()
data.add_field("model", input.model)
data.add_field("prompt", input.prompt)
data.add_field("num_outputs_per_prompt", str(input.num_outputs_per_prompt))
if input.width and input.height:
data.add_field("size", f"{input.width}x{input.height}")
# Add extra body fields to form data if possible, or assume simple key-values
# Note: Nested dicts in extra_body might need JSON serialization if API expects it stringified
if input.extra_body:
data.add_field("extra_body", json.dumps(input.extra_body))
# Explicitly add fps/num_frames if they are not in extra_body (bench_serving logic overrides)
if input.num_frames:
data.add_field("num_frames", str(input.num_frames))
if input.fps:
data.add_field("fps", str(input.fps))
# Add image file
# Currently only support single image upload as 'input_reference' per API spec
img_path = input.image_paths[0]
if os.path.exists(img_path):
data.add_field(
"input_reference",
open(img_path, "rb"),
filename=os.path.basename(img_path),
content_type="application/octet-stream",
)
else:
output.error = f"Image file not found: {img_path}"
output.success = False
if pbar:
pbar.update(1)
return output
try:
async with session.post(input.api_url, data=data) as response:
if response.status == 200:
resp_json = await response.json()
job_id = resp_json.get("id")
else:
output.error = (
f"Submit failed HTTP {response.status}: {await response.text()}"
)
output.success = False
if pbar:
pbar.update(1)
return output
except Exception as e:
output.error = f"Submit exception: {str(e)}"
output.success = False
if pbar:
pbar.update(1)
return output
else:
# Use JSON
payload: Dict[str, Any] = {
"model": input.model,
"prompt": input.prompt,
"num_outputs_per_prompt": input.num_outputs_per_prompt,
}
if input.width and input.height:
payload["size"] = f"{input.width}x{input.height}"
if input.num_frames:
payload["num_frames"] = input.num_frames
if input.num_inference_steps:
payload["num_inference_steps"] = input.num_inference_steps
if input.fps:
payload["fps"] = input.fps
payload.update(input.extra_body)
try:
async with session.post(input.api_url, json=payload) as response:
if response.status == 200:
resp_json = await response.json()
job_id = resp_json.get("id")
else:
output.error = (
f"Submit failed HTTP {response.status}: {await response.text()}"
)
output.success = False
if pbar:
pbar.update(1)
return output
except Exception as e:
output.error = f"Submit exception: {str(e)}"
output.success = False
if pbar:
pbar.update(1)
return output
if not job_id:
output.error = "No job_id returned"
output.success = False
if pbar:
pbar.update(1)
return output
# 2. Poll for completion
# Assuming the API returns a 'status' field.
# We construct the check URL. Assuming api_url is like .../v1/videos
# The check url should be .../v1/videos/{id}
check_url = f"{input.api_url}/{job_id}"
while True:
try:
async with session.get(check_url) as response:
if response.status == 200:
status_data = await response.json()
status = status_data.get("status")
if status == "completed":
output.success = True
output.response_body = status_data
output.output_count = _get_response_output_count(status_data)
if "peak_memory_mb" in status_data:
output.peak_memory_mb = status_data["peak_memory_mb"]
break
elif status == "failed":
output.success = False
output.error = f"Job failed: {status_data.get('error')}"
break
else:
# queued or processing
await asyncio.sleep(1.0)
else:
output.success = False
output.error = (
f"Poll failed HTTP {response.status}: {await response.text()}"
)
break
except Exception as e:
output.success = False
output.error = f"Poll exception: {str(e)}"
break
output.latency = time.perf_counter() - output.start_time
# Check SLO if defined
if input.slo_ms is not None and output.success:
output.slo_achieved = (output.latency * 1000.0) <= input.slo_ms
if pbar:
pbar.update(1)
return output
def calculate_metrics(
outputs: List[RequestFuncOutput],
total_duration: float,
requests_list: List[RequestFuncInput],
args,
slo_enabled: bool,
):
success_outputs = [o for o in outputs if o.success]
error_outputs = [o for o in outputs if not o.success]
num_success = len(success_outputs)
latencies = [o.latency for o in success_outputs]
completed_outputs = sum(o.output_count for o in success_outputs)
peak_memories = [
o.peak_memory_mb
for o in success_outputs
if o.peak_memory_mb is not None and o.peak_memory_mb > 0
]
metrics = {
"duration": total_duration,
"completed_requests": num_success,
"completed_outputs": completed_outputs,
"failed_requests": len(error_outputs),
"throughput_qps": num_success / total_duration if total_duration > 0 else 0,
"output_throughput_ops": (
completed_outputs / total_duration if total_duration > 0 else 0
),
"latency_mean": np.mean(latencies) if latencies else 0,
"latency_median": np.median(latencies) if latencies else 0,
"latency_p50": np.percentile(latencies, 50) if latencies else 0,
"latency_p90": np.percentile(latencies, 90) if latencies else 0,
"latency_p95": np.percentile(latencies, 95) if latencies else 0,
"latency_p99": np.percentile(latencies, 99) if latencies else 0,
"num_outputs_per_prompt": args.num_outputs_per_prompt,
"peak_memory_mb_max": max(peak_memories) if peak_memories else 0,
"peak_memory_mb_mean": np.mean(peak_memories) if peak_memories else 0,
"peak_memory_mb_median": np.median(peak_memories) if peak_memories else 0,
}
if slo_enabled:
slo_defined_total = 0
slo_met_success = 0
for req, out in zip(requests_list, outputs):
if req.slo_ms is None:
continue
slo_defined_total += 1
if out.slo_achieved:
slo_met_success += 1
slo_attain_all = (
(slo_met_success / slo_defined_total) if slo_defined_total > 0 else 0.0
)
metrics.update(
{
"slo_attainment_rate": slo_attain_all,
"slo_met_success": slo_met_success,
"slo_scale": getattr(args, "slo_scale", 3.0),
}
)
return metrics
def wait_for_service(base_url: str, timeout: int = 1200) -> None:
logger.info(f"Waiting for service at {base_url}...")
start_time = time.time()
while True:
try:
# Try /health endpoint first
resp = requests.get(f"{base_url}/health", timeout=1)
if resp.status_code == 200:
logger.info("Service is ready.")
break
except requests.exceptions.RequestException:
pass
if time.time() - start_time > timeout:
raise TimeoutError(
f"Service at {base_url} did not start within {timeout} seconds."
)
time.sleep(1)
async def benchmark(args):
from huggingface_hub import model_info
# Construct base_url if not provided
if args.base_url is None:
args.base_url = NetworkAddress(args.host, args.port).to_url()
# Wait for service
wait_for_service(args.base_url)
# Fetch model info
try:
resp = requests.get(f"{args.base_url}/v1/model_info", timeout=5)
if resp.status_code == 200:
info = resp.json()
if "model_path" in info and info["model_path"]:
args.model = info["model_path"]
logger.info(f"Updated model name from server: {args.model}")
except Exception as e:
logger.info(f"Failed to fetch model info: {e}. Using default: {args.model}")
valid_tasks = (
"text-to-video",
"image-to-video",
"video-to-video",
"text-to-image",
"image-to-image",
)
# Resolve task_name with priority: args.task > local config > HF pipeline_tag
if args.task:
task_name = args.task
logger.info(f"Using task from --task: {task_name}")
elif os.path.exists(args.model):
config_path = os.path.join(args.model, "config.json")
if os.path.exists(config_path):
with open(config_path, "r") as f:
config = json.load(f)
task_name = config.get("pipeline_tag", "text-to-image")
logger.info(f"Inferred task from local config.json: {task_name}")
else:
task_name = "text-to-image"
logger.info(f"No config.json found, defaulting task to: {task_name}")
else:
task_name = model_info(args.model).pipeline_tag
logger.info(f"Inferred task from HuggingFace pipeline_tag: {task_name}")
if task_name not in valid_tasks:
raise ValueError(
f"Task '{task_name}' is not a valid multimodal generation task. "
f"Use --task to specify one of: {', '.join(valid_tasks)}"
)
if task_name in ("text-to-video", "image-to-video", "video-to-video"):
api_url = f"{args.base_url}/v1/videos"
request_func = async_request_video_sglang
else: # text-to-image or image-to-image
api_url = (
f"{args.base_url}/v1/images/edits"
if task_name == "image-to-image"
else f"{args.base_url}/v1/images/generations"
)
request_func = async_request_image_sglang
setattr(args, "task_name", task_name)
if args.random_request_config and args.dataset != "random":
raise ValueError(
"--random-request-config can only be used with --dataset random"
)
if args.dataset == "vbench":
dataset = VBenchDataset(args, api_url, args.model)
elif args.dataset == "random":
dataset = RandomDataset(args, api_url, args.model)
else:
raise ValueError(f"Unknown dataset: {args.dataset}")
logger.info("Loading requests...")
requests_list = dataset.get_requests()
logger.info(f"Prepared {len(requests_list)} requests from {args.dataset} dataset.")
# Limit concurrency
if args.max_concurrency is not None:
semaphore = asyncio.Semaphore(args.max_concurrency)
else:
semaphore = None
async def limited_request_func(req, session, pbar):
if semaphore:
async with semaphore:
return await request_func(req, session, pbar)
else:
return await request_func(req, session, pbar)
async with aiohttp.ClientSession() as session:
# Run warmup requests
warmup_pairs: List[tuple] = []
if args.warmup_requests and requests_list:
# The server always overrides warmup requests to use
# num_inference_steps=1 (see Req.set_as_warmup), so we match
# that here to keep the benchmark's SLO estimation consistent.
warmup_steps = 1
logger.info(
f"Running {args.warmup_requests} warmup request(s) with "
f"num_inference_steps={warmup_steps}..."
)
for i in range(args.warmup_requests):
warm_req = requests_list[i % len(requests_list)]
warm_req = replace(
warm_req,
num_inference_steps=warmup_steps,
)
warm_out = await limited_request_func(warm_req, session, None)
warmup_pairs.append((warm_req, warm_out))
logger.info(
f"Warmup {i+1}/{args.warmup_requests}: "
f"latency={warm_out.latency:.2f}s, success={warm_out.success}"
)
# Populate SLO values from warmups if enabled
if args.slo:
requests_list = _populate_slo_ms_from_warmups(
requests_list=requests_list, warmup_pairs=warmup_pairs, args=args
)
# Run benchmark
pbar = tqdm(total=len(requests_list), disable=args.disable_tqdm)
start_time = time.perf_counter()
tasks = []
for req in requests_list:
if args.request_rate != float("inf"):
# Poisson process: inter-arrival times follow exponential distribution
interval = np.random.exponential(1.0 / args.request_rate)
await asyncio.sleep(interval)
task = asyncio.create_task(limited_request_func(req, session, pbar))
tasks.append(task)
outputs = await asyncio.gather(*tasks)
total_duration = time.perf_counter() - start_time
pbar.close()
# Calculate metrics
metrics = calculate_metrics(outputs, total_duration, requests_list, args, args.slo)
print("\n{s:{c}^{n}}".format(s=" Serving Benchmark Result ", n=60, c="="))
# Section 1: Configuration
print_value_formatted("Task:", task_name)
print_value_formatted("Model:", args.model)
print_value_formatted("Dataset:", args.dataset)
# Section 2: Execution & Traffic
print_divider(50)
print_value_formatted("Benchmark duration (s):", metrics["duration"])
print_value_formatted("Request rate:", str(args.request_rate))
print_value_formatted(
"Max request concurrency:",
str(args.max_concurrency) if args.max_concurrency else "not set",
)
print_value_formatted(
"Successful requests:",
f"{metrics['completed_requests']}/{len(requests_list)}",
)
print_value_formatted("Completed outputs:", metrics["completed_outputs"])
print_value_formatted("Outputs per prompt:", metrics["num_outputs_per_prompt"])
# Section 3: Performance Metrics
print_divider(50)
print_value_formatted("Request throughput (req/s):", metrics["throughput_qps"])
print_value_formatted(
"Output throughput (outputs/s):", metrics["output_throughput_ops"]
)
print_value_formatted("Latency Mean (s):", metrics["latency_mean"])
print_value_formatted("Latency Median (s):", metrics["latency_median"])
print_value_formatted("Latency P90 (s):", metrics["latency_p90"])
print_value_formatted("Latency P95 (s):", metrics["latency_p95"])
print_value_formatted("Latency P99 (s):", metrics["latency_p99"])
if metrics["peak_memory_mb_max"] > 0:
print_divider(50)
print_value_formatted("Peak Memory Max (MB):", metrics["peak_memory_mb_max"])
print_value_formatted("Peak Memory Mean (MB):", metrics["peak_memory_mb_mean"])
print_value_formatted(
"Peak Memory Median (MB):", metrics["peak_memory_mb_median"]
)
if args.slo and "slo_attainment_rate" in metrics:
print_divider(50)
print(
"{:<40} {:<15.2%}".format(
"SLO Attainment Rate:", metrics["slo_attainment_rate"]
)
)
print("{:<40} {:<15}".format("SLO Met (Success):", metrics["slo_met_success"]))
print("{:<40} {:<15.2f}".format("SLO Scale:", metrics["slo_scale"]))
print_divider(60)
if args.output_file:
with open(args.output_file, "w") as f:
json.dump(metrics, f, indent=2)
print(f"Metrics saved to {args.output_file}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Benchmark serving for diffusion models."
)
parser.add_argument(
"--backend",
type=str,
default=None,
help="DEPRECATED: --task is deprecated and will be ignored. The task will be inferred from --model.",
)
parser.add_argument(
"--base-url",
type=str,
default=None,
help="Base URL of the server (e.g., http://localhost:30000). Overrides host/port.",
)
parser.add_argument("--host", type=str, default="localhost", help="Server host.")
parser.add_argument("--port", type=int, default=30000, help="Server port.")
parser.add_argument("--model", type=str, default="default", help="Model name.")
parser.add_argument(
"--dataset",
type=str,
default="vbench",
choices=["vbench", "random"],
help="Dataset to use.",
)
parser.add_argument(
"--task",
type=str,
choices=[
"text-to-video",
"image-to-video",
"text-to-image",
"image-to-image",
"video-to-video",
],
default=None,
help="The task will be inferred from huggingface pipeline_tag. When huggingface pipeline_tag is not provided, --task will be used.",
)
parser.add_argument(
"--dataset-path",
type=str,
default=None,
help="Path to local dataset file (optional).",
)
parser.add_argument(
"--num-prompts", type=int, default=10, help="Number of prompts to benchmark."
)
parser.add_argument(
"--num-outputs-per-prompt",
type=int,
default=1,
help="Number of generated outputs requested per prompt.",
)
parser.add_argument(
"--max-concurrency",
type=int,
default=1,
help="Maximum number of concurrent requests, default to `1`. This can be used "
"to help simulate an environment where a higher level component "
"is enforcing a maximum number of concurrent requests. While the "
"--request-rate argument controls the rate at which requests are "
"initiated, this argument will control how many are actually allowed "
"to execute at a time. This means that when used in combination, the "
"actual request rate may be lower than specified with --request-rate, "
"if the server is not processing requests fast enough to keep up.",
)
parser.add_argument(
"--request-rate",
type=float,
default=float("inf"),
help="Number of requests per second. If this is inf, then all the requests are sent at time 0. "
"Otherwise, we use Poisson process to synthesize the request arrival times. Default is inf.",
)
parser.add_argument("--width", type=int, default=None, help="Image/Video width.")
parser.add_argument("--height", type=int, default=None, help="Image/Video height.")
parser.add_argument(
"--random-request-config",
type=str,
default=None,
help=(
"JSON string defining random request profiles. "
"Each profile may contain: width, height, num_inference_steps, "
"num_outputs_per_prompt, etc. "
"The 'weight' field controls sampling probability (relative weight). "
"Example: "
'[{"width":512,"height":512,"num_inference_steps":20,"weight":0.15},'
'{"width":768,"height":768,"num_inference_steps":20,"weight":0.85}]'
),
)
parser.add_argument(
"--random-request-seed",
type=int,
default=42,
help="Random seed for sampling request profiles (default: 42).",
)
parser.add_argument(
"--num-frames", type=int, default=None, help="Number of frames (for video)."
)
parser.add_argument("--fps", type=int, default=None, help="FPS (for video).")
parser.add_argument(
"--output-file", type=str, default=None, help="Output JSON file for metrics."
)
parser.add_argument(
"--disable-tqdm", action="store_true", help="Disable progress bar."
)
parser.add_argument(
"--log-level",
type=str,
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Log level.",
)
parser.add_argument(
"--slo",
action="store_true",
help="Enable SLO calculation. Uses trace-provided slo_ms or infers from warmups.",
)
parser.add_argument(
"--slo-scale",
type=float,
default=3.0,
help="SLO target multiplier: slo_ms = estimated_exec_time_ms * slo_scale (default: 3).",
)
parser.add_argument(
"--warmup-requests",
type=int,
default=1,
help="Number of warmup requests to run before measurement.",
)
parser.add_argument(
"--num-inference-steps",
type=int,
default=None,
help="Number of inference steps for diffusion models.",
)
args = parser.parse_args()
configure_logger(args)
asyncio.run(benchmark(args))
@@ -0,0 +1,301 @@
import argparse
import json
import os
import re
from datetime import datetime
from typing import Any, Dict, List, Tuple
def calculate_diff(base: float, new: float) -> Tuple[float, float]:
"""Returns (diff, diff_percent)."""
diff = new - base
if base == 0:
percent = 0.0
else:
percent = (diff / base) * 100
return diff, percent
def calculate_upper_bound(baseline: float, rel_tol: float, min_abs_tol: float) -> float:
"""Calculates the upper bound for performance regression check."""
rel_limit = baseline * (1 + rel_tol)
abs_limit = baseline + min_abs_tol
return max(rel_limit, abs_limit)
def calculate_lower_bound(baseline: float, rel_tol: float, min_abs_tol: float) -> float:
"""Calculates the lower bound for performance improvement check."""
rel_lower = baseline * (1 - rel_tol)
abs_lower = baseline - min_abs_tol
return min(rel_lower, abs_lower)
def get_perf_status_emoji(
baseline: float,
new: float,
rel_tol: float = 0.1,
min_abs_tol: float = 120.0,
) -> str:
"""
Determines the status emoji based on performance difference.
Logic:
Upper bound (Slower): max(baseline * (1 + rel_tol), baseline + min_abs_tol)
Lower bound (Faster): min(baseline * (1 - rel_tol), baseline - min_abs_tol)
"""
upper_bound = calculate_upper_bound(baseline, rel_tol, min_abs_tol)
lower_bound = calculate_lower_bound(baseline, rel_tol, min_abs_tol)
if new > upper_bound:
return "🔴"
elif new < lower_bound:
return "🟢"
else:
return "⚪️"
def consolidate_steps(
steps_list: List[Dict[str, Any]],
) -> Tuple[Dict[str, float], List[str], Dict[str, int]]:
"""
Aggregates specific repeating steps (like denoising_step_*) into groups.
Returns:
- aggregated_durations: {name: duration_ms}
- ordered_names: list of names in execution order
- counts: {name: count_of_steps_aggregated}
"""
durations = {}
counts = {}
ordered_names = []
seen_names = set()
# Regex for steps to group
# Group "denoising_step_0", "denoising_step_1" -> "Denoising Loop"
denoise_pattern = re.compile(r"^denoising_step_(\d+)$")
denoising_group_name = "Denoising Loop"
for step in steps_list:
name = step.get("name", "unknown")
dur = step.get("duration_ms", 0.0)
match = denoise_pattern.match(name)
if match:
key = denoising_group_name
if key not in durations:
durations[key] = 0.0
counts[key] = 0
if key not in seen_names:
ordered_names.append(key)
seen_names.add(key)
durations[key] += dur
counts[key] += 1
else:
# Standard stage (preserve order)
if name not in durations:
durations[name] = 0.0
counts[name] = 0
if name not in seen_names:
ordered_names.append(name)
seen_names.add(name)
durations[name] += dur
counts[name] += 1
return durations, ordered_names, counts
def _load_benchmark_file(file_path: str) -> Dict[str, Any]:
"""Loads a benchmark JSON file."""
with open(file_path, "r", encoding="utf-8") as f:
return json.load(f)
def _get_status_emoji_from_diff_percent(diff_pct):
if diff_pct < -2.0:
return ""
elif diff_pct > 2.0:
return ""
else:
return "⚪️"
def _print_single_comparison_report(
others_data, base_e2e, combined_order, base_durations, others_processed, base_counts
):
new_data = others_data[0]
new_e2e = new_data.get("total_duration_ms", 0)
diff_ms, diff_pct = calculate_diff(base_e2e, new_e2e)
status = _get_status_emoji_from_diff_percent(diff_pct)
print("#### 1. High-level Summary")
print("| Metric | Baseline | New | Diff | Status |")
print("| :--- | :--- | :--- | :--- | :--- |")
print(
f"| **E2E Latency** | {base_e2e:.2f} ms | {new_e2e:.2f} ms | **{diff_ms:+.2f} ms ({diff_pct:+.1f}%)** | {status} |"
)
print(
f"| **Throughput** | {1000 / base_e2e if base_e2e else 0:.2f} req/s | {1000 / new_e2e if new_e2e else 0:.2f} req/s | - | - |"
)
print("\n")
print("#### 2. Stage Breakdown")
print("| Stage Name | Baseline (ms) | New (ms) | Diff (ms) | Diff (%) | Status |")
print("| :--- | :--- | :--- | :--- | :--- | :--- |")
new_durations, _, new_counts = others_processed[0]
for stage in combined_order:
b_val = base_durations.get(stage, 0.0)
n_val = new_durations.get(stage, 0.0)
b_count = base_counts.get(stage, 1)
n_count = new_counts.get(stage, 1)
s_diff, s_pct = calculate_diff(b_val, n_val)
count_str = ""
if stage == "Denoising Loop":
count_str = (
f" ({n_count} steps)"
if n_count == b_count
else f" ({b_count}->{n_count} steps)"
)
status_emoji = get_perf_status_emoji(b_val, n_val)
print(
f"| {stage}{count_str} | {b_val:.2f} | {n_val:.2f} | {s_diff:+.2f} | {s_pct:+.1f}% | {status_emoji} |"
)
def _print_multi_comparison_report(
base_e2e,
others_data,
other_labels,
combined_order,
base_durations,
others_processed,
):
print("#### 1. High-level Summary")
header = "| Metric | Baseline | " + " | ".join(other_labels) + " |"
sep = "| :--- | :--- | " + " | ".join([":---"] * len(other_labels)) + " |"
print(header)
print(sep)
# E2E Row
row_e2e = f"| **E2E Latency** | {base_e2e:.2f} ms |"
for i, d in enumerate(others_data):
val = d.get("total_duration_ms", 0)
diff_ms, diff_pct = calculate_diff(base_e2e, val)
status = _get_status_emoji_from_diff_percent(diff_pct)
row_e2e += f" {val:.2f} ms ({diff_pct:+.1f}%) {status} |"
print(row_e2e)
print("\n")
print("#### 2. Stage Breakdown")
# Header: Stage | Baseline | Label1 | Label2 ...
header = "| Stage Name | Baseline | " + " | ".join(other_labels) + " |"
sep = "| :--- | :--- | " + " | ".join([":---"] * len(other_labels)) + " |"
print(header)
print(sep)
for stage in combined_order:
b_val = base_durations.get(stage, 0.0)
row_str = f"| {stage} | {b_val:.2f} |"
for i, (n_durations, _, n_counts) in enumerate(others_processed):
n_val = n_durations.get(stage, 0.0)
_, s_pct = calculate_diff(b_val, n_val)
status_emoji = get_perf_status_emoji(b_val, n_val)
row_str += f" {n_val:.2f} ({s_pct:+.1f}%) {status_emoji} |"
print(row_str)
def compare_benchmarks(file_paths: List[str], output_format: str = "markdown"):
"""
Compares benchmark JSON files and prints a report.
First file is baseline, others will be compared against it.
"""
if len(file_paths) < 2:
print("Error: Need at least 2 files to compare.")
return
try:
data_list = [_load_benchmark_file(f) for f in file_paths]
except Exception as e:
print(f"Error loading benchmark files: {e}")
return
base_data = data_list[0]
others_data = data_list[1:]
# Use filenames as labels if multiple comparisons, else just "New"
other_labels = [os.path.basename(p) for p in file_paths[1:]]
base_e2e = base_data.get("total_duration_ms", 0)
base_durations, base_order, base_counts = consolidate_steps(
base_data.get("steps", [])
)
others_processed = []
for d in others_data:
dur, order, counts = consolidate_steps(d.get("steps", []))
others_processed.append((dur, order, counts))
combined_order = []
# Collect all unique stages maintaining order from newest to baseline
for _, order, _ in reversed(others_processed):
for name in order:
if name not in combined_order:
combined_order.append(name)
for name in base_order:
if name not in combined_order:
combined_order.append(name)
if output_format == "markdown":
print("### Performance Comparison Report\n")
if len(others_data) == 1:
_print_single_comparison_report(
others_data,
base_e2e,
combined_order,
base_durations,
others_processed,
base_counts,
)
else:
_print_multi_comparison_report(
base_e2e,
others_data,
other_labels,
combined_order,
base_durations,
others_processed,
)
print("\n")
# Metadata
print("<details>")
print("<summary>Metadata</summary>\n")
print(f"- Baseline Commit: `{base_data.get('commit_hash', 'N/A')}`")
for i, d in enumerate(others_data):
label = "New" if len(others_data) == 1 else other_labels[i]
print(f"- {label} Commit: `{d.get('commit_hash', 'N/A')}`")
print(f"- Timestamp: {datetime.now().isoformat()}")
print("</details>")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Compare sglang-diffusion performance JSON files."
)
parser.add_argument(
"files",
nargs="+",
help="List of JSON files. First is baseline, others are compared against it.",
)
args = parser.parse_args()
compare_benchmarks(args.files)
@@ -0,0 +1,361 @@
import glob
import json
import os
import random
import re
import subprocess
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
import requests
from PIL import Image
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
@dataclass
class RequestFuncInput:
prompt: str
api_url: str = ""
model: str = ""
num_outputs_per_prompt: int = 1
width: Optional[int] = None
height: Optional[int] = None
num_frames: Optional[int] = None
fps: Optional[int] = None
extra_body: Dict[str, Any] = field(default_factory=dict)
image_paths: Optional[List[str]] = None
request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
slo_ms: Optional[float] = None
num_inference_steps: Optional[int] = None
@dataclass
class RequestFuncOutput:
success: bool = False
latency: float = 0.0
error: str = ""
start_time: float = 0.0
response_body: Dict[str, Any] = field(default_factory=dict)
peak_memory_mb: float = 0.0
slo_achieved: Optional[bool] = None
output_count: int = 0
def is_dir_not_empty(path: str) -> bool:
return os.path.isdir(path) and bool(os.listdir(path))
class BaseDataset(ABC):
def __init__(self, args, api_url: str = "", model: str = ""):
self.args = args
self.api_url = api_url
self.model = model
self.items: List[Dict[str, Any]] = []
@abstractmethod
def __len__(self) -> int:
pass
@abstractmethod
def __getitem__(self, idx: int) -> RequestFuncInput:
pass
def get_requests(self) -> List[RequestFuncInput]:
return [self[i] for i in range(len(self))]
class VBenchDataset(BaseDataset):
"""
Dataset loader for VBench prompts.
Supports t2v, i2v.
"""
T2V_PROMPT_URL = "https://raw.githubusercontent.com/Vchitect/VBench/master/prompts/prompts_per_dimension/subject_consistency.txt"
I2V_DOWNLOAD_SCRIPT_URL = "https://raw.githubusercontent.com/Vchitect/VBench/master/vbench2_beta_i2v/download_data.sh"
def __init__(self, args, api_url: str = "", model: str = ""):
super().__init__(args, api_url, model)
self.cache_dir = os.path.join(os.path.expanduser("~"), ".cache", "sglang")
self.items = self._load_data()
@staticmethod
def _normalize_task_name(task_name: Any) -> Any:
"""Normalize enum-style task values to legacy benchmark task-name strings."""
enum_to_task_name = {
"T2V": "text-to-video",
"I2V": "image-to-video",
"TI2V": "image-to-video",
"T2I": "text-to-image",
"I2I": "image-to-image",
"TI2I": "image-to-image",
}
# Handle Enum-like objects, e.g., ModelTaskType.T2I
enum_name = getattr(task_name, "name", None)
if isinstance(enum_name, str):
return enum_to_task_name.get(enum_name, task_name)
# Handle direct string inputs or enum string repr
if isinstance(task_name, str):
if task_name in enum_to_task_name:
return enum_to_task_name[task_name]
if "." in task_name:
suffix = task_name.split(".")[-1]
return enum_to_task_name.get(suffix, task_name)
return task_name
def _load_data(self) -> List[Dict[str, Any]]:
task_name = self._normalize_task_name(self.args.task_name)
if task_name in ("text-to-video", "text-to-image", "video-to-video"):
return self._load_t2v_prompts()
elif task_name in ("image-to-video", "image-to-image"):
return self._load_i2v_data()
else:
raise ValueError(
f"Illegal task name is found in VBenchDataset {self.args.task_name}"
)
def _download_file(self, url: str, dest_path: str) -> None:
"""Download a file from URL to destination path."""
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
resp = requests.get(url)
resp.raise_for_status()
with open(dest_path, "w") as f:
f.write(resp.text)
def _load_t2v_prompts(self) -> List[Dict[str, Any]]:
path = self.args.dataset_path
if not path:
path = os.path.join(self.cache_dir, "vbench_subject_consistency.txt")
if not os.path.exists(path):
logger.info(f"Downloading VBench T2V prompts to {path}...")
try:
self._download_file(self.T2V_PROMPT_URL, path)
except Exception as e:
logger.info(f"Failed to download VBench prompts: {e}")
return [{"prompt": "A cat sitting on a bench"}] * 50
prompts = []
with open(path, "r") as f:
for line in f:
line = line.strip()
if line:
prompts.append({"prompt": line})
return self._resize_data(prompts)
def _auto_download_i2v_dataset(self) -> Optional[str]:
"""Auto-download VBench I2V dataset and return the dataset directory."""
vbench_i2v_dir = os.path.join(self.cache_dir, "vbench_i2v", "vbench2_beta_i2v")
info_json_path = os.path.join(vbench_i2v_dir, "data", "i2v-bench-info.json")
crop_dir = os.path.join(vbench_i2v_dir, "data", "crop")
origin_dir = os.path.join(vbench_i2v_dir, "data", "origin")
if (
os.path.exists(info_json_path)
and is_dir_not_empty(crop_dir)
and is_dir_not_empty(origin_dir)
):
return vbench_i2v_dir
logger.info(f"Downloading VBench I2V dataset to {vbench_i2v_dir}...")
try:
cache_root = os.path.join(self.cache_dir, "vbench_i2v")
script_path = os.path.join(cache_root, "download_data.sh")
self._download_file(self.I2V_DOWNLOAD_SCRIPT_URL, script_path)
os.chmod(script_path, 0o755)
logger.info("Executing download_data.sh (this may take a while)...")
result = subprocess.run(
["bash", script_path],
cwd=cache_root,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"Download script failed: {result.stderr}")
missing_packages = re.findall(r"(\S+): command not found", result.stderr)
if missing_packages:
missing_packages = list(set(missing_packages))
package_list = ", ".join(f"'{cmd}'" for cmd in missing_packages)
raise RuntimeError(
f"Download script failed because the following commands are not installed: {package_list}.\n"
"Please install them (e.g., on Ubuntu: `sudo apt install ...`) and try again."
)
logger.info(
f"Successfully downloaded VBench I2V dataset to {vbench_i2v_dir}"
)
except Exception as e:
logger.info(f"Failed to download VBench I2V dataset: {e}")
logger.info("Please manually download following instructions at:")
logger.info(
"https://github.com/Vchitect/VBench/tree/master/vbench2_beta_i2v#22-download"
)
return None
return vbench_i2v_dir if os.path.exists(info_json_path) else None
def _load_from_i2v_json(self, json_path: str) -> List[Dict[str, Any]]:
"""Load I2V data from i2v-bench-info.json format."""
with open(json_path, "r") as f:
items = json.load(f)
base_dir = os.path.dirname(
os.path.dirname(json_path)
) # Go up to vbench2_beta_i2v
origin_dir = os.path.join(base_dir, "data", "origin")
data = []
for item in items:
img_path = os.path.join(origin_dir, item.get("file_name", ""))
if os.path.exists(img_path):
data.append({"prompt": item.get("caption", ""), "image_path": img_path})
else:
logger.warning(f"Image not found: {img_path}")
logger.info(f"Loaded {len(data)} I2V samples from VBench I2V dataset")
return data
def _scan_directory_for_images(self, path: str) -> List[Dict[str, Any]]:
"""Scan directory for image files."""
exts = ["*.jpg", "*.jpeg", "*.png", "*.webp"]
files = []
for ext in exts:
files.extend(glob.glob(os.path.join(path, ext)))
files.extend(glob.glob(os.path.join(path, ext.upper())))
origin_dir = os.path.join(path, "data", "origin")
if os.path.exists(origin_dir):
files.extend(glob.glob(os.path.join(origin_dir, ext)))
files.extend(glob.glob(os.path.join(origin_dir, ext.upper())))
return [
{"prompt": os.path.splitext(os.path.basename(f))[0], "image_path": f}
for f in files
]
def _create_dummy_data(self) -> List[Dict[str, Any]]:
"""Create dummy data with a placeholder image in cache directory."""
logger.info("No I2V data found. Using dummy placeholders.")
dummy_image = os.path.join(self.cache_dir, "dummy_image.jpg")
if not os.path.exists(dummy_image):
os.makedirs(self.cache_dir, exist_ok=True)
img = Image.new("RGB", (100, 100), color="red")
img.save(dummy_image)
logger.info(f"Created dummy image at {dummy_image}")
return [{"prompt": "A moving cat", "image_path": dummy_image}] * 10
def _load_i2v_data(self) -> List[Dict[str, Any]]:
"""Load I2V data from VBench I2V dataset or user-provided path."""
path = self.args.dataset_path
if not path:
path = self._auto_download_i2v_dataset()
if not path:
return self._resize_data(self._create_dummy_data())
info_json_candidates = [
os.path.join(path, "data", "i2v-bench-info.json"),
path if path.endswith(".json") else None,
]
for json_path in info_json_candidates:
if json_path and os.path.exists(json_path):
try:
return self._resize_data(self._load_from_i2v_json(json_path))
except Exception as e:
logger.info(f"Failed to load {json_path}: {e}")
if os.path.isdir(path):
data = self._scan_directory_for_images(path)
if data:
return self._resize_data(data)
return self._resize_data(self._create_dummy_data())
def _resize_data(self, data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Resize data to match num_prompts."""
if not self.args.num_prompts:
return data
if len(data) < self.args.num_prompts:
factor = (self.args.num_prompts // len(data)) + 1
data = data * factor
return data[: self.args.num_prompts]
def __len__(self) -> int:
return len(self.items)
def __getitem__(self, idx: int) -> RequestFuncInput:
item = self.items[idx]
return RequestFuncInput(
prompt=item.get("prompt", ""),
api_url=self.api_url,
model=self.model,
num_outputs_per_prompt=self.args.num_outputs_per_prompt,
width=self.args.width,
height=self.args.height,
num_frames=self.args.num_frames,
fps=self.args.fps,
num_inference_steps=self.args.num_inference_steps,
image_paths=[item["image_path"]] if "image_path" in item else None,
)
class RandomDataset(BaseDataset):
def __init__(self, args, api_url: str = "", model: str = ""):
super().__init__(args, api_url, model)
self.num_prompts = args.num_prompts or 100
self.random_request_config = args.random_request_config
if self.random_request_config:
self.random_request_config = json.loads(self.random_request_config)
weights = [p.pop("weight") for p in self.random_request_config]
seed = args.random_request_seed
rng = random.Random(seed)
self._sampled_requests = rng.choices(
self.random_request_config, weights=weights, k=self.num_prompts
)
else:
self._sampled_requests = None
def get_sampling_params(self, idx: int) -> dict:
"""Return the per-request sampling profile dict, or empty dict if not mix-diffusion."""
if self._sampled_requests:
return self._sampled_requests[idx]
return {}
def __len__(self) -> int:
return self.num_prompts
def __getitem__(self, idx: int) -> RequestFuncInput:
profile = self._sampled_requests[idx] if self._sampled_requests else {}
return RequestFuncInput(
prompt=f"Random prompt {idx} for benchmarking diffusion models",
api_url=self.api_url,
model=self.model,
num_outputs_per_prompt=profile.get(
"num_outputs_per_prompt", self.args.num_outputs_per_prompt
),
width=profile.get("width", self.args.width),
height=profile.get("height", self.args.height),
num_frames=profile.get("num_frames", self.args.num_frames),
num_inference_steps=profile.get(
"num_inference_steps", self.args.num_inference_steps
),
fps=profile.get("fps", self.args.fps),
)