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,191 @@
{
"_comment": "Per-model comparison config. Sampling params omitted where model defaults are correct — only override resolution, seed, and params that differ from defaults.",
"test_image_url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/cat.png",
"cases": [
{
"id": "flux1_dev_t2i_1024",
"model": "black-forest-labs/FLUX.1-dev",
"task": "text-to-image",
"prompt": "A futuristic cyberpunk city at night, neon lights reflecting on wet streets",
"width": 1024,
"height": 1024,
"seed": 42,
"num_gpus": 2,
"frameworks": {
"sglang": {
"serve_args": "--warmup --dit-layerwise-offload false --tp-size 2",
"extra_env": {}
}
}
},
{
"id": "flux2_dev_t2i_1024",
"model": "black-forest-labs/FLUX.2-dev",
"task": "text-to-image",
"prompt": "A futuristic cyberpunk city at night, neon lights reflecting on wet streets",
"width": 1024,
"height": 1024,
"seed": 42,
"num_gpus": 2,
"frameworks": {
"sglang": {
"serve_args": "--warmup --dit-layerwise-offload false --tp-size 2",
"extra_env": {}
}
}
},
{
"id": "qwen_image_2512_t2i_1024",
"model": "Qwen/Qwen-Image-2512",
"task": "text-to-image",
"prompt": "A futuristic cyberpunk city at night, neon lights reflecting on wet streets",
"width": 1024,
"height": 1024,
"seed": 42,
"num_gpus": 2,
"frameworks": {
"sglang": {
"serve_args": "--warmup --tp-size 2",
"extra_env": {}
}
}
},
{
"id": "qwen_image_edit_2511",
"model": "Qwen/Qwen-Image-Edit-2511",
"task": "image-edit",
"prompt": "Make the cat wear a red hat",
"reference_image": true,
"width": 1024,
"height": 1024,
"seed": 42,
"num_gpus": 2,
"frameworks": {
"sglang": {
"serve_args": "--warmup --tp-size 2",
"extra_env": {}
}
}
},
{
"id": "zimage_turbo_t2i_1024",
"model": "Tongyi-MAI/Z-Image-Turbo",
"task": "text-to-image",
"prompt": "A futuristic cyberpunk city at night, neon lights reflecting on wet streets",
"width": 1024,
"height": 1024,
"seed": 42,
"num_gpus": 2,
"frameworks": {
"sglang": {
"serve_args": "--warmup --tp-size 2",
"extra_env": {}
}
}
},
{
"id": "wan22_t2v_a14b_720p",
"model": "Wan-AI/Wan2.2-T2V-A14B-Diffusers",
"task": "text-to-video",
"prompt": "A cat and a dog baking a cake together in a kitchen.",
"width": 1280,
"height": 720,
"num_frames": 81,
"seed": 42,
"num_gpus": 4,
"frameworks": {
"sglang": {
"serve_args": "--warmup --enable-cfg-parallel --ulysses-degree 2 --text-encoder-cpu-offload --pin-cpu-memory",
"extra_env": {}
}
}
},
{
"id": "wan22_ti2v_5b_720p",
"model": "Wan-AI/Wan2.2-TI2V-5B-Diffusers",
"task": "text-image-to-video",
"prompt": "The cat starts walking slowly towards the camera.",
"reference_image": true,
"width": 1280,
"height": 720,
"num_frames": 81,
"seed": 42,
"num_gpus": 1,
"frameworks": {
"sglang": {
"serve_args": "--warmup",
"extra_env": {}
}
}
},
{
"id": "ltx2.3_twostage_ti2v_2gpus",
"model": "Lightricks/LTX-2.3",
"task": "text-image-to-video",
"prompt": "The cat starts walking slowly towards the camera.",
"reference_image": true,
"width": 768,
"height": 512,
"num_frames": 121,
"seed": 42,
"num_gpus": 2,
"frameworks": {
"sglang": {
"serve_args": "--warmup --pipeline-class-name LTX2TwoStagePipeline --cfg-parallel-size 2",
"extra_env": {}
}
}
},
{
"id": "ideogram4_fp8_t2i_2gpu",
"model": "ideogram-ai/ideogram-4-fp8",
"task": "text-to-image",
"prompt": "A futuristic cyberpunk city at night, neon lights reflecting on wet streets",
"width": 1024,
"height": 1024,
"seed": 42,
"num_gpus": 2,
"frameworks": {
"sglang": {
"serve_args": "--warmup --tp-size 2 --attention-backend fa",
"extra_env": {}
}
}
},
{
"id": "cosmos3_super_t2v_2gpu",
"model": "nvidia/Cosmos3-Super",
"task": "text-to-video",
"prompt": "A cat and a dog baking a cake together in a kitchen.",
"width": 1280,
"height": 720,
"num_frames": 81,
"seed": 42,
"num_gpus": 2,
"frameworks": {
"sglang": {
"serve_args": "--warmup --tp-size 2",
"extra_env": {"SGLANG_DISABLE_COSMOS3_GUARDRAILS": "1"}
}
}
},
{
"id": "wan22_i2v_a14b_720p",
"model": "Wan-AI/Wan2.2-I2V-A14B-Diffusers",
"task": "image-to-video",
"prompt": "The cat starts walking slowly towards the camera.",
"reference_image": true,
"width": 1280,
"height": 720,
"num_frames": 81,
"seed": 42,
"num_gpus": 4,
"frameworks": {
"sglang": {
"serve_args": "--warmup --enable-cfg-parallel --ulysses-degree 2 --text-encoder-cpu-offload --pin-cpu-memory",
"extra_env": {}
}
}
}
]
}
+327
View File
@@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""
Compute dynamic partitions for diffusion CI tests.
This script runs on lightweight CI runners without sglang dependencies and uses
AST parsing to extract parametrized cases plus standalone files from source.
"""
import argparse
import importlib.util
import json
import math
import os
import sys
from pathlib import Path
from diffusion_case_parser import (
BASELINE_REL_PATH,
RUN_SUITE_REL_PATH,
DiffusionSuiteInfo,
collect_diffusion_suites,
resolve_case_config_path,
)
def _load_partitioning_helpers():
repo_root = Path(__file__).resolve().parents[4]
helper_path = repo_root / "python/sglang/multimodal_gen/test/partitioning.py"
spec = importlib.util.spec_from_file_location(
"diffusion_test_partitioning", helper_path
)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module.PartitionItem, module.partition_items_by_lpt
PartitionItem, partition_items_by_lpt = _load_partitioning_helpers()
SUITE_OUTPUT_NAMES = {"1-gpu": "1gpu", "2-gpu": "2gpu", "1-gpu-b200": "b200"}
USE_NPU_CONFIGS = os.getenv("USE_NPU_CONFIGS", "0").lower() in ("1", "true")
if USE_NPU_CONFIGS:
SUITE_OUTPUT_NAMES = {"1-npu": "1npu", "2-npu": "2npu"}
DEFAULT_STANDALONE_EST_TIME_SECONDS = 300.0
def validate_suite_case_coverage(suites: dict[str, DiffusionSuiteInfo]) -> None:
"""
Guardrail: dynamic diffusion suites must contain parametrized cases.
"""
suites_with_no_cases = []
for suite_name in SUITE_OUTPUT_NAMES:
suite_info = suites.get(suite_name)
if suite_info is None:
print(f"Error: Required suite '{suite_name}' not found in parsed suites.")
sys.exit(1)
if len(suite_info.cases) == 0:
suites_with_no_cases.append(suite_name)
if suites_with_no_cases:
joined = ", ".join(suites_with_no_cases)
print(
"Error: Parsed zero parametrized cases for diffusion suites: "
f"{joined}. This usually means run_suite case imports changed but "
"diffusion parser logic was not updated."
)
sys.exit(1)
def compute_partition_count(
total_time_seconds: float,
min_time_seconds: float,
target_time_seconds: float,
max_time_seconds: float,
max_partitions: int,
) -> int:
if total_time_seconds <= 0:
return 0
min_partition_count = max(1, math.ceil(total_time_seconds / max_time_seconds))
max_partition_count = max(1, math.floor(total_time_seconds / min_time_seconds))
min_partition_count = min(min_partition_count, max_partitions)
max_partition_count = min(max_partition_count, max_partitions)
if max_partition_count < min_partition_count:
fallback_count = math.ceil(total_time_seconds / target_time_seconds)
return max(1, min(fallback_count, max_partitions))
preferred_count = math.ceil(total_time_seconds / target_time_seconds)
preferred_count = max(1, min(preferred_count, max_partitions))
return max(min_partition_count, min(preferred_count, max_partition_count))
def build_partition_items(
suite_info: DiffusionSuiteInfo, include_standalone: bool = True
) -> list[PartitionItem]:
items = [
PartitionItem(kind="case", item_id=case.case_id, est_time=case.est_time)
for case in suite_info.cases
]
if not include_standalone:
return items
items.extend(
PartitionItem(
kind="standalone",
item_id=standalone_file,
est_time=suite_info.standalone_est_times.get(
standalone_file, DEFAULT_STANDALONE_EST_TIME_SECONDS
),
used_fallback_estimate=(
standalone_file in suite_info.missing_standalone_estimates
),
)
for standalone_file in suite_info.standalone_files
)
return items
def build_matrix(partition_count: int) -> dict:
if partition_count <= 0:
return {"include": []}
return {"include": [{"part": i} for i in range(partition_count)]}
def build_partition_plan(
suite_name: str,
partitions: list[list[PartitionItem]],
) -> dict:
return {
"suite": suite_name,
"partition_count": len(partitions),
"partitions": [
{
"part": idx,
"case_ids": [item.item_id for item in partition if item.kind == "case"],
"standalone_files": [
item.item_id for item in partition if item.kind == "standalone"
],
"missing_standalone_estimates": [
item.item_id
for item in partition
if item.kind == "standalone" and item.used_fallback_estimate
],
"estimated_time": round(sum(item.est_time for item in partition), 1),
}
for idx, partition in enumerate(partitions)
],
}
def output_github_value(name: str, value: dict) -> None:
value_json = json.dumps(value, separators=(",", ":"))
github_output = os.environ.get("GITHUB_OUTPUT")
if github_output:
with open(github_output, "a", encoding="utf-8") as f:
f.write(f"{name}={value_json}\n")
print(f"{name}={value_json}")
def output_github_scalar(name: str, value: str) -> None:
github_output = os.environ.get("GITHUB_OUTPUT")
if github_output:
with open(github_output, "a", encoding="utf-8") as f:
f.write(f"{name}={value}\n")
print(f"{name}={value}")
def print_suite_summary(
suite_name: str,
suite_info: DiffusionSuiteInfo,
partitions: list[list[PartitionItem]],
include_standalone: bool = True,
) -> None:
total_time = sum(
item.est_time
for item in build_partition_items(
suite_info, include_standalone=include_standalone
)
)
print(f"{suite_name.upper()} suite:")
print(f" Cases: {len(suite_info.cases)}")
standalone_label = "Standalone files"
if not include_standalone:
standalone_label = "Standalone files ignored"
print(f" {standalone_label}: {len(suite_info.standalone_files)}")
print(
f" Missing standalone estimates: {len(suite_info.missing_standalone_estimates)}"
)
if suite_info.missing_standalone_estimates:
print(
f" Fallback standalone estimate: "
f"{DEFAULT_STANDALONE_EST_TIME_SECONDS:.1f}s"
)
for standalone_file in suite_info.missing_standalone_estimates:
print(f" - {standalone_file}")
print(f" Total estimated time: {total_time:.1f}s ({total_time/60:.1f} min)")
print(f" Selected partitions: {len(partitions)}")
print()
print(" Partition assignments:")
for idx, partition in enumerate(partitions):
partition_time = sum(item.est_time for item in partition)
print(f" Partition {idx}:")
print(
f" Estimated time: {partition_time:.1f}s ({partition_time/60:.1f} min)"
)
for item in partition:
fallback_suffix = (
", fallback estimate"
if item.kind == "standalone" and item.used_fallback_estimate
else ""
)
print(
f" - {item.kind}: {item.item_id} "
f"({item.est_time:.1f}s{fallback_suffix})"
)
print()
def main():
parser = argparse.ArgumentParser(
description="Compute diffusion test partitions for CI"
)
parser.add_argument(
"--min-time",
type=float,
default=1200.0,
help="Minimum desired partition time in seconds (default: 1200 = 20 minutes)",
)
parser.add_argument(
"--target-time",
type=float,
default=1800.0,
help="Preferred partition time in seconds (default: 1800 = 30 minutes)",
)
parser.add_argument(
"--max-time",
type=float,
default=2400.0,
help="Maximum desired partition time in seconds (default: 2400 = 40 minutes)",
)
parser.add_argument(
"--max-partitions",
type=int,
default=10,
help="Maximum number of partitions (default: 10)",
)
parser.add_argument(
"--parametrized-only",
action="store_true",
help="Only partition DiffusionTestCase parametrized cases.",
)
args = parser.parse_args()
script_dir = Path(__file__).resolve().parent
repo_root = script_dir.parent.parent.parent.parent
baseline_path = repo_root / BASELINE_REL_PATH
run_suite_path = repo_root / RUN_SUITE_REL_PATH
if not run_suite_path.exists():
print(f"Error: Run suite not found: {run_suite_path}")
sys.exit(1)
try:
if USE_NPU_CONFIGS:
case_config_path = (
repo_root
/ "python/sglang/multimodal_gen/test/server/ascend/testcase_configs_npu.py"
)
else:
case_config_path = resolve_case_config_path(repo_root, run_suite_path)
except (RuntimeError, FileNotFoundError) as exc:
print(f"Error: {exc}")
sys.exit(1)
suites = collect_diffusion_suites(
case_config_path,
run_suite_path,
baseline_path,
)
validate_suite_case_coverage(suites)
print("=== Diffusion Partition Computation ===")
print(f"Min partition time: {args.min_time}s ({args.min_time/60:.1f} min)")
print(f"Target partition time: {args.target_time}s ({args.target_time/60:.1f} min)")
print(f"Max partition time: {args.max_time}s ({args.max_time/60:.1f} min)")
print()
for suite_name, suite_info in suites.items():
if suite_name not in SUITE_OUTPUT_NAMES:
continue
items = build_partition_items(
suite_info, include_standalone=not args.parametrized_only
)
total_time = sum(item.est_time for item in items)
partition_count = compute_partition_count(
total_time_seconds=total_time,
min_time_seconds=args.min_time,
target_time_seconds=args.target_time,
max_time_seconds=args.max_time,
max_partitions=args.max_partitions,
)
partitions = partition_items_by_lpt(items, partition_count)
print_suite_summary(
suite_name,
suite_info,
partitions,
include_standalone=not args.parametrized_only,
)
output_name = SUITE_OUTPUT_NAMES[suite_name]
output_github_value(f"matrix-{output_name}", build_matrix(partition_count))
output_github_scalar(f"partition-count-{output_name}", str(partition_count))
output_github_value(
f"plan-{output_name}", build_partition_plan(suite_name, partitions)
)
if __name__ == "__main__":
main()
+517
View File
@@ -0,0 +1,517 @@
#!/usr/bin/env python3
"""
AST-based parser for diffusion test cases.
This module parses the diffusion case source and run_suite.py using AST to
extract test case information without requiring sglang dependencies. The case
source file is discovered from ONE_GPU_CASES/TWO_GPU_CASES imports in
run_suite.py so CI keeps a single source of truth.
Usage:
# From sibling scripts in this directory:
from diffusion_case_parser import collect_diffusion_suites, resolve_case_config_path
case_config_path = resolve_case_config_path(repo_root, run_suite_path)
suites = collect_diffusion_suites(case_config_path, run_suite_path, baseline_path)
"""
import ast
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional
# Mapping from list variable names to suite names
CASE_LIST_TO_SUITE = {
"ONE_GPU_CASES": "1-gpu",
"ONE_GPU_CASES_A": "1-gpu",
"ONE_GPU_CASES_B": "1-gpu",
"ONE_GPU_CASES_C": "1-gpu-b200",
"ONE_GPU_MODELOPT_FP8_CASES": "1-gpu",
"ONE_GPU_MODELOPT_CASES": "1-gpu-b200",
"ONE_GPU_B200_CASES": "1-gpu-b200",
"TWO_GPU_CASES": "2-gpu",
"TWO_GPU_CASES_A": "2-gpu",
"TWO_GPU_CASES_B": "2-gpu",
}
# Default estimated time for cases without baseline (5 minutes)
DEFAULT_EST_TIME_SECONDS = 300.0
# Fixed overhead for server startup when estimated_full_test_time_s is not set
STARTUP_OVERHEAD_SECONDS = 120.0
# Paths relative to repository root
BASELINE_REL_PATH = "python/sglang/multimodal_gen/test/server/perf_baselines"
BASELINE_PLATFORM_ORDER = ("h100", "b200", "5090")
RUN_SUITE_REL_PATH = "python/sglang/multimodal_gen/test/run_suite.py"
USE_NPU_CONFIGS = os.getenv("USE_NPU_CONFIGS", "0").lower() in ("1", "true")
if USE_NPU_CONFIGS:
BASELINE_REL_PATH = (
"python/sglang/multimodal_gen/test/server/perf_baselines_npu.json"
)
CASE_LIST_TO_SUITE = {
"ONE_NPU_CASES": "1-npu",
"TWO_NPU_CASES": "2-npu",
}
@dataclass
class DiffusionCaseInfo:
"""Information about a single diffusion test case."""
case_id: str # e.g., "qwen_image_t2i"
suite: str # "1-gpu" or "2-gpu"
est_time: float # estimated time in seconds
@dataclass
class DiffusionSuiteInfo:
"""Complete information for a test suite."""
suite: str # "1-gpu" or "2-gpu"
cases: List[DiffusionCaseInfo] # parametrized test cases
standalone_files: List[str] # standalone test files
standalone_est_times: Dict[str, float] # standalone file -> estimated seconds
missing_standalone_estimates: List[
str
] # standalone files without configured estimate
class DiffusionTestCaseVisitor(ast.NodeVisitor):
"""
AST visitor to extract DiffusionTestCase definitions from the case config.
Parses assignments like:
ONE_GPU_CASES_A: list[DiffusionTestCase] = [
DiffusionTestCase("case_id", ...),
...
]
"""
def __init__(self):
self.cases: Dict[str, List[str]] = {} # list_name -> [case_id, ...]
self.factory_case_ids: Dict[str, str] = {}
def visit_Module(self, node: ast.Module):
for stmt in node.body:
if not isinstance(stmt, ast.FunctionDef):
continue
case_id = self._extract_factory_case_id(stmt)
if case_id:
self.factory_case_ids[stmt.name] = case_id
self.generic_visit(node)
def visit_Expr(self, node: ast.Expr):
"""Handle ``LIST.append(...)`` mutations at any nesting level.
Previously only module-top-level ``ast.Expr`` statements were scanned for
``.append()`` calls, so cases registered under a platform guard such as
``if not current_platform.is_hip(): ONE_GPU_CASES.append(...)`` (used by
``hunyuan3d_shape_gen`` and ``turbo_wan2_1_t2v_1.3b``) were invisible to
the partition planner and therefore never scheduled in CI. Visiting every
``Expr`` lets ``generic_visit`` reach appends inside ``if``/``else`` blocks.
"""
self._process_expr(node.value)
self.generic_visit(node)
def visit_Assign(self, node: ast.Assign):
self._process_assignment(node.targets, node.value)
self.generic_visit(node)
def visit_AnnAssign(self, node: ast.AnnAssign):
if node.target and node.value:
self._process_assignment([node.target], node.value)
self.generic_visit(node)
def visit_AugAssign(self, node: ast.AugAssign):
self._process_aug_assignment(node.target, node.op, node.value)
self.generic_visit(node)
def _process_assignment(self, targets: List[ast.AST], value: ast.AST):
"""Process an assignment to extract case IDs."""
for target in targets:
if isinstance(target, ast.Name):
list_name = target.id
case_ids = self._extract_case_ids(value)
if case_ids is not None:
self.cases[list_name] = case_ids
def _process_aug_assignment(self, target: ast.AST, op: ast.AST, value: ast.AST):
"""Process `+=` style assignment to merge case lists."""
if not isinstance(target, ast.Name) or not isinstance(op, ast.Add):
return
if isinstance(value, ast.Name):
target_suite = CASE_LIST_TO_SUITE.get(target.id)
value_suite = CASE_LIST_TO_SUITE.get(value.id)
if target_suite and value_suite and target_suite != value_suite:
return
rhs_case_ids = self._extract_case_ids(value)
if rhs_case_ids is None:
return
lhs_case_ids = self.cases.get(target.id, [])
self.cases[target.id] = [*lhs_case_ids, *rhs_case_ids]
def _process_expr(self, node: ast.AST):
"""Process list mutation calls such as `ONE_GPU_CASES.append(...)`."""
if not isinstance(node, ast.Call):
return
if not isinstance(node.func, ast.Attribute):
return
if node.func.attr != "append":
return
if not isinstance(node.func.value, ast.Name):
return
list_name = node.func.value.id
if list_name not in CASE_LIST_TO_SUITE:
return
if len(node.args) != 1:
return
case_id = self._extract_case_id_from_call(node.args[0])
if case_id:
self.cases.setdefault(list_name, []).append(case_id)
def _extract_case_ids(self, node: ast.AST) -> Optional[List[str]]:
"""Extract case IDs from a supported expression."""
if isinstance(node, ast.List):
return self._extract_case_ids_from_list(node)
if isinstance(node, ast.Name):
# Reference to a previously parsed list variable.
if node.id not in self.cases:
return None
return list(self.cases[node.id])
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
left_ids = self._extract_case_ids(node.left)
right_ids = self._extract_case_ids(node.right)
if left_ids is None or right_ids is None:
return None
return [*left_ids, *right_ids]
return None
def _extract_case_ids_from_list(self, node: ast.List) -> List[str]:
"""Extract case IDs from a literal list of DiffusionTestCase calls."""
case_ids = []
for elt in node.elts:
if isinstance(elt, ast.Starred):
starred_case_ids = self._extract_case_ids(elt.value)
if starred_case_ids:
case_ids.extend(starred_case_ids)
continue
case_id = self._extract_case_id_from_call(elt)
if case_id:
case_ids.append(case_id)
return case_ids
def _extract_case_id_from_call(self, node: ast.AST) -> Optional[str]:
"""Extract case_id from DiffusionTestCase(...) call."""
if not isinstance(node, ast.Call):
return None
# First positional argument is the case_id.
if isinstance(node.func, ast.Name) and node.func.id in {
"DiffusionTestCase",
"_make_modelopt_ci_case",
}:
if node.args and isinstance(node.args[0], ast.Constant):
return node.args[0].value
if isinstance(node.func, ast.Name) and not node.args:
return self.factory_case_ids.get(node.func.id)
return None
def _extract_factory_case_id(self, node: ast.FunctionDef) -> Optional[str]:
for child in ast.walk(node):
if not isinstance(child, ast.Return) or child.value is None:
continue
case_id = self._extract_case_id_from_call(child.value)
if case_id:
return case_id
return None
def resolve_case_config_path(repo_root: Path, run_suite_path: Path) -> Path:
"""
Resolve the diffusion case config path from run_suite imports.
run_suite.py must import BOTH ONE_GPU_CASES and TWO_GPU_CASES from the same
module. That imported module is treated as the single source of truth.
"""
with open(run_suite_path, "r", encoding="utf-8") as f:
content = f.read()
tree = ast.parse(content, filename=str(run_suite_path))
one_gpu_module: Optional[str] = None
two_gpu_module: Optional[str] = None
for node in ast.walk(tree):
if not isinstance(node, ast.ImportFrom) or not node.module:
continue
imported_names = {alias.name for alias in node.names}
if "ONE_GPU_CASES" in imported_names:
one_gpu_module = node.module
if "TWO_GPU_CASES" in imported_names:
two_gpu_module = node.module
if one_gpu_module is None or two_gpu_module is None:
raise RuntimeError(
"run_suite.py must import BOTH ONE_GPU_CASES and TWO_GPU_CASES."
)
if one_gpu_module != two_gpu_module:
raise RuntimeError(
"run_suite.py imports ONE_GPU_CASES and TWO_GPU_CASES from different "
f"modules: {one_gpu_module} vs {two_gpu_module}"
)
rel_path = Path(*one_gpu_module.split(".")).with_suffix(".py")
candidates = [repo_root / rel_path, repo_root / "python" / rel_path]
case_config_path = next((path for path in candidates if path.exists()), None)
if case_config_path is None:
raise FileNotFoundError(
"Resolved case config from run_suite does not exist. Checked: "
+ ", ".join(str(path) for path in candidates)
)
return case_config_path
class RunSuiteVisitor(ast.NodeVisitor):
"""
AST visitor to extract standalone metadata from run_suite.py.
Parses:
STANDALONE_FILES = {
"1-gpu": ["test_lora_format_adapter.py"],
"2-gpu": [],
}
"""
def __init__(self):
self.standalone_files: Dict[str, List[str]] = {}
self.standalone_est_times: Dict[str, Dict[str, float]] = {}
def visit_Assign(self, node: ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == "STANDALONE_FILES":
self.standalone_files = self._extract_file_dict(node.value)
if (
isinstance(target, ast.Name)
and target.id == "STANDALONE_FILE_EST_TIMES"
):
self.standalone_est_times = self._extract_est_time_dict(node.value)
self.generic_visit(node)
def _extract_file_dict(self, node: ast.AST) -> Dict[str, List[str]]:
"""Extract dictionary of suite -> file list."""
result = {}
if isinstance(node, ast.Dict):
for key, value in zip(node.keys, node.values):
if isinstance(key, ast.Constant) and isinstance(value, ast.List):
suite = key.value
files = [
elt.value for elt in value.elts if isinstance(elt, ast.Constant)
]
result[suite] = files
return result
def _extract_est_time_dict(self, node: ast.AST) -> Dict[str, Dict[str, float]]:
"""Extract dictionary of suite -> standalone file -> estimated seconds."""
result = {}
if not isinstance(node, ast.Dict):
return result
for key, value in zip(node.keys, node.values):
if not isinstance(key, ast.Constant) or not isinstance(value, ast.Dict):
continue
suite = key.value
suite_est_times = {}
for inner_key, inner_value in zip(value.keys, value.values):
if not (
isinstance(inner_key, ast.Constant)
and isinstance(inner_value, ast.Constant)
):
continue
suite_est_times[inner_key.value] = float(inner_value.value)
result[suite] = suite_est_times
return result
def _iter_baseline_paths(baseline_path: Path) -> List[Path]:
if baseline_path.is_file():
return [baseline_path]
if not baseline_path.is_dir():
return []
ordered_paths = [
baseline_path / f"{platform}.json" for platform in BASELINE_PLATFORM_ORDER
]
ordered_paths.extend(
path
for path in sorted(baseline_path.glob("*.json"))
if path not in ordered_paths
)
return [path for path in ordered_paths if path.exists()]
def load_baselines(baseline_path: Path) -> Dict[str, float]:
"""
Load performance baselines from a JSON file or platform baseline directory.
Returns:
Dictionary mapping case_id to estimated time in seconds.
"""
baselines = {}
for path in _iter_baseline_paths(baseline_path):
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
scenarios = data.get("scenarios", {})
for case_id, scenario in scenarios.items():
if scenario.get("estimated_full_test_time_s") is not None:
est_time = scenario["estimated_full_test_time_s"]
else:
expected_e2e_ms = scenario.get("expected_e2e_ms", 0)
est_time = expected_e2e_ms / 1000.0 + STARTUP_OVERHEAD_SECONDS
baselines.setdefault(case_id, est_time)
return baselines
def get_case_est_time(case_id: str, baselines: Dict[str, float]) -> float:
"""Get estimated time for a case, with fallback to default."""
return baselines.get(case_id, DEFAULT_EST_TIME_SECONDS)
def parse_testcase_configs(config_path: Path) -> Dict[str, List[str]]:
"""
Parse a diffusion case config file to extract case IDs.
Returns:
Dictionary mapping list name to case IDs.
e.g., {"ONE_GPU_CASES_A": ["qwen_image_t2i", ...], ...}
"""
with open(config_path, "r", encoding="utf-8") as f:
content = f.read()
tree = ast.parse(content, filename=str(config_path))
visitor = DiffusionTestCaseVisitor()
visitor.visit(tree)
return visitor.cases
def parse_run_suite_standalone_data(
run_suite_path: Path,
) -> tuple[Dict[str, List[str]], Dict[str, Dict[str, float]]]:
"""
Parse run_suite.py to extract standalone file metadata.
Returns:
Tuple of:
- suite -> standalone file list
- suite -> standalone file -> estimated seconds
"""
with open(run_suite_path, "r", encoding="utf-8") as f:
content = f.read()
tree = ast.parse(content, filename=str(run_suite_path))
visitor = RunSuiteVisitor()
visitor.visit(tree)
return visitor.standalone_files, visitor.standalone_est_times
def validate_standalone_est_times(
standalone_files: Dict[str, List[str]],
standalone_est_times: Dict[str, Dict[str, float]],
) -> Dict[str, List[str]]:
missing_by_suite = {}
for suite, files in standalone_files.items():
suite_est_times = standalone_est_times.get(suite, {})
missing = [
standalone_file
for standalone_file in files
if standalone_file not in suite_est_times
]
if missing:
missing_by_suite[suite] = missing
return missing_by_suite
def collect_diffusion_suites(
case_config_path: Path,
run_suite_path: Path,
baseline_path: Path,
) -> Dict[str, DiffusionSuiteInfo]:
"""
Collect all diffusion test suite information using AST parsing.
Args:
case_config_path: Path to case config (resolved from run_suite.py)
run_suite_path: Path to run_suite.py
baseline_path: Path to perf_baselines/ or a single baseline JSON file
Returns:
Dictionary mapping suite name to DiffusionSuiteInfo.
"""
# Parse case IDs from the single source case config.
case_lists = parse_testcase_configs(case_config_path)
# Parse standalone files from run_suite.py
standalone_files, standalone_est_times = parse_run_suite_standalone_data(
run_suite_path
)
missing_standalone_estimates = validate_standalone_est_times(
standalone_files, standalone_est_times
)
# Load baselines for time estimation
baselines = load_baselines(baseline_path)
# Build suite info
suites = {}
for list_name, suite in CASE_LIST_TO_SUITE.items():
case_ids = case_lists.get(list_name, [])
cases = [
DiffusionCaseInfo(
case_id=cid,
suite=suite,
est_time=get_case_est_time(cid, baselines),
)
for cid in case_ids
]
if suite not in suites:
suites[suite] = DiffusionSuiteInfo(
suite=suite,
cases=[],
standalone_files=standalone_files.get(suite, []),
standalone_est_times=dict(standalone_est_times.get(suite, {})),
missing_standalone_estimates=list(
missing_standalone_estimates.get(suite, [])
),
)
suites[suite].cases.extend(cases)
# Dedupe duplicated case IDs while preserving first-seen order.
for suite_info in suites.values():
seen_case_ids = set()
deduped_cases = []
for case in suite_info.cases:
if case.case_id in seen_case_ids:
continue
seen_case_ids.add(case.case_id)
deduped_cases.append(case)
suite_info.cases = deduped_cases
return suites
@@ -0,0 +1,835 @@
"""Generate a Markdown dashboard for SGLang-Diffusion nightly benchmarks.
Reads current comparison results + historical data from sgl-project/ci-data repo
and produces a Markdown report with tables and trend charts saved as PNG files.
Usage:
python3 scripts/ci/utils/diffusion/generate_diffusion_dashboard.py \
--results comparison-results.json \
--output dashboard.md \
--charts-dir comparison-charts/ \
--history-dir history/ # optional, local history JSONs
--fetch-history # fetch from GitHub API instead
"""
import argparse
import json
import os
from datetime import datetime, timezone
# ---------------------------------------------------------------------------
# History fetching (from sgl-project/ci-data repo via GitHub API)
# ---------------------------------------------------------------------------
CI_DATA_REPO_OWNER = "sgl-project"
CI_DATA_REPO_NAME = "ci-data"
CI_DATA_BRANCH = "main"
HISTORY_PREFIX = "diffusion-comparisons"
MAX_HISTORY_RUNS = 29
# Base URL for chart images pushed to sgl-project/ci-data
CHARTS_RAW_BASE_URL = (
f"https://raw.githubusercontent.com/{CI_DATA_REPO_OWNER}/{CI_DATA_REPO_NAME}"
f"/{CI_DATA_BRANCH}/{HISTORY_PREFIX}/charts"
)
def _github_get(url: str, token: str) -> dict | list | None:
"""Simple GET to GitHub API."""
from urllib.error import HTTPError
from urllib.request import Request, urlopen
headers = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
}
req = Request(url, headers=headers)
try:
with urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8"))
except HTTPError as e:
print(f" Warning: GitHub API request failed ({e.code}): {url}")
return None
except Exception as e:
print(f" Warning: GitHub API request error: {e}")
return None
def fetch_history_from_github(token: str) -> list[dict]:
"""Fetch recent comparison result JSONs from sgl-project/ci-data repo."""
print("Fetching historical comparison data from GitHub...")
url = (
f"https://api.github.com/repos/{CI_DATA_REPO_OWNER}/{CI_DATA_REPO_NAME}"
f"/contents/{HISTORY_PREFIX}?ref={CI_DATA_BRANCH}"
)
listing = _github_get(url, token)
if not listing or not isinstance(listing, list):
print(" No historical data found.")
return []
# Filter JSON files and sort by name (date prefix) descending
json_files = sorted(
[f for f in listing if f["name"].endswith(".json")],
key=lambda f: f["name"],
reverse=True,
)[:MAX_HISTORY_RUNS]
history = []
for entry in json_files:
raw_url = entry.get("download_url")
if not raw_url:
continue
data = _github_get(raw_url, token)
if data and isinstance(data, dict):
history.append(data)
print(f" Loaded {len(history)} historical run(s).")
return history
def load_history_from_dir(history_dir: str) -> list[dict]:
"""Load historical JSONs from a local directory."""
if not os.path.isdir(history_dir):
return []
files = sorted(
[f for f in os.listdir(history_dir) if f.endswith(".json")],
reverse=True,
)[:MAX_HISTORY_RUNS]
history = []
for fname in files:
try:
with open(os.path.join(history_dir, fname)) as f:
history.append(json.load(f))
except Exception:
pass
return history
# ---------------------------------------------------------------------------
# Dashboard generation
# ---------------------------------------------------------------------------
def _fmt_latency(val: float | None) -> str:
if val is None:
return "N/A"
return f"{val:.2f}"
def _fmt_speedup(sglang_lat: float | None, other_lat: float | None) -> str:
if sglang_lat is None or other_lat is None or sglang_lat <= 0:
return "N/A"
ratio = other_lat / sglang_lat
return f"{ratio:.2f}x"
def _short_date(ts: str) -> str:
"""Extract short date from ISO timestamp."""
try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
return dt.strftime("%b %d")
except Exception:
return ts[:10]
def _short_sha(sha: str) -> str:
return sha[:7] if sha and sha != "unknown" else "?"
def _assess_risk(
cid: str,
current_cases: dict[str, dict[str, float | None]],
history: list[dict],
other_frameworks: list[str],
) -> tuple[str, str]:
"""Assess risk for a given case, returning (emoji, reason).
Rules (checked in order):
- N/A latency → ❌ broken
- History exists: SGLang latency >5% vs avg of last 3 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
- Default → ✅ stable
"""
sg_lat = current_cases.get(cid, {}).get("sglang")
# Broken: sglang latency is N/A
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)",
)
# Check competitive risk
if other_frameworks:
competitor_lats: dict[str, float] = {}
for ofw in other_frameworks:
olat = current_cases.get(cid, {}).get(ofw)
if olat is not None:
competitor_lats[ofw] = olat
if competitor_lats:
# SGLang slower than any competitor?
for ofw, olat in competitor_lats.items():
if sg_lat > olat:
return (
"🔴",
f"{cid}: SGLang slower than {ofw} "
f"({sg_lat:.2f}s vs {olat:.2f}s)",
)
# SGLang faster — check margin
min_competitor = min(competitor_lats.values())
advantage = (min_competitor - sg_lat) / min_competitor
if advantage > 0.20:
return "🟢", ""
else:
return "🟡", ""
# Default: stable
return "", ""
def _trend_emoji(current: float | None, previous: float | None) -> str:
if current is None or previous is None:
return ""
diff_pct = (current - previous) / previous * 100
if diff_pct < -2:
return " :arrow_down:" # faster (good)
elif diff_pct > 2:
return " :arrow_up:" # slower (bad)
return " :left_right_arrow:"
def _extract_case_results(run_data: dict) -> dict[str, dict[str, float | None]]:
"""Extract {case_id: {framework: latency}} from a run."""
mapping: dict[str, dict[str, float | None]] = {}
for r in run_data.get("results", []):
cid = r["case_id"]
fw = r["framework"]
if cid not in mapping:
mapping[cid] = {}
mapping[cid][fw] = r.get("latency_s")
return mapping
def _sanitize_filename(name: str) -> str:
"""Sanitize a case ID to be a safe filename."""
return name.replace("/", "_").replace(" ", "_").replace(":", "_")
def generate_dashboard(
current: dict,
history: list[dict],
charts_dir: str | None = None,
) -> tuple[str, list[str]]:
"""Generate full markdown dashboard.
Returns (markdown_string, alert_reasons) where alert_reasons is a list of
human-readable strings for cases that need attention (empty if all is well).
If charts_dir is provided, saves chart PNGs as files to that directory
and references them via raw.githubusercontent URLs. Otherwise, charts
are omitted.
Returns the markdown string.
"""
lines: list[str] = []
lines.append("# SGLang-Diffusion Nightly Performance Dashboard\n")
ts = current.get("timestamp", datetime.now(timezone.utc).isoformat())
sha = current.get("commit_sha", "unknown")
lines.append(f"*Generated: {_short_date(ts)} | Commit: `{_short_sha(sha)}`*\n")
current_cases = _extract_case_results(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
if pct > REGRESSION_THRESHOLD:
regressions.append(
f"**{cid}** ({fw}): {prev:.2f}s -> {cur:.2f}s "
f"(+{pct*100:.1f}%)"
)
if regressions:
lines.append("> [!WARNING]\n> **Performance Regression Detected**\n>")
for reg in regressions:
lines.append(f"> - {reg}")
lines.append("\n")
# Discover all frameworks present in results
all_frameworks = []
seen_fw = set()
for r in current.get("results", []):
fw = r["framework"]
if fw not in seen_fw:
all_frameworks.append(fw)
seen_fw.add(fw)
# Ensure sglang is first
if "sglang" in all_frameworks:
all_frameworks.remove("sglang")
all_frameworks.insert(0, "sglang")
other_frameworks = [fw for fw in all_frameworks if fw != "sglang"]
# ---- Section 1: SGLang-Diffusion performance (current run) ----
lines.append("## SGLang-Diffusion Performance\n")
# Compute risk assessments for all cases
risk_map: dict[str, tuple[str, str]] = {}
for cid in case_ids:
risk_map[cid] = _assess_risk(cid, current_cases, history, other_frameworks)
# Dynamic header
header = "| Model | Risk |"
sep = "|-------|------|"
for fw in all_frameworks:
header += f" {fw} (s) |"
sep += "---------|"
for ofw in other_frameworks:
header += f" vs {ofw} |"
sep += "---------|"
lines.append(header)
lines.append(sep)
# One row per case (deduplicated by case_id)
seen_cases = set()
for r in current.get("results", []):
cid = r["case_id"]
if cid in seen_cases:
continue
seen_cases.add(cid)
case_fws = current_cases.get(cid, {})
sg_lat = case_fws.get("sglang")
risk_emoji, _ = risk_map.get(cid, ("", ""))
row = f"| {r['model'].split('/')[-1]} | {risk_emoji} |"
# 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]
min_lat = min(valid_lats) if valid_lats else None
for fw in all_frameworks:
lat = lats[fw]
if lat is not None and min_lat is not None and lat == min_lat:
row += f" **{_fmt_latency(lat)}** |"
else:
row += f" {_fmt_latency(lat)} |"
# Speedup columns
for ofw in other_frameworks:
row += f" {_fmt_speedup(sg_lat, case_fws.get(ofw))} |"
lines.append(row)
# ---- 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")
header = "| Date |"
sep = "|------|"
for cid in case_ids:
header += f" {cid} |"
sep += "---------|"
lines.append(header)
lines.append(sep)
all_runs = [current] + history
for run in all_runs:
run_cases = _extract_case_results(run)
date = _short_date(run.get("timestamp", ""))
row = f"| {date} |"
for cid in case_ids:
sg = run_cases.get(cid, {}).get("sglang")
vl = run_cases.get(cid, {}).get("vllm-omni")
row += f" {_fmt_speedup(sg, vl)} |"
lines.append(row)
# ---- Section 4: Matplotlib Trend Charts (saved as PNG files) ----
if history and charts_dir:
all_runs = list(reversed([current] + history)) # chronological order
def _chart_label(run: dict) -> str:
d = _short_date(run.get("timestamp", ""))
s = _short_sha(run.get("commit_sha", ""))
return f"{d}\n({s})"
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
os.makedirs(charts_dir, exist_ok=True)
# Per-case latency trend charts
for cid in case_ids:
labels = []
sg_vals = []
vl_vals = []
for run in all_runs:
run_cases = _extract_case_results(run)
sg = run_cases.get(cid, {}).get("sglang")
vl = run_cases.get(cid, {}).get("vllm-omni")
if sg is None:
continue
labels.append(_chart_label(run))
sg_vals.append(sg)
vl_vals.append(vl)
if not sg_vals:
continue
has_vl = any(v is not None for v in vl_vals)
fig, ax = plt.subplots(figsize=(max(6, len(labels) * 1.2), 4))
# SGLang line
ax.plot(
range(len(sg_vals)),
sg_vals,
"o-",
color="#2563eb",
linewidth=2,
markersize=6,
label="SGLang",
)
for i, v in enumerate(sg_vals):
ax.annotate(
f"{v:.2f}s",
(i, v),
textcoords="offset points",
xytext=(0, 10),
ha="center",
fontsize=8,
fontweight="bold",
color="#2563eb",
)
# vLLM-Omni line (if data exists)
if has_vl:
vl_clean = [v if v is not None else float("nan") for v in vl_vals]
ax.plot(
range(len(vl_clean)),
vl_clean,
"s--",
color="#dc2626",
linewidth=2,
markersize=5,
label="vLLM-Omni",
)
for i, v in enumerate(vl_vals):
if v is not None:
ax.annotate(
f"{v:.2f}s",
(i, v),
textcoords="offset points",
xytext=(0, -14),
ha="center",
fontsize=8,
color="#dc2626",
)
ax.set_xticks(range(len(labels)))
ax.set_xticklabels(labels, fontsize=7)
ax.set_ylabel("Latency (s)")
ax.set_title(f"Latency Trend -- {cid}", fontsize=11, fontweight="bold")
ax.legend(loc="lower right", fontsize=8, framealpha=0.8)
ax.grid(True, alpha=0.3)
all_vals = sg_vals + [v for v in vl_vals if v is not None]
y_min = min(all_vals)
y_max = max(all_vals)
y_range = y_max - y_min if y_max > y_min else max(y_max * 0.1, 0.1)
ax.set_ylim(
bottom=max(0, y_min - y_range * 0.3),
top=y_max + y_range * 0.3,
)
filename = f"latency_{_sanitize_filename(cid)}.png"
chart_path = os.path.join(charts_dir, filename)
fig.savefig(chart_path, format="png", dpi=120, bbox_inches="tight")
plt.close(fig)
print(f" Saved chart: {chart_path}")
chart_url = f"{CHARTS_RAW_BASE_URL}/{filename}"
lines.append(f"\n### Latency Trend: {cid}\n")
lines.append(f"![Latency Trend {cid}]({chart_url})\n")
# Speedup trend chart (only if multiple frameworks)
if other_frameworks:
fig, ax = plt.subplots(figsize=(max(6, len(all_runs) * 1.2), 4))
colors = ["#2563eb", "#dc2626", "#16a34a", "#ea580c"]
for ci_idx, cid in enumerate(case_ids):
speedups = []
run_labels = []
for run in all_runs:
run_cases = _extract_case_results(run)
sg = run_cases.get(cid, {}).get("sglang")
vl = run_cases.get(cid, {}).get("vllm-omni")
if sg and vl and sg > 0:
speedups.append(vl / sg)
else:
speedups.append(None)
run_labels.append(_chart_label(run))
clean = [v if v is not None else float("nan") for v in speedups]
ax.plot(
range(len(clean)),
clean,
"o-",
color=colors[ci_idx % len(colors)],
linewidth=2,
markersize=5,
label=cid,
)
ax.set_xticks(range(len(run_labels)))
ax.set_xticklabels(run_labels, fontsize=7)
ax.set_ylabel("Speedup (x)")
ax.set_title(
"SGLang Speedup Over vLLM-Omni", fontsize=11, fontweight="bold"
)
ax.axhline(y=1.0, color="gray", linestyle=":", alpha=0.5)
ax.legend(loc="upper left", fontsize=7)
ax.grid(True, alpha=0.3)
filename = "speedup_trend.png"
chart_path = os.path.join(charts_dir, filename)
fig.savefig(chart_path, format="png", dpi=120, bbox_inches="tight")
plt.close(fig)
print(f" Saved chart: {chart_path}")
chart_url = f"{CHARTS_RAW_BASE_URL}/{filename}"
lines.append("\n### Speedup Trend (SGLang vs vLLM-Omni)\n")
lines.append(f"![Speedup Trend]({chart_url})\n")
except ImportError:
lines.append("\n*Charts unavailable (matplotlib not installed)*\n")
# ---- SGLang Performance Trend (raw data table, at the end) ----
if history:
lines.append(f"\n## SGLang Performance Trend (Last {len(history) + 1} Runs)\n")
header = "| Date | Commit |"
sep = "|------|--------|"
for cid in case_ids:
header += f" {cid} (s) |"
sep += "---------|"
header += " Trend |"
sep += "-------|"
lines.append(header)
lines.append(sep)
all_runs = [current] + history
for i, run in enumerate(all_runs):
run_cases = _extract_case_results(run)
date = _short_date(run.get("timestamp", ""))
sha_s = _short_sha(run.get("commit_sha", ""))
row = f"| {date} | `{sha_s}` |"
for cid in case_ids:
lat = run_cases.get(cid, {}).get("sglang")
row += f" {_fmt_latency(lat)} |"
if i + 1 < len(all_runs):
prev_cases = _extract_case_results(all_runs[i + 1])
emojis = []
for cid in case_ids:
cur = run_cases.get(cid, {}).get("sglang")
prev = prev_cases.get(cid, {}).get("sglang")
emojis.append(_trend_emoji(cur, prev))
row += " ".join(emojis) + " |"
else:
row += " -- |"
lines.append(row)
# ---- Risk Notification ----
alert_cases = [
(cid, emoji, reason)
for cid, (emoji, reason) in risk_map.items()
if emoji in ("⚠️", "🔴", "")
]
if alert_cases:
lines.append("\n> [!CAUTION]")
lines.append("> **Action Required — Performance Alert**")
lines.append(">")
lines.append("> The following cases need attention:")
for _cid, _emoji, reason in alert_cases:
lines.append(f"> - {reason}")
lines.append("")
# Footer
lines.append("\n---")
lines.append(
"*Generated by `generate_diffusion_dashboard.py` in SGLang nightly CI.*"
)
alert_reasons = [reason for _, _, reason in alert_cases]
return "\n".join(lines) + "\n", alert_reasons
ALERT_ASSIGNEES = ["mickqian", "bbuf", "yhyang201"]
ALERT_LABEL = "perf-regression"
ALERT_ISSUE_TITLE = "[Diffusion CI] Performance regression tracker"
def _find_alert_issue(repo: str) -> tuple[str | None, bool]:
"""Find the perf-regression tracker issue (open OR closed).
Returns (issue_number, is_open). Prefers an open issue; if none,
returns the most recent closed one so it can be reopened.
"""
import subprocess
for state in ("open", "closed"):
result = subprocess.run(
[
"gh",
"issue",
"list",
"--repo",
repo,
"--label",
ALERT_LABEL,
"--state",
state,
"--json",
"number",
"--limit",
"1",
],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0 or not result.stdout.strip():
continue
issues = json.loads(result.stdout)
if issues:
return str(issues[0]["number"]), state == "open"
return None, False
def _create_alert_issue(alert_reasons: list[str]) -> None:
"""Create or update the single perf-regression tracker issue.
Logic:
- If an open issue exists → add a comment with the new alert.
- If a closed issue exists → reopen it, then add a comment.
- If no issue exists → create one.
This guarantees at most one tracker issue ever exists.
Uses `gh` (GitHub CLI) which is available in all GitHub Actions runners.
Falls back silently outside CI.
"""
import subprocess
run_url = ""
run_id = os.environ.get("GITHUB_RUN_ID", "")
repo = os.environ.get("GITHUB_REPOSITORY", "sgl-project/sglang")
server_url = os.environ.get("GITHUB_SERVER_URL", "https://github.com")
if run_id:
run_url = f"{server_url}/{repo}/actions/runs/{run_id}"
date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
body_lines = [
f"## Performance Alert — {date}",
"",
"The nightly diffusion benchmark detected the following issue(s):",
"",
]
for reason in alert_reasons:
body_lines.append(f"- {reason}")
if run_url:
body_lines += ["", f"**CI Run:** {run_url}"]
body = "\n".join(body_lines)
try:
existing, is_open = _find_alert_issue(repo)
if existing:
# Reopen if closed
if not is_open:
subprocess.run(
[
"gh",
"issue",
"reopen",
existing,
"--repo",
repo,
],
capture_output=True,
text=True,
timeout=30,
)
print(f"Reopened alert issue #{existing}")
# Add comment
result = subprocess.run(
[
"gh",
"issue",
"comment",
existing,
"--repo",
repo,
"--body",
body,
],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
print(f"Commented on alert issue #{existing}")
else:
print(
f"Warning: failed to comment on issue #{existing} "
f"(rc={result.returncode}): {result.stderr.strip()}"
)
else:
# Create a new issue
cmd = [
"gh",
"issue",
"create",
"--repo",
repo,
"--title",
ALERT_ISSUE_TITLE,
"--body",
body,
"--label",
ALERT_LABEL,
]
for user in ALERT_ASSIGNEES:
cmd += ["--assignee", user]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode == 0:
print(f"Created alert issue: {result.stdout.strip()}")
else:
print(
f"Warning: failed to create alert issue "
f"(rc={result.returncode}): {result.stderr.strip()}"
)
except FileNotFoundError:
print("Warning: `gh` CLI not found — skipping alert issue creation")
except Exception as e:
print(f"Warning: failed to create/update alert issue: {e}")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Generate SGLang-Diffusion nightly benchmark dashboard"
)
parser.add_argument(
"--results",
required=True,
help="Path to comparison-results.json from current run",
)
parser.add_argument(
"--output",
default="dashboard.md",
help="Output markdown file path",
)
parser.add_argument(
"--charts-dir",
default="comparison-charts",
help="Directory to save chart PNG files (default: comparison-charts/)",
)
parser.add_argument(
"--history-dir",
default=None,
help="Local directory containing historical comparison JSONs",
)
parser.add_argument(
"--fetch-history",
action="store_true",
help="Fetch history from ci-data GitHub repo",
)
parser.add_argument(
"--step-summary",
action="store_true",
help="Also write to $GITHUB_STEP_SUMMARY",
)
args = parser.parse_args()
# Load current results
with open(args.results) as f:
current = json.load(f)
print(f"Loaded current results: {len(current.get('results', []))} entries")
# Load history
history: list[dict] = []
if args.fetch_history:
token = os.environ.get("GH_PAT_FOR_NIGHTLY_CI_DATA") or os.environ.get(
"GITHUB_TOKEN"
)
if token:
history = fetch_history_from_github(token)
else:
print("Warning: No GitHub token available, skipping history fetch")
elif args.history_dir:
history = load_history_from_dir(args.history_dir)
print(f"Loaded {len(history)} historical run(s) from {args.history_dir}")
# Generate dashboard
markdown, alert_reasons = generate_dashboard(
current, history, charts_dir=args.charts_dir
)
# Write output
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
with open(args.output, "w") as f:
f.write(markdown)
print(f"Dashboard written to {args.output}")
# Write to GitHub Step Summary
if args.step_summary:
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
if summary_file:
with open(summary_file, "a") as f:
f.write(markdown)
print("Dashboard appended to $GITHUB_STEP_SUMMARY")
else:
print("Warning: $GITHUB_STEP_SUMMARY not set, skipping")
# Create GitHub Issue for performance alerts (so assignees get notified)
if alert_reasons:
_create_alert_issue(alert_reasons)
else:
print("No performance alerts — skipping issue creation.")
if __name__ == "__main__":
main()
@@ -0,0 +1,231 @@
"""Publish SGLang-Diffusion nightly benchmark results to sgl-project/ci-data repo.
Pushes comparison-results.json, dashboard.md, and chart PNG files to the
ci-data repository for historical tracking. Chart PNGs are stored under
diffusion-comparisons/charts/ so they can be referenced via
raw.githubusercontent URLs in the dashboard markdown (GitHub Step Summary
blocks data: URIs).
Usage:
python3 scripts/ci/utils/diffusion/publish_comparison_results.py \
--results comparison-results.json \
--dashboard dashboard.md \
--charts-dir comparison-charts/
"""
import argparse
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
# Reuse GitHub API helpers from publish_traces.
# Support both direct script execution and package-style imports.
if __package__:
from ..publish_traces import (
create_blobs,
create_commit,
create_tree,
get_branch_sha,
get_tree_sha,
is_permission_error,
is_rate_limit_error,
update_branch_ref,
verify_token_permissions,
)
else:
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from publish_traces import (
create_blobs,
create_commit,
create_tree,
get_branch_sha,
get_tree_sha,
is_permission_error,
is_rate_limit_error,
update_branch_ref,
verify_token_permissions,
)
# Repository configuration
REPO_OWNER = "sgl-project"
REPO_NAME = "ci-data"
BRANCH = "main"
STORAGE_PREFIX = "diffusion-comparisons"
def _collect_chart_files(charts_dir: str) -> list[tuple[str, bytes]]:
"""Collect PNG chart files from directory for upload."""
files: list[tuple[str, bytes]] = []
if not charts_dir or not os.path.isdir(charts_dir):
return files
for entry in sorted(os.listdir(charts_dir)):
if not entry.lower().endswith(".png"):
continue
full_path = os.path.join(charts_dir, entry)
if not os.path.isfile(full_path):
continue
with open(full_path, "rb") as f:
content = f.read()
# Store charts under diffusion-comparisons/charts/
repo_path = f"{STORAGE_PREFIX}/charts/{entry}"
files.append((repo_path, content))
return files
def publish_comparison(
results_path: str,
dashboard_path: str | None = None,
charts_dir: str | None = None,
) -> None:
"""Publish comparison results, dashboard, and charts to ci-data repo."""
token = os.environ.get("GH_PAT_FOR_NIGHTLY_CI_DATA") or os.environ.get(
"GITHUB_TOKEN"
)
if not token:
print("Error: GH_PAT_FOR_NIGHTLY_CI_DATA or GITHUB_TOKEN not set")
sys.exit(1)
run_id = os.environ.get("GITHUB_RUN_ID", "local")
run_number = os.environ.get("GITHUB_RUN_NUMBER", "0")
# Verify permissions
perm = verify_token_permissions(REPO_OWNER, REPO_NAME, token)
if perm == "rate_limited":
print("Warning: Rate limited, skipping publish")
return
elif not perm:
print("Error: Token permission verification failed")
sys.exit(1)
# Prepare files to upload
files_to_upload: list[tuple[str, bytes]] = []
# Results JSON: stored with date prefix for chronological ordering
date_prefix = datetime.now(timezone.utc).strftime("%Y-%m-%d")
results_target = f"{STORAGE_PREFIX}/{date_prefix}_{run_id}.json"
with open(results_path, "rb") as f:
files_to_upload.append((results_target, f.read()))
# Dashboard markdown: always overwrite latest
if dashboard_path and os.path.exists(dashboard_path):
dashboard_target = f"{STORAGE_PREFIX}/dashboard.md"
with open(dashboard_path, "rb") as f:
files_to_upload.append((dashboard_target, f.read()))
# Chart PNG files
chart_files = _collect_chart_files(charts_dir)
if chart_files:
print(f"Found {len(chart_files)} chart PNG(s) to upload")
files_to_upload.extend(chart_files)
print(f"Publishing {len(files_to_upload)} file(s) to {REPO_OWNER}/{REPO_NAME}")
# Create blobs
try:
tree_items = create_blobs(REPO_OWNER, REPO_NAME, files_to_upload, token)
except Exception as e:
if is_rate_limit_error(e):
print("Warning: Rate limited during blob creation, skipping")
return
if is_permission_error(e):
print(f"Error: No write permission to {REPO_OWNER}/{REPO_NAME}")
sys.exit(1)
raise
# Commit with retry (handle concurrent writes)
max_retries = 5
retry_delay = 5
for attempt in range(max_retries):
try:
branch_sha = get_branch_sha(REPO_OWNER, REPO_NAME, BRANCH, token)
tree_sha = get_tree_sha(REPO_OWNER, REPO_NAME, branch_sha, token)
new_tree_sha = create_tree(
REPO_OWNER, REPO_NAME, tree_sha, tree_items, token
)
commit_msg = (
f"Diffusion comparison results for run {run_id} (#{run_number})"
)
commit_sha = create_commit(
REPO_OWNER, REPO_NAME, new_tree_sha, branch_sha, commit_msg, token
)
update_branch_ref(REPO_OWNER, REPO_NAME, BRANCH, commit_sha, token)
print(
f"Successfully published comparison results (commit {commit_sha[:7]})"
)
return
except Exception as e:
is_retryable = False
if hasattr(e, "error_body"):
body = getattr(e, "error_body", "")
if "Update is not a fast forward" in body:
is_retryable = True
elif "Object does not exist" in body:
is_retryable = True
from urllib.error import HTTPError
if isinstance(e, HTTPError) and e.code in [422, 500, 502, 503, 504]:
is_retryable = True
if is_rate_limit_error(e):
print("Warning: Rate limited, skipping publish")
return
if is_permission_error(e):
print(f"Error: No write permission to {REPO_OWNER}/{REPO_NAME}")
sys.exit(1)
if is_retryable and attempt < max_retries - 1:
print(
f"Attempt {attempt + 1}/{max_retries} failed, retrying in {retry_delay}s..."
)
time.sleep(retry_delay)
else:
print(f"Failed to publish after {attempt + 1} attempts: {e}")
raise
def main():
parser = argparse.ArgumentParser(
description="Publish SGLang-Diffusion nightly benchmark results to ci-data"
)
parser.add_argument(
"--results",
required=True,
help="Path to comparison-results.json",
)
parser.add_argument(
"--dashboard",
default=None,
help="Path to dashboard.md (optional)",
)
parser.add_argument(
"--charts-dir",
default=None,
help="Directory containing chart PNG files to upload (optional)",
)
args = parser.parse_args()
if not os.path.exists(args.results):
print(f"Error: Results file not found: {args.results}")
sys.exit(1)
publish_comparison(
results_path=args.results,
dashboard_path=args.dashboard,
charts_dir=args.charts_dir,
)
if __name__ == "__main__":
main()
@@ -0,0 +1,507 @@
"""
Publish diffusion CI ground-truth images to sgl-project/ci-data
via the GitHub API (same pattern as publish_traces.py).
"""
import argparse
import base64
import hashlib
import io
import json
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from urllib.error import HTTPError
import numpy as np
from PIL import Image, ImageFilter
# Reuse GitHub API helpers from publish_traces.
# Support both direct script execution and package-style imports.
if __package__:
from ..publish_traces import (
create_blobs,
create_commit,
create_tree,
get_branch_sha,
get_tree_sha,
is_permission_error,
is_rate_limit_error,
make_github_request,
update_branch_ref,
verify_token_permissions,
)
else:
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from publish_traces import (
create_blobs,
create_commit,
create_tree,
get_branch_sha,
get_tree_sha,
is_permission_error,
is_rate_limit_error,
make_github_request,
update_branch_ref,
verify_token_permissions,
)
REPO_OWNER = "sgl-project"
REPO_NAME = "ci-data"
BRANCH = "main"
DEFAULT_TARGET_DIR = "diffusion-ci/consistency_gt/sglang_generated"
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
QUALITY_MAX_SIDE = 256
LOW_DETAIL_STD_THRESHOLD = 0.075
LOW_DETAIL_ENTROPY_THRESHOLD = 0.55
LOW_DETAIL_BLUR_RESIDUAL_THRESHOLD = 0.035
LOW_DETAIL_GRADIENT_P95_THRESHOLD = 0.045
RANDOM_NOISE_CORRELATION_THRESHOLD = 0.55
RANDOM_NOISE_LOW_FREQUENCY_THRESHOLD = 0.20
RANDOM_NOISE_BLUR_RESIDUAL_THRESHOLD = 0.045
OLD_NEW_MIN_SSIM = 0.20
OLD_NEW_MAX_MEAN_ABS_DIFF = 45.0
@dataclass(frozen=True)
class ImageQualityMetrics:
luminance_std: float
entropy: float
blur_residual: float
gradient_p95: float
neighbor_correlation: float
low_frequency_ratio: float
@dataclass(frozen=True)
class OldNewMetrics:
ssim: float
mean_abs_diff: float
def collect_images(source_dir, target_dir):
"""Collect image files from source_dir and return list of (repo_path, content) tuples."""
files = []
for entry in sorted(os.listdir(source_dir)):
ext = os.path.splitext(entry)[1].lower()
if ext not in IMAGE_EXTENSIONS:
continue
full_path = os.path.join(source_dir, entry)
if not os.path.isfile(full_path):
continue
with open(full_path, "rb") as f:
content = f.read()
repo_path = f"{target_dir}/{entry}"
files.append((repo_path, content))
return files
def git_blob_sha(content):
header = f"blob {len(content)}\0".encode()
return hashlib.sha1(header + content).hexdigest()
def get_remote_blob_shas(repo_owner, repo_name, target_dir, token):
return {
path: item["sha"]
for path, item in get_remote_image_entries(
repo_owner, repo_name, target_dir, token
).items()
}
def get_remote_image_entries(repo_owner, repo_name, target_dir, token):
url = (
f"https://api.github.com/repos/{repo_owner}/{repo_name}/contents/"
f"{target_dir}?ref={BRANCH}"
)
try:
response = make_github_request(url, token)
except HTTPError as e:
if e.code == 404:
return {}
raise
entries = json.loads(response)
return {
item["path"]: item
for item in entries
if item.get("type") == "file"
and "sha" in item
and os.path.splitext(item["path"])[1].lower() in IMAGE_EXTENSIONS
}
def filter_changed_files(files, remote_blob_shas):
return [
(path, content)
for path, content in files
if remote_blob_shas.get(path) != git_blob_sha(content)
]
def get_remote_blob_content(repo_owner, repo_name, blob_sha, token):
url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/git/blobs/{blob_sha}"
response = make_github_request(url, token)
blob = json.loads(response)
if blob.get("encoding") != "base64":
raise ValueError(
f"Unexpected blob encoding for {blob_sha}: {blob.get('encoding')}"
)
return base64.b64decode(blob["content"])
def _load_quality_image(content):
with Image.open(io.BytesIO(content)) as image:
image = image.convert("RGB")
image.thumbnail((QUALITY_MAX_SIDE, QUALITY_MAX_SIDE), Image.Resampling.BICUBIC)
return image.copy()
def _image_to_rgb_array(image):
return np.asarray(image, dtype=np.float32)
def _luminance(rgb):
return 0.299 * rgb[..., 0] + 0.587 * rgb[..., 1] + 0.114 * rgb[..., 2]
def _neighbor_correlation(luma):
def corr(a, b):
a = a.ravel()
b = b.ravel()
if a.std() < 1e-6 or b.std() < 1e-6:
return 1.0
return float(np.corrcoef(a, b)[0, 1])
return (corr(luma[:, 1:], luma[:, :-1]) + corr(luma[1:, :], luma[:-1, :])) / 2
def _low_frequency_ratio(luma):
centered = luma - luma.mean()
power = np.abs(np.fft.fftshift(np.fft.fft2(centered))) ** 2
total_power = power.sum()
if total_power < 1e-12:
return 0.0
height, width = luma.shape
y, x = np.ogrid[:height, :width]
center_y = height // 2
center_x = width // 2
radius = np.sqrt((y - center_y) ** 2 + (x - center_x) ** 2)
low_frequency_radius = min(height, width) * 0.08
return float(power[radius <= low_frequency_radius].sum() / total_power)
def compute_image_quality_metrics(content):
image = _load_quality_image(content)
rgb = _image_to_rgb_array(image)
luma = _luminance(rgb) / 255.0
gradients = np.concatenate(
[
np.abs(np.diff(luma, axis=1)).ravel(),
np.abs(np.diff(luma, axis=0)).ravel(),
]
)
histogram, _ = np.histogram(luma, bins=32, range=(0, 1))
probabilities = histogram / histogram.sum()
nonzero_probabilities = probabilities[probabilities > 0]
entropy = float(
-(nonzero_probabilities * np.log2(nonzero_probabilities)).sum() / 5.0
)
blurred = _image_to_rgb_array(image.filter(ImageFilter.GaussianBlur(radius=3)))
return ImageQualityMetrics(
luminance_std=float(luma.std()),
entropy=entropy,
blur_residual=float(np.mean(np.abs(rgb - blurred)) / 255.0),
gradient_p95=float(np.percentile(gradients, 95)),
neighbor_correlation=_neighbor_correlation(luma),
low_frequency_ratio=_low_frequency_ratio(luma),
)
def get_quality_failure_reasons(metrics):
reasons = []
low_detail_static = (
metrics.luminance_std < LOW_DETAIL_STD_THRESHOLD
and metrics.entropy < LOW_DETAIL_ENTROPY_THRESHOLD
and (
metrics.blur_residual < LOW_DETAIL_BLUR_RESIDUAL_THRESHOLD
or metrics.gradient_p95 < LOW_DETAIL_GRADIENT_P95_THRESHOLD
)
)
high_frequency_noise = (
metrics.neighbor_correlation < RANDOM_NOISE_CORRELATION_THRESHOLD
and metrics.low_frequency_ratio < RANDOM_NOISE_LOW_FREQUENCY_THRESHOLD
and metrics.blur_residual > RANDOM_NOISE_BLUR_RESIDUAL_THRESHOLD
)
if low_detail_static:
reasons.append("low-contrast low-detail output")
if high_frequency_noise:
reasons.append("high-frequency random noise")
return reasons
def _resize_for_old_new_compare(content, size=None):
with Image.open(io.BytesIO(content)) as image:
image = image.convert("RGB")
if size is None:
image.thumbnail(
(QUALITY_MAX_SIDE, QUALITY_MAX_SIDE), Image.Resampling.BICUBIC
)
else:
image = image.resize(size, Image.Resampling.BICUBIC)
return _image_to_rgb_array(image)
def compute_old_new_metrics(old_content, new_content):
old_rgb = _resize_for_old_new_compare(old_content)
new_rgb = _resize_for_old_new_compare(
new_content, size=(old_rgb.shape[1], old_rgb.shape[0])
)
old_luma = _luminance(old_rgb) / 255.0
new_luma = _luminance(new_rgb) / 255.0
old_mean = old_luma.mean()
new_mean = new_luma.mean()
old_variance = old_luma.var()
new_variance = new_luma.var()
covariance = ((old_luma - old_mean) * (new_luma - new_mean)).mean()
c1 = 0.01**2
c2 = 0.03**2
ssim = (
(2 * old_mean * new_mean + c1)
* (2 * covariance + c2)
/ ((old_mean**2 + new_mean**2 + c1) * (old_variance + new_variance + c2))
)
return OldNewMetrics(
ssim=float(ssim),
mean_abs_diff=float(np.abs(old_rgb - new_rgb).mean()),
)
def _format_quality_metrics(metrics):
return (
f"std={metrics.luminance_std:.4f}, entropy={metrics.entropy:.4f}, "
f"blur_residual={metrics.blur_residual:.4f}, "
f"gradient_p95={metrics.gradient_p95:.4f}, "
f"neighbor_corr={metrics.neighbor_correlation:.4f}, "
f"low_freq={metrics.low_frequency_ratio:.4f}"
)
def _format_old_new_metrics(metrics):
return f"ssim={metrics.ssim:.4f}, mean_abs_diff={metrics.mean_abs_diff:.2f}"
def validate_gt_files(files_to_upload, changed_files, remote_image_entries, token):
failures = []
for path, content in files_to_upload:
quality_metrics = compute_image_quality_metrics(content)
quality_reasons = get_quality_failure_reasons(quality_metrics)
if quality_reasons:
failures.append(
f"{path}: {', '.join(quality_reasons)} "
f"({_format_quality_metrics(quality_metrics)})"
)
for path, content in changed_files:
remote_entry = remote_image_entries.get(path)
if not remote_entry:
continue
old_content = get_remote_blob_content(
REPO_OWNER, REPO_NAME, remote_entry["sha"], token
)
old_quality_metrics = compute_image_quality_metrics(old_content)
old_quality_reasons = get_quality_failure_reasons(old_quality_metrics)
if old_quality_reasons:
print(
f"Skipping old/new drift check for {path} because existing GT is "
f"already suspicious: {', '.join(old_quality_reasons)} "
f"({_format_quality_metrics(old_quality_metrics)})"
)
continue
old_new_metrics = compute_old_new_metrics(old_content, content)
if (
old_new_metrics.ssim < OLD_NEW_MIN_SSIM
and old_new_metrics.mean_abs_diff > OLD_NEW_MAX_MEAN_ABS_DIFF
):
failures.append(
f"{path}: changed too far from existing GT "
f"({_format_old_new_metrics(old_new_metrics)})"
)
if not failures:
print(
f"GT quality gate passed for {len(files_to_upload)} generated image(s) "
f"and {len(changed_files)} changed image(s)."
)
return
print("GT quality gate failed; refusing to publish suspicious image updates:")
for failure in failures:
print(f" - {failure}")
sys.exit(1)
def check_quality(source_dir, target_dir=None):
target_dir = target_dir or DEFAULT_TARGET_DIR
token = os.getenv("GITHUB_TOKEN")
if not token:
print("Error: GITHUB_TOKEN environment variable not set")
sys.exit(1)
files_to_upload = collect_images(source_dir, target_dir)
if not files_to_upload:
print(f"No image files found in {source_dir}")
return
remote_image_entries = get_remote_image_entries(
REPO_OWNER, REPO_NAME, target_dir, token
)
remote_blob_shas = {
path: item["sha"] for path, item in remote_image_entries.items()
}
changed_files = filter_changed_files(files_to_upload, remote_blob_shas)
validate_gt_files(files_to_upload, changed_files, remote_image_entries, token)
def publish(source_dir, target_dir=None):
target_dir = target_dir or DEFAULT_TARGET_DIR
token = os.getenv("GITHUB_TOKEN")
if not token:
print("Error: GITHUB_TOKEN environment variable not set")
sys.exit(1)
files_to_upload = collect_images(source_dir, target_dir)
if not files_to_upload:
print(f"No image files found in {source_dir}")
return
print(
f"Found {len(files_to_upload)} image(s) to upload to {REPO_OWNER}/{REPO_NAME}/{target_dir}"
)
# Verify token
perm = verify_token_permissions(REPO_OWNER, REPO_NAME, token)
if perm == "rate_limited":
print("GitHub API rate-limited, skipping upload.")
return
if not perm:
print("Token permission verification failed.")
sys.exit(1)
# Commit with retry (handle concurrent pushes)
max_retries = 5
for attempt in range(max_retries):
try:
branch_sha = get_branch_sha(REPO_OWNER, REPO_NAME, BRANCH, token)
tree_sha = get_tree_sha(REPO_OWNER, REPO_NAME, branch_sha, token)
remote_image_entries = get_remote_image_entries(
REPO_OWNER, REPO_NAME, target_dir, token
)
remote_blob_shas = {
path: item["sha"] for path, item in remote_image_entries.items()
}
changed_files = filter_changed_files(files_to_upload, remote_blob_shas)
validate_gt_files(
files_to_upload, changed_files, remote_image_entries, token
)
if not changed_files:
print("No image changes to publish.")
return
try:
tree_items = create_blobs(REPO_OWNER, REPO_NAME, changed_files, token)
except Exception as e:
if is_rate_limit_error(e):
print("Rate-limited during blob creation, skipping.")
return
if is_permission_error(e):
print(
f"ERROR: Token lacks write permission to {REPO_OWNER}/{REPO_NAME}. "
"Update GH_PAT_FOR_NIGHTLY_CI_DATA with a token that has contents:write."
)
sys.exit(1)
raise
new_tree_sha = create_tree(
REPO_OWNER, REPO_NAME, tree_sha, tree_items, token
)
if new_tree_sha == tree_sha:
print("No tree changes to publish.")
return
commit_msg = f"diffusion-ci: update images in {target_dir} ({len(changed_files)} files) [automated]"
commit_sha = create_commit(
REPO_OWNER, REPO_NAME, new_tree_sha, branch_sha, commit_msg, token
)
update_branch_ref(REPO_OWNER, REPO_NAME, BRANCH, commit_sha, token)
print(
f"Successfully pushed {len(changed_files)} changed images (commit {commit_sha[:10]})"
)
return
except Exception as e:
if is_rate_limit_error(e):
print("Rate-limited, skipping.")
return
if is_permission_error(e):
print(f"ERROR: permission denied to {REPO_OWNER}/{REPO_NAME}")
sys.exit(1)
retryable = False
if hasattr(e, "error_body"):
if "Update is not a fast forward" in e.error_body:
retryable = True
elif "Object does not exist" in e.error_body:
retryable = True
if isinstance(e, HTTPError) and e.code in [422, 500, 502, 503, 504]:
retryable = True
if retryable and attempt < max_retries - 1:
import time
wait = 2**attempt
print(
f"Attempt {attempt + 1}/{max_retries} failed, retrying in {wait}s..."
)
time.sleep(wait)
else:
print(f"Failed after {attempt + 1} attempts: {e}")
raise
def main():
parser = argparse.ArgumentParser(
description="Publish diffusion GT images to GitHub"
)
parser.add_argument(
"--source-dir", required=True, help="Directory containing GT images"
)
parser.add_argument(
"--target-dir",
required=False,
default=None,
help=f"Target directory in the remote repo (default: {DEFAULT_TARGET_DIR})",
)
parser.add_argument(
"--check-only",
action="store_true",
help="Validate generated GT images without publishing them",
)
args = parser.parse_args()
if args.check_only:
check_quality(args.source_dir, args.target_dir)
else:
publish(args.source_dir, args.target_dir)
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Collect and save diffusion performance metrics for artifact collection in CI.
This script reads diffusion test results from the pytest stash and saves them
with metadata for the performance dashboard.
Usage:
python3 scripts/ci/utils/diffusion/save_diffusion_metrics.py \
--gpu-config 1-gpu-h100 \
--run-id 12345678 \
--output test/diffusion-metrics-1gpu.json \
--results-json test/diffusion-results.json
"""
import argparse
import json
import os
import sys
from datetime import datetime, timezone
def load_diffusion_results(results_file: str) -> list[dict]:
"""Load diffusion performance results from JSON file."""
if not os.path.exists(results_file):
print(f"Warning: Results file not found: {results_file}")
return []
try:
with open(results_file, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, list) else [data]
except (json.JSONDecodeError, OSError) as e:
print(f"Warning: Failed to parse {results_file}: {e}")
return []
def transform_diffusion_result(result: dict, gpu_config: str) -> dict:
"""Transform a diffusion result to match dashboard expectations.
Dashboard expects:
- Separate test_name, class_name
- Numeric metrics in consistent units
- Optional modality field
"""
return {
"test_name": result.get("test_name"),
"class_name": result.get("class_name"),
"modality": result.get("modality", "image"),
"e2e_ms": result.get("e2e_ms"),
"avg_denoise_ms": result.get("avg_denoise_ms"),
"median_denoise_ms": result.get("median_denoise_ms"),
"stage_metrics": result.get("stage_metrics", {}),
"sampled_steps": result.get("sampled_steps", {}),
# Video-specific metrics (if present)
"frames_per_second": result.get("frames_per_second"),
"total_frames": result.get("total_frames"),
"avg_frame_time_ms": result.get("avg_frame_time_ms"),
}
def group_results_by_class(results: list[dict], gpu_config: str) -> list[dict]:
"""Group diffusion results by test class (suite).
Returns list with one entry per test class, containing all tests in that class.
"""
groups = {}
for result in results:
class_name = result.get("class_name", "unknown")
if class_name not in groups:
groups[class_name] = {
"gpu_config": gpu_config,
"test_suite": class_name,
"tests": [],
}
transformed = transform_diffusion_result(result, gpu_config)
groups[class_name]["tests"].append(transformed)
return list(groups.values())
def save_metrics(
gpu_config: str,
run_id: str,
output_file: str,
results_file: str,
) -> bool:
"""Collect diffusion metrics and save to output file."""
timestamp = datetime.now(timezone.utc).isoformat()
# Load diffusion results
raw_results = load_diffusion_results(results_file)
print(f"Loaded {len(raw_results)} diffusion test result(s)")
# Group by test class
grouped = group_results_by_class(raw_results, gpu_config)
# Create metrics structure
metrics = {
"run_id": run_id,
"timestamp": timestamp,
"gpu_config": gpu_config,
"test_type": "diffusion",
"results": grouped,
}
# Ensure output directory exists and write output
try:
os.makedirs(os.path.dirname(output_file) or ".", exist_ok=True)
with open(output_file, "w", encoding="utf-8") as f:
json.dump(metrics, f, indent=2)
if not raw_results:
print(f"Created empty metrics file: {output_file}")
else:
print(f"Saved diffusion metrics to: {output_file}")
return True
except OSError as e:
print(f"Error writing metrics file: {e}")
return False
def main():
parser = argparse.ArgumentParser(
description="Collect diffusion performance metrics from test results"
)
parser.add_argument(
"--gpu-config",
required=True,
help="GPU configuration (e.g., 1-gpu-h100, 2-gpu-h100)",
)
parser.add_argument(
"--run-id",
required=True,
help="GitHub Actions run ID",
)
parser.add_argument(
"--output",
required=True,
help="Output file path for metrics JSON",
)
parser.add_argument(
"--results-json",
required=True,
help="Path to diffusion results JSON file",
)
args = parser.parse_args()
success = save_metrics(
gpu_config=args.gpu_config,
run_id=args.run_id,
output_file=args.output,
results_file=args.results_json,
)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
+343
View File
@@ -0,0 +1,343 @@
#!/usr/bin/env python3
"""
Verify 100% coverage of diffusion test cases.
This script checks that all expected test cases were executed across all partitions.
Designed to run in the CI summary job after all partition jobs complete.
Usage:
python scripts/ci/utils/diffusion/verify_diffusion_coverage.py --reports-dir <path>
Exit codes:
0 - All cases executed (100% coverage)
1 - Missing cases detected (coverage < 100%)
"""
import argparse
import json
import sys
from pathlib import Path
from diffusion_case_parser import (
BASELINE_REL_PATH,
RUN_SUITE_REL_PATH,
collect_diffusion_suites,
resolve_case_config_path,
)
DYNAMIC_SUITES = {"1-gpu", "2-gpu"}
def load_execution_reports(reports_dir: Path) -> list[dict]:
"""Load all execution report JSON files from the given directory."""
reports = []
for json_file in reports_dir.glob("**/execution_report_*.json"):
with open(json_file, "r", encoding="utf-8") as f:
reports.append(json.load(f))
return reports
def get_expected_cases(repo_root: Path) -> dict[str, set[str]]:
"""
Get all expected cases from case config and run_suite.py.
Returns:
Dictionary mapping suite name to set of expected case IDs.
Standalone files are represented as "standalone:<filename>".
"""
baseline_path = repo_root / BASELINE_REL_PATH
run_suite_path = repo_root / RUN_SUITE_REL_PATH
case_config_path = resolve_case_config_path(repo_root, run_suite_path)
suites = collect_diffusion_suites(
case_config_path,
run_suite_path,
baseline_path,
)
expected = {}
for suite_name, suite_info in suites.items():
if suite_name not in DYNAMIC_SUITES:
continue
case_ids = set(case.case_id for case in suite_info.cases)
# Add standalone files as special case IDs
for standalone_file in suite_info.standalone_files:
case_ids.add(f"standalone:{standalone_file}")
expected[suite_name] = case_ids
empty_dynamic_suites = [
suite_name
for suite_name in DYNAMIC_SUITES
if suite_name in expected
and not any(
not case_id.startswith("standalone:") for case_id in expected[suite_name]
)
]
if empty_dynamic_suites:
raise RuntimeError(
"Parsed zero parametrized cases for diffusion suites: "
+ ", ".join(sorted(empty_dynamic_suites))
+ ". Refuse to pass coverage verification."
)
return expected
def collect_executed_cases(reports: list[dict]) -> dict[str, set[str]]:
"""
Collect all executed cases from execution reports.
Returns:
Dictionary mapping suite name to set of executed case IDs.
"""
executed = {}
for report in reports:
suite = report["suite"]
if suite not in executed:
executed[suite] = set()
executed_cases = report.get("executed_cases", [])
if executed_cases:
executed[suite].update(executed_cases)
elif report["is_standalone"]:
standalone_file = report["standalone_file"]
executed[suite].add(f"standalone:{standalone_file}")
return executed
def collect_case_results(reports: list[dict]) -> dict[str, dict[str, str]]:
"""
Collect case results (pass/fail/error status) from execution reports.
Returns:
Dictionary mapping suite name to {case_id: status} dictionary.
"""
results = {}
for report in reports:
suite = report["suite"]
if suite not in results:
results[suite] = {}
# Get case_results from report (empty dict for legacy reports)
case_results = report.get("case_results", {})
results[suite].update(case_results)
return results
def collect_missing_standalone_estimates(reports: list[dict]) -> dict[str, set[str]]:
missing_by_suite: dict[str, set[str]] = {}
for report in reports:
suite = report["suite"]
missing = report.get("missing_standalone_estimates", [])
if not missing:
continue
missing_by_suite.setdefault(suite, set()).update(missing)
return missing_by_suite
def collect_standalone_measurements(reports: list[dict]) -> dict[tuple[str, str], dict]:
measurements: dict[tuple[str, str], dict] = {}
for report in reports:
for measurement in report.get("standalone_measurements", []):
key = (measurement["suite"], measurement["standalone_file"])
measurements[key] = measurement
return measurements
def print_missing_standalone_estimates_summary(
missing_by_suite: dict[str, set[str]],
measurements: dict[tuple[str, str], dict],
) -> None:
if not missing_by_suite:
return
print("\n" + "=" * 60)
print(
"Add standalone estimate(s) to "
"python/sglang/multimodal_gen/test/run_suite.py"
)
print("=" * 60)
print("The following standalone file(s) used fallback estimate 300.0s.")
print("Update STANDALONE_FILE_EST_TIMES with the measured runtime below:\n")
for suite in sorted(missing_by_suite):
print(f'"{suite}": {{')
for standalone_file in sorted(missing_by_suite[suite]):
measurement = measurements.get((suite, standalone_file))
measured_time = (
measurement["measured_full_test_time_s"] if measurement else 300.0
)
print(f' "{standalone_file}": {measured_time:.1f},')
print("}\n")
def verify_coverage(
expected: dict[str, set[str]],
executed: dict[str, set[str]],
) -> tuple[bool, dict[str, set[str]]]:
"""
Verify that all expected cases were executed.
Returns:
Tuple of (is_complete, missing_cases_by_suite)
"""
missing = {}
for suite, expected_cases in expected.items():
executed_cases = executed.get(suite, set())
suite_missing = expected_cases - executed_cases
if suite_missing:
missing[suite] = suite_missing
return len(missing) == 0, missing
def print_results_summary(
case_results: dict[str, dict[str, str]],
) -> tuple[int, int, int]:
"""
Print test results summary and return counts.
Returns:
Tuple of (passed_count, failed_count, error_count)
"""
# Check if we have any results data
total_results = sum(len(results) for results in case_results.values())
if total_results == 0:
print("\nTest Results: No results data available (legacy reports)")
return (0, 0, 0)
# Count by status
passed_count = 0
failed_count = 0
error_count = 0
failed_cases: dict[str, list[str]] = {}
for suite, results in case_results.items():
for case_id, status in results.items():
if status == "pass":
passed_count += 1
elif status == "fail":
failed_count += 1
if suite not in failed_cases:
failed_cases[suite] = []
failed_cases[suite].append(case_id)
elif status == "error":
error_count += 1
if suite not in failed_cases:
failed_cases[suite] = []
failed_cases[suite].append(f"{case_id} (error)")
# Print summary
total = passed_count + failed_count + error_count
print("\n" + "=" * 60)
print("Test Results Summary")
print("=" * 60)
print(f" Total executed: {total}")
print(f" ✅ Passed: {passed_count}")
print(f" ❌ Failed: {failed_count}")
if error_count > 0:
print(f" ⚠️ Errors: {error_count}")
# Print failed cases if any
if failed_cases:
print("\nFailed cases:")
for suite, cases in sorted(failed_cases.items()):
print(f" {suite}:")
for case_id in sorted(cases):
print(f" - {case_id}")
return (passed_count, failed_count, error_count)
def main():
parser = argparse.ArgumentParser(
description="Verify 100% coverage of diffusion test cases"
)
parser.add_argument(
"--reports-dir",
type=str,
required=True,
help="Directory containing execution report JSON files",
)
args = parser.parse_args()
# Determine repository root
script_dir = Path(__file__).resolve().parent
repo_root = script_dir.parent.parent.parent.parent
reports_dir = Path(args.reports_dir)
print("=" * 60)
print("Diffusion CI Coverage Verification")
print("=" * 60)
# Load execution reports
reports = load_execution_reports(reports_dir)
print(f"\nLoaded {len(reports)} execution reports")
if not reports:
print("\nERROR: No execution reports found!")
print(f"Expected reports in: {reports_dir}")
sys.exit(1)
# Get expected cases
try:
expected = get_expected_cases(repo_root)
except (RuntimeError, FileNotFoundError) as exc:
print(f"\nERROR: {exc}")
sys.exit(1)
print("\nExpected cases by suite:")
for suite, cases in expected.items():
print(f" {suite}: {len(cases)} cases")
# Collect executed cases
executed = collect_executed_cases(reports)
print("\nExecuted cases by suite:")
for suite, cases in executed.items():
print(f" {suite}: {len(cases)} cases")
# Collect case results
case_results = collect_case_results(reports)
missing_standalone_estimates = collect_missing_standalone_estimates(reports)
standalone_measurements = collect_standalone_measurements(reports)
# Verify coverage
is_complete, missing = verify_coverage(expected, executed)
if is_complete:
print("\n" + "=" * 60)
print("✅ COVERAGE: 100% - All test cases executed")
print("=" * 60)
else:
print("\n" + "=" * 60)
print("❌ COVERAGE FAILURE: Missing test cases detected")
print("=" * 60)
for suite, cases in missing.items():
print(f"\n{suite.upper()} suite - Missing {len(cases)} case(s):")
for case_id in sorted(cases):
print(f" - {case_id}")
# Print test results summary
passed_count, failed_count, error_count = print_results_summary(case_results)
print_missing_standalone_estimates_summary(
missing_standalone_estimates, standalone_measurements
)
# Exit with appropriate code
if not is_complete:
sys.exit(1)
elif missing_standalone_estimates:
sys.exit(1)
elif failed_count > 0 or error_count > 0:
print("\n" + "=" * 60)
print("⚠️ WARNING: Some tests failed but coverage is complete")
print("=" * 60)
sys.exit(0) # Coverage is complete, failures are visible in results
else:
sys.exit(0)
if __name__ == "__main__":
main()