[diffusion] benchmark: add SLO metric forinbench_serving (#18907)

Co-authored-by: ronnie_zheng <zl19940307@163.com>
This commit is contained in:
Junhao Liu
2026-03-08 22:35:57 +08:00
committed by GitHub
co-authored by ronnie_zheng
parent cc73355a1f
commit 051427c0a3
2 changed files with 196 additions and 7 deletions
@@ -6,10 +6,13 @@ Usage:
# launch a server and benchmark on it # launch a server and benchmark on it
# T2V or T2I or any other multimodal generation model # T2V or T2I or any other multimodal generation model
sglang serve Wan-AI/Wan2.2-T2V-A14B-Diffusers --num-gpus 1 --port 1231 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 # 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 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 argparse
@@ -17,6 +20,7 @@ import asyncio
import json import json
import os import os
import time import time
from dataclasses import replace
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import aiohttp import aiohttp
@@ -38,6 +42,89 @@ from sglang.multimodal_gen.test.test_utils import print_divider, print_value_for
logger = init_logger(__name__) 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 _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( async def async_request_image_sglang(
input: RequestFuncInput, input: RequestFuncInput,
@@ -124,6 +211,10 @@ async def async_request_image_sglang(
output.latency = time.perf_counter() - output.start_time 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: if pbar:
pbar.update(1) pbar.update(1)
return output return output
@@ -277,12 +368,22 @@ async def async_request_video_sglang(
output.latency = time.perf_counter() - output.start_time 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: if pbar:
pbar.update(1) pbar.update(1)
return output return output
def calculate_metrics(outputs: List[RequestFuncOutput], total_duration: float): 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] success_outputs = [o for o in outputs if o.success]
error_outputs = [o for o in outputs if not o.success] error_outputs = [o for o in outputs if not o.success]
@@ -304,6 +405,29 @@ def calculate_metrics(outputs: List[RequestFuncOutput], total_duration: float):
"peak_memory_mb_median": np.median(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 return metrics
@@ -418,10 +542,39 @@ async def benchmark(args):
else: else:
return await request_func(req, session, pbar) 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 # Run benchmark
pbar = tqdm(total=len(requests_list), disable=args.disable_tqdm) pbar = tqdm(total=len(requests_list), disable=args.disable_tqdm)
async with aiohttp.ClientSession() as session:
start_time = time.perf_counter() start_time = time.perf_counter()
tasks = [] tasks = []
for req in requests_list: for req in requests_list:
@@ -439,7 +592,7 @@ async def benchmark(args):
pbar.close() pbar.close()
# Calculate metrics # Calculate metrics
metrics = calculate_metrics(outputs, total_duration) metrics = calculate_metrics(outputs, total_duration, requests_list, args, args.slo)
print("\n{s:{c}^{n}}".format(s=" Serving Benchmark Result ", n=60, c="=")) print("\n{s:{c}^{n}}".format(s=" Serving Benchmark Result ", n=60, c="="))
@@ -478,6 +631,16 @@ async def benchmark(args):
"Peak Memory Median (MB):", metrics["peak_memory_mb_median"] "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) print_divider(60)
if args.output_file: if args.output_file:
@@ -573,6 +736,29 @@ if __name__ == "__main__":
choices=["DEBUG", "INFO", "WARNING", "ERROR"], choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Log level.", 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() args = parser.parse_args()
@@ -28,6 +28,8 @@ class RequestFuncInput:
extra_body: Dict[str, Any] = field(default_factory=dict) extra_body: Dict[str, Any] = field(default_factory=dict)
image_paths: Optional[List[str]] = None image_paths: Optional[List[str]] = None
request_id: str = field(default_factory=lambda: str(uuid.uuid4())) request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
slo_ms: Optional[float] = None
num_inference_steps: Optional[int] = None
@dataclass @dataclass
@@ -38,6 +40,7 @@ class RequestFuncOutput:
start_time: float = 0.0 start_time: float = 0.0
response_body: Dict[str, Any] = field(default_factory=dict) response_body: Dict[str, Any] = field(default_factory=dict)
peak_memory_mb: float = 0.0 peak_memory_mb: float = 0.0
slo_achieved: Optional[bool] = None
def is_dir_not_empty(path: str) -> bool: def is_dir_not_empty(path: str) -> bool: