[diffusion] chore: make nightly performance measurements robust (#37915)

This commit is contained in:
Mick
2026-09-04 22:03:25 +08:00
committed by GitHub
parent dc6b5d1f5a
commit 88021b0734
8 changed files with 405 additions and 93 deletions
@@ -424,6 +424,7 @@ async def edits(
enable_upscaling: Optional[bool] = Form(False),
upscaling_model_path: Optional[str] = Form(None),
upscaling_scale: Optional[int] = Form(4),
perf_dump_path: Optional[str] = Form(None),
num_frames: int = Form(1),
):
request_id = generate_request_id()
@@ -484,6 +485,7 @@ async def edits(
enable_upscaling=enable_upscaling,
upscaling_model_path=upscaling_model_path,
upscaling_scale=upscaling_scale,
perf_dump_path=perf_dump_path,
)
trace_headers = extract_trace_headers(raw_request.headers)
batch = prepare_request(
@@ -494,6 +494,7 @@ async def create_video(
output_quality: Optional[str] = Form(None),
output_compression: Optional[int] = Form(None),
output_path: Optional[str] = Form(None),
perf_dump_path: Optional[str] = Form(None),
extra_params: Optional[str] = Form(None),
extra_body: Optional[str] = Form(None),
):
@@ -645,6 +646,7 @@ async def create_video(
output_compression=form_value("output_compression", output_compression),
output_quality=form_value("output_quality", output_quality),
output_path=form_value("output_path", output_path),
perf_dump_path=form_value("perf_dump_path", perf_dump_path),
diffusers_kwargs=form_value("diffusers_kwargs", None),
**extra_request_fields,
)
@@ -0,0 +1,153 @@
import importlib.util
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[5]
def _load_script(name: str, relative_path: str):
spec = importlib.util.spec_from_file_location(name, REPO_ROOT / relative_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
runner = _load_script(
"diffusion_nightly_runner",
"scripts/ci/utils/diffusion/run_comparison.py",
)
dashboard = _load_script(
"diffusion_nightly_dashboard",
"scripts/ci/utils/diffusion/generate_diffusion_dashboard.py",
)
def test_sglang_server_warmup_matches_measured_shape():
case = {
"model": "example/model",
"num_gpus": 2,
"width": 768,
"height": 512,
"num_frames": 121,
}
command = runner._build_sglang_cmd(
case,
{"serve_args": "--warmup-mode server --tp-size 2"},
30000,
)
resolution_index = command.index("--warmup-resolutions")
frame_index = command.index("--warmup-num-frames")
assert command[resolution_index + 1] == "768x512"
assert command[frame_index + 1] == "121"
def test_explicit_server_warmup_shape_is_preserved():
case = {
"model": "example/model",
"num_gpus": 1,
"width": 1024,
"height": 1024,
"num_frames": 81,
}
command = runner._build_sglang_cmd(
case,
{
"serve_args": (
"--warmup-mode server --warmup-resolutions 512x512 "
"--warmup-num-frames 25"
)
},
30000,
)
assert command.count("--warmup-resolutions") == 1
assert command.count("--warmup-num-frames") == 1
assert command[command.index("--warmup-resolutions") + 1] == "512x512"
assert command[command.index("--warmup-num-frames") + 1] == "25"
def test_perf_dump_summary_uses_medians():
perf_dumps = [
{
"total_duration_ms": 1000.0,
"steps": [
{"name": "TextEncodingStage", "duration_ms": 100.0},
{"name": "DenoisingStage", "duration_ms": 800.0},
],
"denoise_steps_ms": [{"duration_ms": 8.0}, {"duration_ms": 10.0}],
},
{
"total_duration_ms": 3000.0,
"steps": [
{"name": "TextEncodingStage", "duration_ms": 300.0},
{"name": "DenoisingStage", "duration_ms": 2400.0},
],
"denoise_steps_ms": [{"duration_ms": 30.0}],
},
{
"total_duration_ms": 1100.0,
"steps": [
{"name": "TextEncodingStage", "duration_ms": 110.0},
{"name": "DenoisingStage", "duration_ms": 880.0},
],
"denoise_steps_ms": [{"duration_ms": 11.0}],
},
]
summary = runner._summarize_perf_dumps(perf_dumps)
assert summary["server_latency_s"] == 1.1
assert summary["server_stage_medians_ms"] == {
"DenoisingStage": 880.0,
"TextEncodingStage": 110.0,
}
assert summary["median_denoise_step_ms"] == 10.5
def test_dashboard_uses_historical_median_and_shows_server_breakdown():
current = {
"timestamp": "2026-09-04T00:00:00+00:00",
"commit_sha": "abcdef123456",
"results": [
{
"case_id": "example",
"framework": "sglang",
"model": "example/model",
"latency_s": 10.4,
"latency_samples_s": [10.3, 10.4, 10.5],
"measurement_count": 3,
"server_latency_s": 10.0,
"server_stage_medians_ms": {
"TextEncodingStage": 100.0,
"DenoisingStage": 9800.0,
"DecodingStage": 100.0,
},
"median_denoise_step_ms": 196.0,
}
],
}
history = [
{
"results": [
{
"case_id": "example",
"framework": "sglang",
"latency_s": value,
}
]
}
for value in (10.0, 30.0, 9.8)
]
baseline, count = dashboard._historical_latency_baseline(
"example", "sglang", history
)
markdown, alerts = dashboard.generate_dashboard(current, history)
assert baseline == 10.0
assert count == 3
assert alerts == []
assert "| 3 | **10.40** |" in markdown
assert "## SGLang Server-Side Breakdown" in markdown
assert "| model | 10.00 | 0.10 | 9.80 | 0.10 | 196.00 |" in markdown
@@ -1,3 +1,4 @@
import inspect
import os
from dataclasses import fields
@@ -23,6 +24,7 @@ from sglang.multimodal_gen.runtime.entrypoints.openai.image_api import (
_runtime_sampling_quality,
_select_image_variant_cloud_url,
_select_image_variant_path,
edits,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
ImageGenerationsRequest,
@@ -30,6 +32,10 @@ from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
def test_image_edits_declares_perf_dump_path_form_field():
assert "perf_dump_path" in inspect.signature(edits).parameters
def test_url_response_returns_one_item_per_output_path():
paths = ["first.png", "second.png"]
@@ -1,3 +1,4 @@
import inspect
from dataclasses import fields
from types import SimpleNamespace
from unittest.mock import patch
@@ -16,10 +17,15 @@ from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.realtime_adapter
from sglang.multimodal_gen.runtime.entrypoints.openai.video_api import (
_build_video_sampling_params,
_video_request_model_kwargs,
create_video,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
def test_multipart_video_declares_perf_dump_path_form_field():
assert "perf_dump_path" in inspect.signature(create_video).parameters
def test_video_api_forwards_profiling_options():
request = VideoGenerationsRequest(
prompt="profile this request",
@@ -1,5 +1,6 @@
{
"_comment": "Per-model comparison config. Sampling params omitted where model defaults are correct — only override resolution, seed, and params that differ from defaults.",
"measurement_repeats": 3,
"test_image_url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/cat.png",
"cases": [
{
@@ -15,6 +15,7 @@ Usage:
import argparse
import json
import os
import statistics
from datetime import datetime, timezone
# ---------------------------------------------------------------------------
@@ -26,6 +27,8 @@ CI_DATA_REPO_NAME = "ci-data-diffusion"
CI_DATA_BRANCH = "main"
HISTORY_PREFIX = "diffusion-comparisons"
MAX_HISTORY_RUNS = 29
HISTORICAL_BASELINE_RUNS = 5
REGRESSION_THRESHOLD = 0.05
# Base URL for chart images pushed to sgl-project/ci-data-diffusion
CHARTS_RAW_BASE_URL = (
@@ -136,6 +139,19 @@ def _short_sha(sha: str) -> str:
return sha[:7] if sha and sha != "unknown" else "?"
def _historical_latency_baseline(
cid: str, framework: str, history: list[dict]
) -> tuple[float | None, int]:
values = []
for run in history[:HISTORICAL_BASELINE_RUNS]:
latency = _extract_case_results(run).get(cid, {}).get(framework)
if latency is not None:
values.append(latency)
if not values:
return None, 0
return statistics.median(values), len(values)
def _assess_risk(
cid: str,
current_cases: dict[str, dict[str, float | None]],
@@ -146,7 +162,7 @@ def _assess_risk(
Rules (checked in order):
- N/A latency → ❌ broken
- History exists: SGLang latency >5% vs avg of last 3 runs → ⚠️ regression
- History exists: SGLang latency >5% vs median of recent runs → ⚠️ regression
- Competitor exists & SGLang slower → 🔴 competitive risk
- SGLang faster than all competitors by >20% → 🟢 strong advantage
- SGLang faster than all competitors by ≤20% → 🟡 moderate advantage
@@ -158,23 +174,18 @@ def _assess_risk(
if sg_lat is None:
return "", f"{cid}: SGLang latency is N/A (broken)"
# Check regression against 3-run historical average
if history:
hist_lats: list[float] = []
for run in history[:3]:
run_cases = _extract_case_results(run)
h_lat = run_cases.get(cid, {}).get("sglang")
if h_lat is not None:
hist_lats.append(h_lat)
if hist_lats:
avg_3 = sum(hist_lats) / len(hist_lats)
if avg_3 > 0 and (sg_lat - avg_3) / avg_3 > 0.05:
pct = (sg_lat - avg_3) / avg_3 * 100
return (
"⚠️",
f"{cid}: SGLang regression +{pct:.1f}% vs 3-run avg "
f"({sg_lat:.2f}s vs {avg_3:.2f}s)",
)
baseline, baseline_runs = _historical_latency_baseline(cid, "sglang", history)
if (
baseline is not None
and baseline > 0
and (sg_lat - baseline) / baseline > REGRESSION_THRESHOLD
):
pct = (sg_lat - baseline) / baseline * 100
return (
"⚠️",
f"{cid}: SGLang regression +{pct:.1f}% vs {baseline_runs}-run "
f"median ({sg_lat:.2f}s vs {baseline:.2f}s)",
)
# Check competitive risk
if other_frameworks:
@@ -229,6 +240,24 @@ def _extract_case_results(run_data: dict) -> dict[str, dict[str, float | None]]:
return mapping
def _extract_case_records(run_data: dict) -> dict[str, dict[str, dict]]:
"""Extract {case_id: {framework: result record}} from a run."""
mapping: dict[str, dict[str, dict]] = {}
for result in run_data.get("results", []):
mapping.setdefault(result["case_id"], {})[result["framework"]] = result
return mapping
def _stage_group_seconds(result: dict, suffixes: str | tuple[str, ...]) -> float | None:
if isinstance(suffixes, str):
suffixes = (suffixes,)
stages = result.get("server_stage_medians_ms") or {}
values = [value for name, value in stages.items() if name.endswith(suffixes)]
if not values:
return None
return sum(values) / 1000.0
def _sanitize_filename(name: str) -> str:
"""Sanitize a case ID to be a safe filename."""
return name.replace("/", "_").replace(" ", "_").replace(":", "_")
@@ -257,22 +286,22 @@ def generate_dashboard(
lines.append(f"*Generated: {_short_date(ts)} | Commit: `{_short_sha(sha)}`*\n")
current_cases = _extract_case_results(current)
current_records = _extract_case_records(current)
case_ids = list(current_cases.keys())
# ---- Regression detection ----
REGRESSION_THRESHOLD = 0.05 # 5%
regressions: list[str] = []
if history:
prev_cases = _extract_case_results(history[0])
for cid in case_ids:
for fw in ("sglang", "vllm-omni"):
cur = current_cases.get(cid, {}).get(fw)
prev = prev_cases.get(cid, {}).get(fw)
if cur and prev and prev > 0:
pct = (cur - prev) / prev
baseline, baseline_runs = _historical_latency_baseline(cid, fw, history)
if cur is not None and baseline is not None and baseline > 0:
pct = (cur - baseline) / baseline
if pct > REGRESSION_THRESHOLD:
regressions.append(
f"**{cid}** ({fw}): {prev:.2f}s -> {cur:.2f}s "
f"**{cid}** ({fw}): {cur:.2f}s vs "
f"{baseline_runs}-run median {baseline:.2f}s "
f"(+{pct * 100:.1f}%)"
)
@@ -305,10 +334,10 @@ def generate_dashboard(
risk_map[cid] = _assess_risk(cid, current_cases, history, other_frameworks)
# Dynamic header
header = "| Model | Risk |"
sep = "|-------|------|"
header = "| Model | Risk | Samples |"
sep = "|-------|------|---------|"
for fw in all_frameworks:
header += f" {fw} (s) |"
header += f" {fw} median (s) |"
sep += "---------|"
for ofw in other_frameworks:
header += f" vs {ofw} |"
@@ -326,9 +355,15 @@ def generate_dashboard(
case_fws = current_cases.get(cid, {})
sg_lat = case_fws.get("sglang")
sg_record = current_records.get(cid, {}).get("sglang", {})
sample_count = sg_record.get("measurement_count")
if not sample_count and sg_lat is not None:
sample_count = 1
risk_emoji, _ = risk_map.get(cid, ("", ""))
row = f"| {r['model'].split('/')[-1]} | {risk_emoji} |"
row = (
f"| {r['model'].split('/')[-1]} | {risk_emoji} | {sample_count or 'N/A'} |"
)
# Latency columns -- bold the fastest
lats = {fw: case_fws.get(fw) for fw in all_frameworks}
valid_lats = [v for v in lats.values() if v is not None]
@@ -344,6 +379,38 @@ def generate_dashboard(
row += f" {_fmt_speedup(sg_lat, case_fws.get(ofw))} |"
lines.append(row)
server_records = [
current_records.get(cid, {}).get("sglang", {}) for cid in case_ids
]
if any(record.get("server_latency_s") is not None for record in server_records):
lines.append("\n## SGLang Server-Side Breakdown\n")
lines.append(
"| Model | Server total (s) | Text encode (s) | Denoise (s) | "
"Decode (s) | Median denoise step (ms) |"
)
lines.append(
"|-------|------------------|-----------------|--------------|"
"------------|---------------------------|"
)
for record in server_records:
if record.get("server_latency_s") is None:
continue
model = record["model"].split("/")[-1]
text_encode = _stage_group_seconds(
record, ("TextEncodingStage", "TokenizationStage")
)
denoise = _stage_group_seconds(record, "DenoisingStage")
decode = _stage_group_seconds(record, "DecodingStage")
denoise_step = record.get("median_denoise_step_ms")
denoise_step_text = (
f"{denoise_step:.2f}" if denoise_step is not None else "N/A"
)
lines.append(
f"| {model} | {_fmt_latency(record['server_latency_s'])} | "
f"{_fmt_latency(text_encode)} | {_fmt_latency(denoise)} | "
f"{_fmt_latency(decode)} | {denoise_step_text} |"
)
# ---- Section 2: Speedup-over-time vs. other frameworks (rendered only when present) ----
if history and other_frameworks:
lines.append("\n## SGLang vs vLLM-Omni Speedup Over Time\n")
+140 -65
View File
@@ -1,7 +1,7 @@
"""Diffusion serving benchmark for SGLang-Diffusion nightly CI.
Launches an SGLang-Diffusion server for each test case, sends a single
request, measures end-to-end latency, and writes comparison-results.json.
Launches an SGLang-Diffusion server for each test case, sends repeated
requests, measures median end-to-end latency, and writes comparison-results.json.
The runner still supports extra frameworks via --frameworks, but the nightly
config tracks SGLang-Diffusion only.
@@ -24,8 +24,10 @@ import base64
import io
import json
import os
import shlex
import signal
import socket
import statistics
import subprocess
import sys
import tempfile
@@ -84,12 +86,29 @@ def _build_sglang_cmd(case: dict, fw_cfg: dict, port: int) -> list[str]:
]
if case["num_gpus"] > 1:
cmd += ["--num-gpus", str(case["num_gpus"])]
if fw_cfg.get("serve_args", "").strip():
cmd += fw_cfg["serve_args"].strip().split()
# No explicit --warmup-resolutions: server-based warmup now defaults to the
# model's sampling-default resolution (see warmup_request_builder), which
# already matches these single-resolution cases — the default warmup is
# sufficient, so we don't pin a resolution here.
serve_args = shlex.split(fw_cfg.get("serve_args", ""))
cmd += serve_args
def has_option(name: str) -> bool:
return any(arg == name or arg.startswith(f"{name}=") for arg in serve_args)
server_warmup = any(
arg == "--warmup-mode=server"
or (
arg == "--warmup-mode"
and index + 1 < len(serve_args)
and serve_args[index + 1] == "server"
)
for index, arg in enumerate(serve_args)
)
if server_warmup and not has_option("--warmup-resolutions"):
cmd += ["--warmup-resolutions", f"{case['width']}x{case['height']}"]
if (
server_warmup
and case.get("num_frames") is not None
and not has_option("--warmup-num-frames")
):
cmd += ["--warmup-num-frames", str(case["num_frames"])]
return cmd
@@ -375,8 +394,8 @@ def _build_sglang_payload(case: dict) -> dict:
return payload
def _read_perf_dump(perf_dump_path: str, timeout: float = 10.0) -> float | None:
"""Read total_duration_ms from a perf dump JSON written by the server.
def _read_perf_dump(perf_dump_path: str, timeout: float = 10.0) -> dict | None:
"""Read a perf dump JSON written by the server.
The server writes the file asynchronously after the HTTP response,
so we poll briefly.
@@ -386,9 +405,8 @@ def _read_perf_dump(perf_dump_path: str, timeout: float = 10.0) -> float | None:
try:
with open(perf_dump_path) as f:
data = json.load(f)
total_ms = data.get("total_duration_ms")
if total_ms is not None:
return total_ms / 1000.0
if data.get("total_duration_ms") is not None:
return data
except (FileNotFoundError, json.JSONDecodeError):
pass
time.sleep(0.5)
@@ -415,16 +433,6 @@ def send_image_request_sglang(
if "data" not in data or len(data["data"]) == 0:
raise RuntimeError(f"Image request returned no data: {data}")
# Report client-side e2e latency to match vllm-omni / lightx2v (fair
# cross-framework comparison); server-side perf_dump is diagnostic only.
if perf_dump_path:
server_latency = _read_perf_dump(perf_dump_path)
if server_latency is not None:
print(
f" Image generated in {client_latency:.2f}s (client e2e; "
f"server-side {server_latency:.2f}s, diagnostic)"
)
return client_latency
print(f" Image generated in {client_latency:.2f}s")
return client_latency
@@ -468,16 +476,6 @@ def send_video_request_sglang(
client_latency = time.time() - start
# Report client-side e2e latency to match vllm-omni / lightx2v (fair
# cross-framework comparison); server-side perf_dump is diagnostic only.
if perf_dump_path:
server_latency = _read_perf_dump(perf_dump_path)
if server_latency is not None:
print(
f" Video generated in {client_latency:.2f}s (client e2e; "
f"server-side {server_latency:.2f}s, diagnostic)"
)
return client_latency
print(f" Video generated in {client_latency:.2f}s")
return client_latency
@@ -556,16 +554,6 @@ def send_image_conditioned_request_sglang(
client_latency = time.time() - start
# Report client-side e2e latency to match vllm-omni / lightx2v (fair
# cross-framework comparison); server-side perf_dump is diagnostic only.
if perf_dump_path:
server_latency = _read_perf_dump(perf_dump_path)
if server_latency is not None:
print(
f" Generated in {client_latency:.2f}s (client e2e; "
f"server-side {server_latency:.2f}s, diagnostic)"
)
return client_latency
print(f" Generated in {client_latency:.2f}s (sglang, image-conditioned)")
return client_latency
@@ -730,6 +718,39 @@ def send_request(
# ---------------------------------------------------------------------------
def _summarize_perf_dumps(perf_dumps: list[dict]) -> dict:
server_latency_samples_s = [
round(dump["total_duration_ms"] / 1000.0, 3) for dump in perf_dumps
]
stage_samples: dict[str, list[float]] = {}
denoise_step_samples: list[float] = []
for dump in perf_dumps:
for stage in dump.get("steps", []):
name = stage.get("name")
duration_ms = stage.get("duration_ms")
if name and duration_ms is not None:
stage_samples.setdefault(name, []).append(float(duration_ms))
denoise_step_samples.extend(
float(step["duration_ms"])
for step in dump.get("denoise_steps_ms", [])
if step.get("duration_ms") is not None
)
summary = {
"server_latency_samples_s": server_latency_samples_s,
"server_latency_s": round(statistics.median(server_latency_samples_s), 3),
"server_stage_medians_ms": {
name: round(statistics.median(values), 3)
for name, values in sorted(stage_samples.items())
},
}
if denoise_step_samples:
summary["median_denoise_step_ms"] = round(
statistics.median(denoise_step_samples), 3
)
return summary
def run_single(
case: dict,
framework: str,
@@ -737,6 +758,7 @@ def run_single(
port: int,
log_dir: Path,
config: dict | None = None,
measurement_repeats: int = 1,
) -> dict:
"""Run a single (case, framework) combination. Returns result dict."""
result = {
@@ -745,6 +767,8 @@ def run_single(
"model": case["model"],
"task": case["task"],
"latency_s": None,
"latency_samples_s": [],
"measurement_count": 0,
"error": None,
}
@@ -754,11 +778,6 @@ def run_single(
env = os.environ.copy()
env.update(fw_cfg.get("extra_env", {}))
# perf_dump_path for SGLang server-side timing (passed in request, zero overhead when None)
perf_dump_path = None
if framework == "sglang":
perf_dump_path = os.path.join(str(log_dir), f"perf_{case['id']}_measured.json")
log_file = log_dir / f"{case['id']}_{framework}.log"
log_fh = open(log_file, "w", encoding="utf-8", buffering=1)
log_thread = None
@@ -811,26 +830,54 @@ def run_single(
base_url = f"http://{DEFAULT_HOST}:{port}"
wait_for_health(base_url, framework)
# No client-side warmup: each framework relies on its own server-side
# warmup before traffic. sglang's serve_args pass --warmup-mode server,
# which primes kernels with a synthetic request at startup, before the
# health check passes. This goes through the internal
# warmup path that bypasses sampling-param preset validation (e.g.
# Ideogram-4's preset-locked num_inference_steps), so no per-case warmup
# special-casing is needed here.
# SGLang server warmup uses the measured shape added by
# _build_sglang_cmd. The repeated requests below absorb any remaining
# request-path cold effects, including image-conditioned preprocessing.
# NOTE: vllm-omni / lightx2v configure no server-side warmup; if
# cross-framework comparison is restored, they must add their own warmup
# to stay on equal footing — otherwise their measured request pays the
# full cold-start.
# Measured request — pass perf_dump_path for SGLang server-side timing
if perf_dump_path and os.path.exists(perf_dump_path):
os.remove(perf_dump_path)
print(" Sending measured request...")
latency = send_request(
base_url, case, framework, config, perf_dump_path=perf_dump_path
)
result["latency_s"] = round(latency, 3)
latency_samples: list[float] = []
perf_dumps: list[dict] = []
for sample_index in range(measurement_repeats):
perf_dump_path = None
if framework == "sglang":
sample_suffix = "" if sample_index == 0 else f"_{sample_index + 1}"
perf_dump_path = str(
(
log_dir / f"perf_{case['id']}_measured{sample_suffix}.json"
).resolve()
)
if os.path.exists(perf_dump_path):
os.remove(perf_dump_path)
print(
f" Sending measured request {sample_index + 1}/"
f"{measurement_repeats}..."
)
latency = send_request(
base_url, case, framework, config, perf_dump_path=perf_dump_path
)
latency_samples.append(round(latency, 3))
if perf_dump_path:
perf_dump = _read_perf_dump(perf_dump_path)
if perf_dump is None:
raise RuntimeError(
f"Server did not write performance data to {perf_dump_path}"
)
perf_dumps.append(perf_dump)
print(
" Server-side latency: "
f"{perf_dump['total_duration_ms'] / 1000.0:.2f}s"
)
result["latency_samples_s"] = latency_samples
result["measurement_count"] = len(latency_samples)
result["latency_s"] = round(statistics.median(latency_samples), 3)
if perf_dumps:
result.update(_summarize_perf_dumps(perf_dumps))
except Exception as e:
result["error"] = server_error.get("message", str(e))
@@ -889,6 +936,7 @@ def run_comparison(
port: int = DEFAULT_PORT,
output: str = "comparison-results.json",
dry_run: bool = False,
measurement_repeats: int | None = None,
) -> dict:
"""Run all comparison cases, grouped by framework to minimize installs.
@@ -898,6 +946,13 @@ def run_comparison(
timestamp = datetime.now(timezone.utc).isoformat()
commit_sha = _get_checkout_commit_sha()
run_id = os.environ.get("GITHUB_RUN_ID", "local")
repeats = (
measurement_repeats
if measurement_repeats is not None
else int(config.get("measurement_repeats", 1))
)
if repeats <= 0:
raise ValueError("measurement_repeats must be a positive integer")
log_dir = Path("comparison-logs")
log_dir.mkdir(exist_ok=True)
@@ -962,7 +1017,15 @@ def run_comparison(
)
continue
result = run_single(case, fw_name, fw_cfg, port, log_dir, config)
result = run_single(
case,
fw_name,
fw_cfg,
port,
log_dir,
config,
measurement_repeats=repeats,
)
results.append(result)
# Wait for GPU memory to clear
@@ -973,6 +1036,7 @@ def run_comparison(
"timestamp": timestamp,
"commit_sha": commit_sha,
"run_id": run_id,
"measurement_repeats": repeats,
"results": results,
}
@@ -986,7 +1050,11 @@ def run_comparison(
print("SUMMARY")
print(f"{'=' * 60}")
for r in results:
lat = f"{r['latency_s']:.2f}s" if r["latency_s"] else r.get("error", "N/A")
lat = (
f"{r['latency_s']:.2f}s median (n={r.get('measurement_count', 1)})"
if r["latency_s"]
else r.get("error", "N/A")
)
print(f" {r['case_id']:30s} | {r['framework']:12s} | {lat}")
return output_data
@@ -1034,6 +1102,12 @@ def main():
action="store_true",
help="Parse config and print commands without launching servers",
)
parser.add_argument(
"--measurement-repeats",
type=int,
default=None,
help="Measured requests per case (default: value from config)",
)
args = parser.parse_args()
@@ -1049,6 +1123,7 @@ def main():
port=args.port,
output=args.output,
dry_run=args.dry_run,
measurement_repeats=args.measurement_repeats,
)
# Exit with non-zero if any case had an error