94057c3d3e
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
1629 lines
58 KiB
Python
1629 lines
58 KiB
Python
"""
|
|
Server management and performance validation for diffusion tests.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import os
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Sequence
|
|
from urllib.request import urlopen
|
|
|
|
import pytest
|
|
from openai import Client
|
|
|
|
from sglang.multimodal_gen.benchmarks.compare_perf import calculate_upper_bound
|
|
from sglang.multimodal_gen.runtime.platforms import current_platform
|
|
from sglang.multimodal_gen.runtime.utils.common import kill_process_tree
|
|
from sglang.multimodal_gen.runtime.utils.logging_utils import (
|
|
globally_suppress_loggers,
|
|
init_logger,
|
|
)
|
|
from sglang.multimodal_gen.runtime.utils.perf_logger import RequestPerfRecord
|
|
from sglang.multimodal_gen.test.server.common.slack import upload_file_to_slack
|
|
from sglang.multimodal_gen.test.server.realtime_consistency import (
|
|
build_realtime_init_payload,
|
|
collect_realtime_output,
|
|
encode_realtime_frames_to_mp4,
|
|
prepare_realtime_first_frame,
|
|
realtime_ws_url,
|
|
record_realtime_key_frames,
|
|
record_realtime_perf_stats,
|
|
)
|
|
from sglang.multimodal_gen.test.server.testcase_configs import (
|
|
DiffusionSamplingParams,
|
|
PerformanceSummary,
|
|
ScenarioConfig,
|
|
ToleranceConfig,
|
|
)
|
|
from sglang.multimodal_gen.test.test_utils import (
|
|
get_expected_image_format,
|
|
get_video_frame_count,
|
|
is_image_url,
|
|
prepare_perf_log,
|
|
validate_image,
|
|
validate_image_file,
|
|
validate_openai_video,
|
|
validate_video_file,
|
|
)
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
globally_suppress_loggers()
|
|
|
|
FIRST_DENOISE_STEP_TOLERANCE = 4.0
|
|
FIRST_DENOISE_STEP_MIN_ABS_TOLERANCE_MS = 80.0
|
|
DECODING_STAGE_MIN_ABS_TOLERANCE_MS = 450.0
|
|
VIDEO_DENOISE_STEP_MIN_ABS_TOLERANCE_MS = 160.0
|
|
|
|
# Tracks mesh output file paths from generate_mesh for later correctness validation.
|
|
# Keyed by case_id, cleaned up after use.
|
|
MESH_OUTPUT_PATHS: dict[str, str] = {}
|
|
|
|
|
|
def _urlopen_with_retry(url: str, timeout: int = 30, max_retries: int = 3) -> bytes:
|
|
"""Download content from a URL with retry on transient failures."""
|
|
for attempt in range(max_retries + 1):
|
|
try:
|
|
with urlopen(url, timeout=timeout) as response:
|
|
return response.read()
|
|
except (TimeoutError, OSError) as e:
|
|
if attempt < max_retries:
|
|
wait = 2**attempt
|
|
logger.warning(
|
|
f"Download attempt {attempt + 1}/{max_retries + 1} failed "
|
|
f"for {url}: {e}. Retrying in {wait}s..."
|
|
)
|
|
time.sleep(wait)
|
|
else:
|
|
logger.error(
|
|
f"Failed to download from {url} after "
|
|
f"{max_retries + 1} attempts: {e}"
|
|
)
|
|
raise
|
|
|
|
|
|
def download_image_from_url(url: str) -> Path:
|
|
"""Download an image from a URL to a temporary file.
|
|
|
|
Args:
|
|
url: The URL of the image to download
|
|
|
|
Returns:
|
|
Path to the downloaded temporary file
|
|
"""
|
|
logger.info(f"Downloading image from URL: {url}")
|
|
|
|
# Determine file extension from URL
|
|
ext = ".jpg" # default
|
|
if url.lower().endswith((".png", ".jpeg", ".jpg", ".webp", ".gif")):
|
|
ext = url[url.rfind(".") :]
|
|
|
|
# Create temporary file
|
|
temp_file = (
|
|
Path(tempfile.gettempdir()) / f"diffusion_test_image_{int(time.time())}{ext}"
|
|
)
|
|
|
|
data = _urlopen_with_retry(url)
|
|
temp_file.write_bytes(data)
|
|
logger.info(f"Downloaded image to: {temp_file}")
|
|
return temp_file
|
|
|
|
|
|
def parse_dimensions(size_string: str | None) -> tuple[int | None, int | None]:
|
|
"""Parse a size string in "widthxheight" format to (width, height) tuple.
|
|
|
|
Args:
|
|
size_string: Size string in "widthxheight" format (e.g., "1024x1024") or None.
|
|
Spaces are automatically stripped.
|
|
|
|
Returns:
|
|
Tuple of (width, height) as integers if parsing succeeds, (None, None) otherwise.
|
|
"""
|
|
if not size_string:
|
|
return (None, None)
|
|
|
|
# Strip spaces from the entire string
|
|
size_string = size_string.strip()
|
|
if not size_string:
|
|
return (None, None)
|
|
|
|
# Split by "x"
|
|
parts = size_string.split("x")
|
|
if len(parts) != 2:
|
|
return (None, None)
|
|
|
|
# Strip spaces from each part and try to convert to int
|
|
try:
|
|
width_str = parts[0].strip()
|
|
height_str = parts[1].strip()
|
|
|
|
if not width_str or not height_str:
|
|
return (None, None)
|
|
|
|
width = int(width_str)
|
|
height = int(height_str)
|
|
|
|
# Validate that both are positive
|
|
if width <= 0 or height <= 0:
|
|
return (None, None)
|
|
|
|
return (width, height)
|
|
except ValueError:
|
|
return (None, None)
|
|
|
|
|
|
@dataclass
|
|
class ServerContext:
|
|
"""Context for a running diffusion server."""
|
|
|
|
port: int
|
|
process: subprocess.Popen
|
|
model: str
|
|
stdout_file: Path
|
|
perf_log_path: Path
|
|
log_dir: Path
|
|
_stdout_fh: Any = field(repr=False)
|
|
_log_thread: threading.Thread | None = field(default=None, repr=False)
|
|
|
|
def log_tail(self, lines: int = 200) -> str:
|
|
"""Return recent server output for failure diagnostics."""
|
|
try:
|
|
content = self.stdout_file.read_text(encoding="utf-8", errors="ignore")
|
|
return "\n".join(content.splitlines()[-lines:])
|
|
except Exception:
|
|
return ""
|
|
|
|
def cleanup(self) -> None:
|
|
"""Clean up server resources."""
|
|
try:
|
|
kill_process_tree(self.process.pid)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self._stdout_fh.flush()
|
|
self._stdout_fh.close()
|
|
except Exception:
|
|
pass
|
|
|
|
# ROCm/AMD: Extra cleanup to ensure GPU memory is released between tests
|
|
# This is needed because ROCm memory release can be slower than CUDA
|
|
if current_platform.is_hip():
|
|
self._cleanup_rocm_gpu_memory()
|
|
# Clean up downloaded models if HF cache is not persistent
|
|
# This prevents disk exhaustion in CI when cache is not mounted
|
|
self._cleanup_hf_cache_if_not_persistent()
|
|
else:
|
|
# Give the runtime a brief cooldown after server shutdown.
|
|
time.sleep(2)
|
|
|
|
def _cleanup_hf_cache_if_not_persistent(self) -> None:
|
|
"""Clean up HF cache if it's not on a persistent volume.
|
|
|
|
When running in CI without persistent cache, downloaded models accumulate
|
|
and can cause disk/memory exhaustion. This cleans up the model after each
|
|
test if the cache is not persistent.
|
|
"""
|
|
import shutil
|
|
|
|
hf_home = os.environ.get("HF_HOME", "")
|
|
if not hf_home:
|
|
return
|
|
|
|
hf_hub_cache = os.path.join(hf_home, "hub")
|
|
|
|
# Check if HF cache is on a persistent volume by looking for a marker file
|
|
# or checking if the directory existed before this test run
|
|
persistent_marker = os.path.join(hf_home, ".persistent_cache")
|
|
if os.path.exists(persistent_marker):
|
|
logger.info("HF cache is persistent, skipping cleanup")
|
|
return
|
|
|
|
# Check if the cache directory is empty or was just created
|
|
# If it has very few models, it's likely not persistent
|
|
if not os.path.exists(hf_hub_cache):
|
|
return
|
|
|
|
try:
|
|
# Get model cache directories
|
|
model_dirs = [
|
|
d
|
|
for d in os.listdir(hf_hub_cache)
|
|
if d.startswith("models--")
|
|
and os.path.isdir(os.path.join(hf_hub_cache, d))
|
|
]
|
|
|
|
# If there are cached models but no persistent marker, clean up
|
|
# to prevent disk exhaustion in CI
|
|
if model_dirs:
|
|
logger.info(
|
|
"HF cache appears non-persistent (no .persistent_cache marker), "
|
|
"cleaning up %d model(s) to prevent disk exhaustion",
|
|
len(model_dirs),
|
|
)
|
|
for model_dir in model_dirs:
|
|
model_path = os.path.join(hf_hub_cache, model_dir)
|
|
try:
|
|
shutil.rmtree(model_path)
|
|
logger.info("Cleaned up model cache: %s", model_dir)
|
|
except Exception as e:
|
|
logger.warning("Failed to clean up %s: %s", model_dir, e)
|
|
except Exception as e:
|
|
logger.warning("Error during HF cache cleanup: %s", e)
|
|
|
|
def _cleanup_rocm_gpu_memory(self) -> None:
|
|
"""ROCm-specific cleanup to ensure GPU memory is fully released."""
|
|
import gc
|
|
|
|
# Wait for process to fully terminate
|
|
try:
|
|
self.process.wait(timeout=30)
|
|
except Exception:
|
|
pass
|
|
|
|
# Force garbage collection multiple times
|
|
for _ in range(3):
|
|
gc.collect()
|
|
|
|
# Clear HIP memory on all GPUs
|
|
try:
|
|
import torch
|
|
|
|
for i in range(torch.cuda.device_count()):
|
|
with torch.cuda.device(i):
|
|
torch.cuda.empty_cache()
|
|
torch.cuda.synchronize()
|
|
except Exception:
|
|
pass
|
|
|
|
# Wait for GPU memory to be released (ROCm can be much slower than CUDA)
|
|
# The GPU driver needs time to reclaim memory from killed processes
|
|
time.sleep(15)
|
|
|
|
|
|
class ServerManager:
|
|
"""Manages diffusion server lifecycle."""
|
|
|
|
def __init__(
|
|
self,
|
|
model: str,
|
|
port: int,
|
|
wait_deadline: float = 1200.0,
|
|
extra_args: str = "",
|
|
env_vars: dict[str, str] | None = None,
|
|
):
|
|
self.model = model
|
|
self.port = port
|
|
self.wait_deadline = wait_deadline
|
|
self.extra_args = extra_args
|
|
self.env_vars = env_vars or {}
|
|
|
|
def _wait_for_rocm_gpu_memory_clear(self, max_wait: float = 60.0) -> None:
|
|
"""ROCm-specific: Wait for GPU memory to be mostly free before starting.
|
|
|
|
ROCm GPU memory release from killed processes can be significantly slower
|
|
than CUDA, so we need to wait longer and be more patient.
|
|
"""
|
|
try:
|
|
import torch
|
|
|
|
if not torch.cuda.is_available():
|
|
return
|
|
|
|
start_time = time.time()
|
|
last_total_used = float("inf")
|
|
|
|
while time.time() - start_time < max_wait:
|
|
# Check GPU memory usage
|
|
total_used = 0
|
|
for i in range(torch.cuda.device_count()):
|
|
mem_info = torch.cuda.mem_get_info(i)
|
|
free, total = mem_info
|
|
used = total - free
|
|
total_used += used
|
|
|
|
# If less than 5GB is used across all GPUs, we're good
|
|
if total_used < 5 * 1024 * 1024 * 1024: # 5GB
|
|
logger.info(
|
|
"[server-test] ROCm GPU memory is clear (used: %.2f GB)",
|
|
total_used / (1024**3),
|
|
)
|
|
return
|
|
|
|
# Log progress
|
|
elapsed = int(time.time() - start_time)
|
|
if total_used < last_total_used:
|
|
logger.info(
|
|
"[server-test] ROCm: GPU memory clearing (used: %.2f GB, elapsed: %ds)",
|
|
total_used / (1024**3),
|
|
elapsed,
|
|
)
|
|
else:
|
|
logger.info(
|
|
"[server-test] ROCm: Waiting for GPU memory (used: %.2f GB, elapsed: %ds)",
|
|
total_used / (1024**3),
|
|
elapsed,
|
|
)
|
|
last_total_used = total_used
|
|
time.sleep(3)
|
|
|
|
# Final warning with detailed GPU info
|
|
logger.warning(
|
|
"[server-test] ROCm GPU memory not fully cleared after %.0fs (used: %.2f GB). "
|
|
"Proceeding anyway - this may cause OOM.",
|
|
max_wait,
|
|
total_used / (1024**3),
|
|
)
|
|
except Exception as e:
|
|
logger.debug("[server-test] Could not check ROCm GPU memory: %s", e)
|
|
|
|
def start(self) -> ServerContext:
|
|
"""Start the diffusion server and wait for readiness."""
|
|
# ROCm/AMD: Wait for GPU memory to be clear before starting
|
|
# This prevents OOM when running sequential tests on ROCm
|
|
if current_platform.is_hip():
|
|
self._wait_for_rocm_gpu_memory_clear()
|
|
|
|
log_dir, perf_log_path = prepare_perf_log()
|
|
|
|
safe_model_name = self.model.replace("/", "_")
|
|
stdout_path = (
|
|
Path(tempfile.gettempdir())
|
|
/ f"sgl_server_{self.port}_{safe_model_name}.log"
|
|
)
|
|
stdout_path.unlink(missing_ok=True)
|
|
|
|
command = [
|
|
"sglang",
|
|
"serve",
|
|
"--model-path",
|
|
self.model,
|
|
"--port",
|
|
str(self.port),
|
|
"--log-level=debug",
|
|
]
|
|
if self.extra_args.strip():
|
|
command.extend(self.extra_args.strip().split())
|
|
|
|
env = os.environ.copy()
|
|
env["SGLANG_DIFFUSION_STAGE_LOGGING"] = "1"
|
|
env["SGLANG_PERF_LOG_DIR"] = log_dir.as_posix()
|
|
|
|
# Apply custom environment variables
|
|
env.update(self.env_vars)
|
|
|
|
cmd_str = shlex.join(command)
|
|
# Use print (not logger) so the command always appears in CI output
|
|
# regardless of log-level configuration.
|
|
print(f"[server-test] Running command: {cmd_str}", flush=True)
|
|
|
|
process = subprocess.Popen(
|
|
command,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1,
|
|
env=env,
|
|
)
|
|
|
|
log_thread = None
|
|
stdout_fh = stdout_path.open("w", encoding="utf-8", buffering=1)
|
|
if process.stdout:
|
|
|
|
def _log_pipe(pipe: Any, file: Any) -> None:
|
|
"""Read from pipe and write to file and stdout."""
|
|
try:
|
|
with pipe:
|
|
for line in iter(pipe.readline, ""):
|
|
sys.stdout.write(line)
|
|
sys.stdout.flush()
|
|
file.write(line)
|
|
file.flush()
|
|
except Exception as e:
|
|
logger.error("Log pipe thread error: %s", e)
|
|
finally:
|
|
file.close()
|
|
logger.debug("Log pipe thread finished.")
|
|
|
|
log_thread = threading.Thread(
|
|
target=_log_pipe, args=(process.stdout, stdout_fh)
|
|
)
|
|
log_thread.daemon = True
|
|
log_thread.start()
|
|
|
|
print(
|
|
f"[server-test] Starting server pid={process.pid}, "
|
|
f"model={self.model}, log={stdout_path}",
|
|
flush=True,
|
|
)
|
|
|
|
self._wait_for_ready(process, stdout_path)
|
|
|
|
return ServerContext(
|
|
port=self.port,
|
|
process=process,
|
|
model=self.model,
|
|
stdout_file=stdout_path,
|
|
perf_log_path=perf_log_path,
|
|
log_dir=log_dir,
|
|
_stdout_fh=stdout_fh,
|
|
_log_thread=log_thread,
|
|
)
|
|
|
|
def _wait_for_ready(self, process: subprocess.Popen, stdout_path: Path) -> None:
|
|
"""Wait for server to become ready."""
|
|
start = time.time()
|
|
ready_message = "Application startup complete."
|
|
log_period = 30
|
|
prev_log_period_count = 0
|
|
|
|
while time.time() - start < self.wait_deadline:
|
|
if process.poll() is not None:
|
|
tail = self._get_log_tail(stdout_path)
|
|
raise RuntimeError(
|
|
f"Server exited early (code {process.returncode}).\n{tail}"
|
|
)
|
|
|
|
if stdout_path.exists():
|
|
try:
|
|
content = stdout_path.read_text(encoding="utf-8", errors="ignore")
|
|
if ready_message in content:
|
|
logger.info("[server-test] Server ready")
|
|
return
|
|
except Exception as e:
|
|
logger.debug("Could not read log yet: %s", e)
|
|
|
|
elapsed = int(time.time() - start)
|
|
if (elapsed // log_period) > prev_log_period_count:
|
|
prev_log_period_count = elapsed // log_period
|
|
logger.info("[server-test] Waiting for server... elapsed=%ss", elapsed)
|
|
time.sleep(1)
|
|
|
|
tail = self._get_log_tail(stdout_path)
|
|
raise TimeoutError(f"Server not ready within {self.wait_deadline}s.\n{tail}")
|
|
|
|
@staticmethod
|
|
def _get_log_tail(path: Path, lines: int = 200) -> str:
|
|
"""Get the last N lines from a log file."""
|
|
try:
|
|
content = path.read_text(encoding="utf-8", errors="ignore")
|
|
return "\n".join(content.splitlines()[-lines:])
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
class PerformanceValidator:
|
|
"""Validates performance metrics against expectations."""
|
|
|
|
is_video_gen: bool = False
|
|
|
|
def __init__(
|
|
self,
|
|
scenario: ScenarioConfig,
|
|
tolerances: ToleranceConfig,
|
|
step_fractions: Sequence[float],
|
|
):
|
|
self.scenario = scenario
|
|
self.tolerances = tolerances
|
|
self.step_fractions = step_fractions
|
|
self.is_baseline_generation_mode = (
|
|
os.environ.get("SGLANG_GEN_BASELINE", "0") == "1"
|
|
)
|
|
|
|
def _assert_le(
|
|
self,
|
|
name: str,
|
|
actual: float,
|
|
expected: float,
|
|
tolerance: float,
|
|
min_abs_tolerance_ms: float = 20.0,
|
|
):
|
|
"""Assert that actual is less than or equal to expected within a tolerance.
|
|
|
|
Uses the larger of relative tolerance or absolute tolerance to prevent
|
|
flaky failures on very fast operations.
|
|
|
|
For AMD GPUs, uses 100% higher tolerance and issues warning instead of assertion.
|
|
"""
|
|
# Check if running on AMD GPU
|
|
is_amd = current_platform.is_hip()
|
|
|
|
if is_amd:
|
|
# Use 100% higher tolerance for AMD (2x the expected value)
|
|
amd_tolerance = 1.0 # 100%
|
|
upper_bound = calculate_upper_bound(
|
|
expected, amd_tolerance, min_abs_tolerance_ms
|
|
)
|
|
if actual > upper_bound:
|
|
logger.warning(
|
|
f"[AMD PERF WARNING] Validation would fail for '{name}'.\n"
|
|
f" Actual: {actual:.4f}ms\n"
|
|
f" Expected: {expected:.4f}ms\n"
|
|
f" AMD Limit: {upper_bound:.4f}ms "
|
|
f"(rel_tol: {amd_tolerance:.1%}, abs_pad: {min_abs_tolerance_ms}ms)\n"
|
|
f" Original tolerance was: {tolerance:.1%}"
|
|
)
|
|
else:
|
|
upper_bound = calculate_upper_bound(
|
|
expected, tolerance, min_abs_tolerance_ms
|
|
)
|
|
assert actual <= upper_bound, (
|
|
f"Validation failed for '{name}'.\n"
|
|
f" Actual: {actual:.4f}ms\n"
|
|
f" Expected: {expected:.4f}ms\n"
|
|
f" Limit: {upper_bound:.4f}ms "
|
|
f"(rel_tol: {tolerance:.1%}, abs_pad: {min_abs_tolerance_ms}ms)"
|
|
)
|
|
|
|
def validate(
|
|
self, perf_record: RequestPerfRecord, *args, **kwargs
|
|
) -> PerformanceSummary:
|
|
"""Validate all performance metrics and return summary."""
|
|
summary = self.collect_metrics(perf_record)
|
|
if self.is_baseline_generation_mode:
|
|
return summary
|
|
|
|
self._validate_e2e(summary)
|
|
self._validate_denoise_agg(summary)
|
|
self._validate_denoise_steps(summary)
|
|
self._validate_stages(summary)
|
|
|
|
return summary
|
|
|
|
def collect_metrics(
|
|
self,
|
|
perf_record: RequestPerfRecord,
|
|
) -> PerformanceSummary:
|
|
return PerformanceSummary.from_req_perf_record(perf_record, self.step_fractions)
|
|
|
|
def _validate_e2e(self, summary: PerformanceSummary) -> None:
|
|
"""Validate end-to-end performance."""
|
|
assert summary.e2e_ms > 0, "E2E duration missing"
|
|
self._assert_le(
|
|
"E2E Latency",
|
|
summary.e2e_ms,
|
|
self.scenario.expected_e2e_ms,
|
|
self.tolerances.e2e,
|
|
)
|
|
|
|
def _validate_denoise_agg(self, summary: PerformanceSummary) -> None:
|
|
"""Validate aggregate denoising metrics."""
|
|
assert summary.avg_denoise_ms > 0, "Denoising step timings missing"
|
|
|
|
self._assert_le(
|
|
"Average Denoise Step",
|
|
summary.avg_denoise_ms,
|
|
self.scenario.expected_avg_denoise_ms,
|
|
self.tolerances.denoise_agg,
|
|
)
|
|
self._assert_le(
|
|
"Median Denoise Step",
|
|
summary.median_denoise_ms,
|
|
self.scenario.expected_median_denoise_ms,
|
|
self.tolerances.denoise_agg,
|
|
)
|
|
|
|
def _validate_denoise_steps(self, summary: PerformanceSummary) -> None:
|
|
"""Validate individual denoising steps."""
|
|
for idx, actual in summary.sampled_steps.items():
|
|
expected = self.scenario.denoise_step_ms.get(idx)
|
|
if expected is None:
|
|
continue
|
|
if idx == 0:
|
|
# server warmup is generic, so the first real step can still
|
|
# pay request-shape/path lazy init that is not a steady-state signal
|
|
self._assert_le(
|
|
f"Denoise Step {idx}",
|
|
actual,
|
|
expected,
|
|
FIRST_DENOISE_STEP_TOLERANCE,
|
|
min_abs_tolerance_ms=FIRST_DENOISE_STEP_MIN_ABS_TOLERANCE_MS,
|
|
)
|
|
continue
|
|
|
|
self._assert_le(
|
|
f"Denoise Step {idx}",
|
|
actual,
|
|
expected,
|
|
self.tolerances.denoise_step,
|
|
)
|
|
|
|
def _validate_stages(self, summary: PerformanceSummary) -> None:
|
|
"""Validate stage-level metrics."""
|
|
assert summary.stage_metrics, "Stage metrics missing"
|
|
|
|
for stage, expected in self.scenario.stages_ms.items():
|
|
if stage == "per_frame_generation" and self.is_video_gen:
|
|
continue
|
|
actual = summary.stage_metrics.get(stage)
|
|
assert actual is not None, f"Stage {stage} timing missing"
|
|
tolerance = (
|
|
self.tolerances.denoise_stage
|
|
if stage == "DenoisingStage"
|
|
else self.tolerances.non_denoise_stage
|
|
)
|
|
if stage.endswith("DecodingStage"):
|
|
tolerance = max(tolerance, 0.9)
|
|
min_abs_tolerance_ms = DECODING_STAGE_MIN_ABS_TOLERANCE_MS
|
|
else:
|
|
min_abs_tolerance_ms = 120.0
|
|
self._assert_le(
|
|
f"Stage '{stage}'",
|
|
actual,
|
|
expected,
|
|
tolerance,
|
|
min_abs_tolerance_ms=min_abs_tolerance_ms,
|
|
)
|
|
|
|
|
|
class VideoPerformanceValidator(PerformanceValidator):
|
|
"""Extended validator for video diffusion with frame-level metrics."""
|
|
|
|
is_video_gen = True
|
|
|
|
def _validate_denoise_steps(self, summary: PerformanceSummary) -> None:
|
|
"""Validate individual denoising steps."""
|
|
for idx, actual in summary.sampled_steps.items():
|
|
expected = self.scenario.denoise_step_ms.get(idx)
|
|
if expected is None:
|
|
continue
|
|
if idx == 0:
|
|
self._assert_le(
|
|
f"Denoise Step {idx}",
|
|
actual,
|
|
expected,
|
|
FIRST_DENOISE_STEP_TOLERANCE,
|
|
min_abs_tolerance_ms=FIRST_DENOISE_STEP_MIN_ABS_TOLERANCE_MS,
|
|
)
|
|
continue
|
|
|
|
# video per-step samples can catch one-off scheduling/offload jitter;
|
|
# avg and median denoise checks remain the steady-state guard
|
|
self._assert_le(
|
|
f"Denoise Step {idx}",
|
|
actual,
|
|
expected,
|
|
self.tolerances.denoise_step,
|
|
min_abs_tolerance_ms=VIDEO_DENOISE_STEP_MIN_ABS_TOLERANCE_MS,
|
|
)
|
|
|
|
def validate(
|
|
self,
|
|
perf_record: RequestPerfRecord,
|
|
num_frames: int | None = None,
|
|
) -> PerformanceSummary:
|
|
"""Validate video metrics including frame generation rates."""
|
|
summary = super().validate(perf_record)
|
|
|
|
if num_frames and summary.e2e_ms > 0:
|
|
summary.total_frames = num_frames
|
|
summary.avg_frame_time_ms = summary.e2e_ms / num_frames
|
|
summary.frames_per_second = 1000.0 / summary.avg_frame_time_ms
|
|
|
|
if not self.is_baseline_generation_mode:
|
|
self._validate_frame_rate(summary)
|
|
|
|
return summary
|
|
|
|
def _validate_frame_rate(self, summary: PerformanceSummary) -> None:
|
|
"""Validate frame generation performance."""
|
|
expected_frame_time = self.scenario.stages_ms.get("per_frame_generation")
|
|
if expected_frame_time and summary.avg_frame_time_ms:
|
|
self._assert_le(
|
|
"Average Frame Time",
|
|
summary.avg_frame_time_ms,
|
|
expected_frame_time,
|
|
self.tolerances.denoise_stage,
|
|
)
|
|
|
|
|
|
class MeshValidator(PerformanceValidator):
|
|
"""Validator for 3D mesh generation. Inherits perf validation from PerformanceValidator."""
|
|
|
|
pass
|
|
|
|
|
|
# Pinned to a ci-data commit (not main): invalidates the per-URL download cache
|
|
# whenever the reference is regenerated, and keeps the mesh GT reproducible.
|
|
# Bump this SHA when pushing a new hunyuan3d.glb to ci-data.
|
|
HUNYUAN3D_REFERENCE_URL = (
|
|
"https://raw.githubusercontent.com/sgl-project/ci-data/"
|
|
"395f6e49c37d22a57d79fbcd3653d43984099ae2"
|
|
"/diffusion-ci/consistency_gt/1-gpu/hunyuan3d_2_0/hunyuan3d.glb"
|
|
)
|
|
|
|
|
|
def _download_reference_mesh(url: str) -> Path:
|
|
"""Download a reference mesh from URL, caching in temp dir.
|
|
|
|
Validates that the cached/downloaded file actually *loads* as a non-empty
|
|
mesh — not just that a magic/length header looks right. raw.githubusercontent
|
|
can briefly serve a truncated or corrupt response for a just-pushed large
|
|
file, and a prior run may have cached those bytes on a persistent runner; a
|
|
size/magic check can't catch a blob whose byte count matches the declared
|
|
length but whose body is corrupt (exactly what poisoned this CI cache and
|
|
surfaced as a cryptic trimesh "incorrect header on GLB file" deep inside
|
|
validation). Loading via trimesh rejects any such cache (forcing a
|
|
re-download) and turns a bad fresh download into a clear error. The ``v2``
|
|
cache prefix also invalidates blobs written by the earlier, weaker checks.
|
|
"""
|
|
import hashlib
|
|
|
|
cache_name = f"ref_mesh_v2_{hashlib.md5(url.encode()).hexdigest()}.glb"
|
|
cache_path = Path(tempfile.gettempdir()) / cache_name
|
|
|
|
def _loads_as_mesh(path: Path) -> bool:
|
|
try:
|
|
import trimesh
|
|
|
|
mesh = trimesh.load(str(path), force="mesh")
|
|
return (
|
|
getattr(mesh, "vertices", None) is not None and len(mesh.vertices) > 0
|
|
)
|
|
except Exception:
|
|
return False
|
|
|
|
if cache_path.exists() and _loads_as_mesh(cache_path):
|
|
logger.info(f"Using cached reference mesh: {cache_path}")
|
|
return cache_path
|
|
|
|
logger.info(f"Downloading reference mesh from: {url}")
|
|
cache_path.write_bytes(_urlopen_with_retry(url, timeout=60))
|
|
if not _loads_as_mesh(cache_path):
|
|
size = cache_path.stat().st_size if cache_path.exists() else 0
|
|
cache_path.unlink(missing_ok=True)
|
|
raise RuntimeError(
|
|
f"Reference mesh from {url} did not load as a valid mesh "
|
|
f"({size} bytes). The CDN may not have propagated a recently-pushed "
|
|
f"file yet; retry shortly."
|
|
)
|
|
logger.info(f"Reference mesh cached at: {cache_path}")
|
|
return cache_path
|
|
|
|
|
|
def validate_mesh_correctness(
|
|
generated_mesh_path: str,
|
|
reference_url: str = HUNYUAN3D_REFERENCE_URL,
|
|
num_sample_points: int = 4096,
|
|
cd_threshold_ratio: float = 0.01,
|
|
random_seed: int = 42,
|
|
):
|
|
"""Validate mesh geometric similarity against a reference via Chamfer Distance.
|
|
|
|
Downloads the reference mesh from a URL (cached), samples point clouds from
|
|
both meshes, and asserts Chamfer Distance is within threshold.
|
|
"""
|
|
import numpy as np
|
|
|
|
try:
|
|
import trimesh
|
|
except ImportError:
|
|
pytest.fail("trimesh is required for mesh validation: pip install trimesh")
|
|
|
|
from scipy.spatial import cKDTree
|
|
|
|
# Load generated mesh
|
|
generated_mesh = trimesh.load(generated_mesh_path)
|
|
if isinstance(generated_mesh, trimesh.Scene):
|
|
generated_mesh = generated_mesh.dump(concatenate=True)
|
|
|
|
# Download and load reference mesh
|
|
ref_path = _download_reference_mesh(reference_url)
|
|
reference_mesh = trimesh.load(str(ref_path))
|
|
if isinstance(reference_mesh, trimesh.Scene):
|
|
reference_mesh = reference_mesh.dump(concatenate=True)
|
|
|
|
# Bounding box diagonal for threshold normalization
|
|
ref_bbox = reference_mesh.bounding_box.bounds
|
|
bbox_diagonal = float(np.linalg.norm(ref_bbox[1] - ref_bbox[0]))
|
|
cd_threshold = cd_threshold_ratio * bbox_diagonal
|
|
|
|
# Sample point clouds
|
|
np.random.seed(random_seed)
|
|
gen_points = np.array(
|
|
generated_mesh.sample(num_sample_points, return_index=True)[0]
|
|
)
|
|
ref_points = np.array(
|
|
reference_mesh.sample(num_sample_points, return_index=True)[0]
|
|
)
|
|
|
|
# Bidirectional Chamfer Distance
|
|
tree1 = cKDTree(gen_points)
|
|
tree2 = cKDTree(ref_points)
|
|
forward_cd = float(np.mean(tree2.query(gen_points)[0] ** 2))
|
|
backward_cd = float(np.mean(tree1.query(ref_points)[0] ** 2))
|
|
total_cd = forward_cd + backward_cd
|
|
|
|
assert total_cd <= cd_threshold, (
|
|
f"Chamfer Distance check failed: total_cd={total_cd:.6f}, "
|
|
f"threshold={cd_threshold:.6f} ({cd_threshold_ratio * 100:.2f}% of bbox diagonal {bbox_diagonal:.4f})"
|
|
)
|
|
|
|
|
|
# Registry of validators by name
|
|
VALIDATOR_REGISTRY = {
|
|
"default": PerformanceValidator,
|
|
"video": VideoPerformanceValidator,
|
|
"mesh": MeshValidator,
|
|
"action": PerformanceValidator,
|
|
}
|
|
|
|
|
|
def _extract_async_job_error_message(job: Any) -> str | None:
|
|
error = getattr(job, "error", None)
|
|
if error is None and isinstance(job, dict):
|
|
error = job.get("error")
|
|
|
|
if error is None:
|
|
return None
|
|
|
|
if isinstance(error, dict):
|
|
for key in ("message", "detail", "error"):
|
|
value = error.get(key)
|
|
if value:
|
|
return str(value)
|
|
return str(error)
|
|
|
|
message = getattr(error, "message", None)
|
|
if message:
|
|
return str(message)
|
|
|
|
return str(error)
|
|
|
|
|
|
def get_generate_fn(
|
|
model_path: str,
|
|
modality: str,
|
|
sampling_params: DiffusionSamplingParams,
|
|
) -> Callable[[str, Client], tuple[str, bytes]]:
|
|
"""Return appropriate generation function for the case."""
|
|
# Allow override via environment variable (useful for AMD where large resolutions cause slow VAE)
|
|
output_size = os.environ.get("SGLANG_TEST_OUTPUT_SIZE", sampling_params.output_size)
|
|
n = sampling_params.num_outputs_per_prompt
|
|
|
|
def _create_and_download_video(
|
|
client,
|
|
case_id,
|
|
*,
|
|
model: str,
|
|
size: str,
|
|
prompt: str | None = None,
|
|
seconds: int | None = None,
|
|
input_reference: Any | None = None,
|
|
extra_body: dict[Any] | None = None,
|
|
expected_frame_count: int | None = None,
|
|
) -> str:
|
|
"""
|
|
Create a video job via /v1/videos, poll until completion,
|
|
then download the binary content and validate it.
|
|
|
|
Returns request-id
|
|
"""
|
|
|
|
create_kwargs: dict[str, Any] = {
|
|
"model": model,
|
|
"size": size,
|
|
}
|
|
if prompt is not None:
|
|
create_kwargs["prompt"] = prompt
|
|
if seconds is not None:
|
|
create_kwargs["seconds"] = seconds
|
|
if input_reference is not None:
|
|
create_kwargs["input_reference"] = input_reference # triggers multipart
|
|
if extra_body is not None:
|
|
create_kwargs["extra_body"] = extra_body
|
|
|
|
job = client.videos.create(**create_kwargs) # type: ignore[attr-defined]
|
|
video_id = job.id
|
|
|
|
job_completed = False
|
|
is_baseline_generation_mode = os.environ.get("SGLANG_GEN_BASELINE", "0") == "1"
|
|
# Check if running on AMD GPU - use longer timeout
|
|
is_amd = current_platform.is_hip()
|
|
if is_baseline_generation_mode:
|
|
timeout = 3600.0
|
|
elif is_amd:
|
|
timeout = 2400.0 # 40 minutes for AMD
|
|
else:
|
|
timeout = 1200.0
|
|
deadline = time.time() + timeout
|
|
while True:
|
|
page = client.videos.list() # type: ignore[attr-defined]
|
|
item = next((v for v in page.data if v.id == video_id), None)
|
|
status = getattr(item, "status", None) if item is not None else None
|
|
|
|
if status == "completed":
|
|
job_completed = True
|
|
break
|
|
|
|
if status == "failed":
|
|
error_message = (
|
|
_extract_async_job_error_message(item) or "unknown error"
|
|
)
|
|
pytest.fail(
|
|
f"{case_id}: video job {video_id} failed early: {error_message}"
|
|
)
|
|
|
|
if status in {"cancelled", "deleted"}:
|
|
pytest.fail(
|
|
f"{case_id}: video job {video_id} ended with status={status}"
|
|
)
|
|
|
|
if time.time() > deadline:
|
|
break
|
|
|
|
time.sleep(1)
|
|
|
|
if not job_completed:
|
|
if is_baseline_generation_mode:
|
|
logger.warning(
|
|
f"{case_id}: video job {video_id} timed out during baseline generation. "
|
|
"Attempting to collect performance data anyway."
|
|
)
|
|
return (video_id, b"")
|
|
|
|
if is_amd:
|
|
logger.warning(
|
|
f"[AMD TIMEOUT WARNING] {case_id}: video job {video_id} did not complete "
|
|
f"within {timeout}s timeout. This may indicate performance issues on AMD."
|
|
)
|
|
pytest.skip(
|
|
f"{case_id}: video job timed out on AMD after {timeout}s - skipping"
|
|
)
|
|
|
|
pytest.fail(f"{case_id}: video job {video_id} did not complete in time")
|
|
|
|
# download video
|
|
resp = client.videos.download_content(video_id=video_id) # type: ignore[attr-defined]
|
|
content = resp.read()
|
|
validate_openai_video(content)
|
|
|
|
expected_filename = f"{video_id}.mp4"
|
|
tmp_path = expected_filename
|
|
with open(tmp_path, "wb") as f:
|
|
f.write(content)
|
|
|
|
# Validate output file
|
|
expected_width, expected_height = parse_dimensions(size)
|
|
if (
|
|
extra_body is not None
|
|
and extra_body.get("enable_upscaling")
|
|
and expected_width
|
|
and expected_height
|
|
):
|
|
scale = extra_body.get("upscaling_scale", 4)
|
|
expected_width *= scale
|
|
expected_height *= scale
|
|
validate_video_file(
|
|
tmp_path, expected_filename, expected_width, expected_height
|
|
)
|
|
|
|
if expected_frame_count is not None:
|
|
actual_count = get_video_frame_count(tmp_path)
|
|
assert actual_count == expected_frame_count, (
|
|
f"{case_id}: frame count mismatch after interpolation — "
|
|
f"expected {expected_frame_count}, got {actual_count}"
|
|
)
|
|
|
|
upload_file_to_slack(
|
|
case_id=case_id,
|
|
model=model_path,
|
|
prompt=sampling_params.prompt,
|
|
file_path=tmp_path,
|
|
origin_file_path=sampling_params.image_path,
|
|
)
|
|
os.remove(tmp_path)
|
|
|
|
return (video_id, content)
|
|
|
|
video_seconds = sampling_params.seconds or 4
|
|
|
|
def generate_image(case_id, client) -> tuple[str, bytes]:
|
|
"""T2I: Text to Image generation."""
|
|
if not sampling_params.prompt:
|
|
pytest.skip(f"{case_id}: no text prompt configured")
|
|
|
|
# Request parameters that affect output format
|
|
req_output_format = sampling_params.output_format
|
|
req_background = None # Not specified in current request
|
|
|
|
# Build extra_body for optional features
|
|
extra_body = dict(sampling_params.extras)
|
|
|
|
response = client.images.with_raw_response.generate(
|
|
model=model_path,
|
|
prompt=sampling_params.prompt,
|
|
n=n,
|
|
size=output_size,
|
|
response_format="b64_json",
|
|
output_format=req_output_format,
|
|
extra_body=extra_body if extra_body else None,
|
|
)
|
|
result = response.parse()
|
|
validate_image(result.data[0].b64_json)
|
|
|
|
rid = result.id
|
|
|
|
img_data = base64.b64decode(result.data[0].b64_json)
|
|
# Infer expected format from request parameters
|
|
expected_ext = get_expected_image_format(req_output_format, req_background)
|
|
expected_filename = f"{result.created}.{expected_ext}"
|
|
tmp_path = expected_filename
|
|
with open(tmp_path, "wb") as f:
|
|
f.write(img_data)
|
|
|
|
# Validate output file
|
|
expected_width, expected_height = parse_dimensions(output_size)
|
|
if (
|
|
sampling_params.extras.get("enable_upscaling")
|
|
and expected_width
|
|
and expected_height
|
|
):
|
|
expected_width *= sampling_params.extras.get("upscaling_scale", 4)
|
|
expected_height *= sampling_params.extras.get("upscaling_scale", 4)
|
|
validate_image_file(
|
|
tmp_path,
|
|
expected_filename,
|
|
expected_width,
|
|
expected_height,
|
|
output_format=req_output_format,
|
|
background=req_background,
|
|
)
|
|
|
|
upload_file_to_slack(
|
|
case_id=case_id,
|
|
model=model_path,
|
|
prompt=sampling_params.prompt,
|
|
file_path=tmp_path,
|
|
)
|
|
os.remove(tmp_path)
|
|
|
|
return (rid, img_data)
|
|
|
|
def generate_image_edit(case_id, client) -> tuple[str, bytes]:
|
|
"""TI2I: Text + Image -> Image edit."""
|
|
if not sampling_params.prompt or not sampling_params.image_path:
|
|
pytest.skip(f"{case_id}: no edit config")
|
|
|
|
image_paths = sampling_params.image_path
|
|
|
|
if not isinstance(image_paths, list):
|
|
image_paths = [image_paths]
|
|
|
|
new_image_paths = []
|
|
for image_path in image_paths:
|
|
if is_image_url(image_path):
|
|
new_image_paths.append(download_image_from_url(str(image_path)))
|
|
else:
|
|
local_path = Path(image_path)
|
|
new_image_paths.append(local_path)
|
|
if not local_path.exists():
|
|
pytest.skip(f"{case_id}: file missing: {image_path}")
|
|
|
|
image_paths = new_image_paths
|
|
|
|
# Request parameters that affect output format
|
|
req_output_format = (
|
|
sampling_params.output_format
|
|
) # Not specified in current request
|
|
req_background = None # Not specified in current request
|
|
|
|
# Build extra_body for optional features
|
|
extra_body = {"num_frames": sampling_params.num_frames}
|
|
extra_body.update(sampling_params.extras)
|
|
|
|
images = [open(image_path, "rb") for image_path in image_paths]
|
|
try:
|
|
response = client.images.with_raw_response.edit(
|
|
model=model_path,
|
|
image=images,
|
|
prompt=sampling_params.prompt,
|
|
n=n,
|
|
size=output_size,
|
|
response_format="b64_json",
|
|
output_format=req_output_format,
|
|
extra_body=extra_body,
|
|
)
|
|
finally:
|
|
for img in images:
|
|
img.close()
|
|
|
|
result = response.parse()
|
|
validate_image(result.data[0].b64_json)
|
|
|
|
img_data = base64.b64decode(result.data[0].b64_json)
|
|
rid = result.id
|
|
|
|
# Infer expected format from request parameters
|
|
expected_ext = get_expected_image_format(req_output_format, req_background)
|
|
expected_filename = f"{rid}.{expected_ext}"
|
|
tmp_path = expected_filename
|
|
with open(tmp_path, "wb") as f:
|
|
f.write(img_data)
|
|
|
|
# Validate output file
|
|
expected_width, expected_height = parse_dimensions(output_size)
|
|
validate_image_file(
|
|
tmp_path,
|
|
expected_filename,
|
|
expected_width,
|
|
expected_height,
|
|
output_format=req_output_format,
|
|
background=req_background,
|
|
)
|
|
|
|
upload_file_to_slack(
|
|
case_id=case_id,
|
|
model=model_path,
|
|
prompt=sampling_params.prompt,
|
|
file_path=tmp_path,
|
|
origin_file_path=sampling_params.image_path,
|
|
)
|
|
os.remove(tmp_path)
|
|
|
|
return (rid, img_data)
|
|
|
|
def generate_image_edit_url(case_id, client) -> tuple[str, bytes]:
|
|
"""TI2I: Text + Image ? Image edit using direct URL transfer (no pre-download)."""
|
|
if not sampling_params.prompt or not sampling_params.image_path:
|
|
pytest.skip(f"{case_id}: no edit config")
|
|
# Handle both single URL and list of URLs
|
|
image_urls = sampling_params.image_path
|
|
if not isinstance(image_urls, list):
|
|
image_urls = [image_urls]
|
|
|
|
# Validate all URLs
|
|
for url in image_urls:
|
|
if not is_image_url(url):
|
|
pytest.skip(
|
|
f"{case_id}: image_path must be a URL for URL direct test: {url}"
|
|
)
|
|
|
|
# Request parameters that affect output format
|
|
req_output_format = (
|
|
sampling_params.output_format
|
|
) # Not specified in current request
|
|
req_background = None # Not specified in current request
|
|
|
|
response = client.images.with_raw_response.edit(
|
|
model=model_path,
|
|
prompt=sampling_params.prompt,
|
|
image=[], # Only for OpenAI verification
|
|
n=n,
|
|
size=sampling_params.output_size,
|
|
response_format="b64_json",
|
|
output_format=req_output_format,
|
|
extra_body={"url": image_urls, "num_frames": sampling_params.num_frames},
|
|
)
|
|
|
|
result = response.parse()
|
|
rid = result.id
|
|
|
|
validate_image(result.data[0].b64_json)
|
|
|
|
# Save and upload result for verification
|
|
img_data = base64.b64decode(result.data[0].b64_json)
|
|
# Infer expected format from request parameters
|
|
expected_ext = get_expected_image_format(req_output_format, req_background)
|
|
expected_filename = f"{rid}.{expected_ext}"
|
|
tmp_path = expected_filename
|
|
with open(tmp_path, "wb") as f:
|
|
f.write(img_data)
|
|
|
|
# Validate output file
|
|
expected_width, expected_height = parse_dimensions(sampling_params.output_size)
|
|
validate_image_file(
|
|
tmp_path,
|
|
expected_filename,
|
|
expected_width,
|
|
expected_height,
|
|
output_format=req_output_format,
|
|
background=req_background,
|
|
)
|
|
|
|
upload_file_to_slack(
|
|
case_id=case_id,
|
|
model=model_path,
|
|
prompt=sampling_params.prompt,
|
|
file_path=tmp_path,
|
|
origin_file_path=str(sampling_params.image_path),
|
|
)
|
|
os.remove(tmp_path)
|
|
|
|
return (rid, img_data)
|
|
|
|
def generate_video(case_id, client) -> tuple[str, bytes]:
|
|
"""T2V: Text ? Video."""
|
|
if not sampling_params.prompt:
|
|
pytest.skip(f"{case_id}: no text prompt configured")
|
|
|
|
# Build extra_body for optional features
|
|
extra_body = dict(sampling_params.extras)
|
|
if sampling_params.num_frames:
|
|
extra_body["num_frames"] = sampling_params.num_frames
|
|
|
|
# Compute expected output frame count for validation
|
|
expected_frame_count = None
|
|
if (
|
|
sampling_params.extras.get("enable_frame_interpolation")
|
|
and sampling_params.num_frames
|
|
):
|
|
n = sampling_params.num_frames
|
|
exp = sampling_params.extras.get("frame_interpolation_exp", 1)
|
|
expected_frame_count = (n - 1) * (2**exp) + 1
|
|
|
|
return _create_and_download_video(
|
|
client,
|
|
case_id,
|
|
model=model_path,
|
|
prompt=sampling_params.prompt,
|
|
size=output_size,
|
|
seconds=video_seconds,
|
|
extra_body=extra_body if extra_body else None,
|
|
expected_frame_count=expected_frame_count,
|
|
)
|
|
|
|
def generate_image_to_video(case_id, client) -> tuple[str, bytes]:
|
|
"""I2V: Image -> Video (optional prompt)."""
|
|
if not sampling_params.image_path:
|
|
pytest.skip(f"{case_id}: no input image configured")
|
|
|
|
if is_image_url(sampling_params.image_path):
|
|
image_path = download_image_from_url(str(sampling_params.image_path))
|
|
else:
|
|
image_path = Path(sampling_params.image_path)
|
|
if not image_path.exists():
|
|
pytest.skip(f"{case_id}: file missing: {image_path}")
|
|
|
|
# Build extra_body for optional features
|
|
extra_body = dict(sampling_params.extras)
|
|
|
|
with image_path.open("rb") as fh:
|
|
return _create_and_download_video(
|
|
client,
|
|
case_id,
|
|
model=model_path,
|
|
prompt=sampling_params.prompt,
|
|
size=output_size,
|
|
seconds=video_seconds,
|
|
input_reference=fh,
|
|
extra_body=extra_body if extra_body else None,
|
|
)
|
|
|
|
def generate_text_url_image_to_video(case_id, client) -> tuple[str, bytes]:
|
|
if not sampling_params.prompt or not sampling_params.image_path:
|
|
pytest.skip(f"{case_id}: no edit config")
|
|
|
|
# Build extra_body for optional features
|
|
extra_body = {"reference_url": sampling_params.image_path}
|
|
extra_body.update(sampling_params.extras)
|
|
|
|
return _create_and_download_video(
|
|
client,
|
|
case_id,
|
|
model=model_path,
|
|
prompt=sampling_params.prompt,
|
|
size=sampling_params.output_size,
|
|
seconds=video_seconds,
|
|
extra_body={
|
|
"reference_url": sampling_params.image_path,
|
|
"fps": sampling_params.fps,
|
|
"num_frames": sampling_params.num_frames,
|
|
},
|
|
)
|
|
|
|
def generate_text_image_to_video(case_id, client) -> tuple[str, bytes]:
|
|
"""TI2V: Text + Image -> Video."""
|
|
if not sampling_params.prompt or not sampling_params.image_path:
|
|
pytest.skip(f"{case_id}: no edit config")
|
|
|
|
if is_image_url(sampling_params.image_path):
|
|
image_path = download_image_from_url(str(sampling_params.image_path))
|
|
else:
|
|
image_path = Path(sampling_params.image_path)
|
|
if not image_path.exists():
|
|
pytest.skip(f"{case_id}: file missing: {image_path}")
|
|
|
|
# Build extra_body for optional features
|
|
extra_body = dict(sampling_params.extras)
|
|
|
|
with image_path.open("rb") as fh:
|
|
return _create_and_download_video(
|
|
client,
|
|
case_id,
|
|
model=model_path,
|
|
prompt=sampling_params.prompt,
|
|
size=output_size,
|
|
seconds=video_seconds,
|
|
input_reference=fh,
|
|
extra_body={
|
|
"fps": sampling_params.fps,
|
|
"num_frames": sampling_params.num_frames,
|
|
**extra_body,
|
|
},
|
|
)
|
|
|
|
def generate_realtime_video(case_id, client) -> tuple[str, bytes]:
|
|
"""Realtime video generation folded back into mp4 for consistency checks."""
|
|
if not sampling_params.prompt:
|
|
pytest.skip(f"{case_id}: no realtime prompt configured")
|
|
if sampling_params.realtime_num_chunks is None:
|
|
pytest.skip(f"{case_id}: realtime_num_chunks is not configured")
|
|
if sampling_params.realtime_num_chunks <= 0:
|
|
pytest.fail(f"{case_id}: realtime_num_chunks must be positive")
|
|
|
|
first_frame = prepare_realtime_first_frame(sampling_params.image_path)
|
|
init_payload = build_realtime_init_payload(
|
|
model_path=model_path,
|
|
sampling_params=sampling_params,
|
|
output_size=output_size,
|
|
first_frame=first_frame,
|
|
)
|
|
realtime_output = asyncio.run(
|
|
collect_realtime_output(
|
|
ws_url=realtime_ws_url(client),
|
|
init_payload=init_payload,
|
|
events=list(sampling_params.realtime_events),
|
|
num_chunks=sampling_params.realtime_num_chunks,
|
|
require_chunk_stats=bool(sampling_params.realtime_perf_thresholds),
|
|
)
|
|
)
|
|
record_realtime_perf_stats(case_id, realtime_output.chunk_stats)
|
|
record_realtime_key_frames(case_id, realtime_output.frames)
|
|
fps = int(sampling_params.fps or 24)
|
|
video_bytes = encode_realtime_frames_to_mp4(realtime_output.frames, fps=fps)
|
|
validate_openai_video(video_bytes)
|
|
|
|
rid = f"{case_id}-realtime"
|
|
expected_filename = f"{rid}.mp4"
|
|
tmp_path = expected_filename
|
|
Path(tmp_path).write_bytes(video_bytes)
|
|
expected_width, expected_height = parse_dimensions(output_size)
|
|
validate_video_file(
|
|
tmp_path, expected_filename, expected_width, expected_height
|
|
)
|
|
upload_file_to_slack(
|
|
case_id=case_id,
|
|
model=model_path,
|
|
prompt=sampling_params.prompt,
|
|
file_path=tmp_path,
|
|
origin_file_path=sampling_params.image_path,
|
|
)
|
|
os.remove(tmp_path)
|
|
return (rid, video_bytes)
|
|
|
|
def generate_mesh(case_id, client) -> tuple[str, bytes]:
|
|
"""I2M: Image to Mesh generation using async /v1/meshes API."""
|
|
import requests as http_requests
|
|
|
|
if not sampling_params.image_path:
|
|
pytest.skip(f"{case_id}: no input image configured for mesh generation")
|
|
|
|
image_path = sampling_params.image_path
|
|
if isinstance(image_path, str) and is_image_url(image_path):
|
|
image_path = download_image_from_url(image_path)
|
|
elif isinstance(image_path, Path):
|
|
if not image_path.exists():
|
|
pytest.skip(f"{case_id}: image file missing: {image_path}")
|
|
else:
|
|
image_path = Path(str(image_path))
|
|
if not image_path.exists():
|
|
pytest.skip(f"{case_id}: image file missing: {image_path}")
|
|
|
|
base_url = str(client.base_url).rstrip("/")
|
|
if base_url.endswith("/v1"):
|
|
base_url = base_url[:-3]
|
|
|
|
create_url = f"{base_url}/v1/meshes"
|
|
|
|
with open(str(image_path), "rb") as img_file:
|
|
files = {"image": (Path(str(image_path)).name, img_file, "image/png")}
|
|
data = {
|
|
"prompt": "generate 3d mesh",
|
|
"model": model_path,
|
|
"seed": "0",
|
|
"guidance_scale": "5.0",
|
|
"num_inference_steps": "50",
|
|
}
|
|
|
|
logger.info(f"[Mesh Gen] Sending request to {create_url}")
|
|
|
|
try:
|
|
response = http_requests.post(
|
|
create_url, files=files, data=data, timeout=60
|
|
)
|
|
except Exception as e:
|
|
pytest.fail(f"{case_id}: mesh creation request failed: {e}")
|
|
|
|
if response.status_code != 200:
|
|
pytest.fail(f"{case_id}: mesh creation failed: {response.text}")
|
|
|
|
job = response.json()
|
|
mesh_id = job.get("id")
|
|
if not mesh_id:
|
|
pytest.fail(f"{case_id}: no mesh id in response: {job}")
|
|
|
|
poll_url = f"{base_url}/v1/meshes/{mesh_id}"
|
|
poll_interval = 5
|
|
max_wait = 1200
|
|
elapsed = 0
|
|
|
|
while elapsed < max_wait:
|
|
time.sleep(poll_interval)
|
|
elapsed += poll_interval
|
|
|
|
try:
|
|
poll_resp = http_requests.get(poll_url, timeout=30)
|
|
except Exception as e:
|
|
logger.warning(f"[Mesh Gen] Poll failed: {e}")
|
|
continue
|
|
|
|
if poll_resp.status_code != 200:
|
|
continue
|
|
|
|
status_data = poll_resp.json()
|
|
status = status_data.get("status", "")
|
|
|
|
if status == "completed":
|
|
content_url = f"{base_url}/v1/meshes/{mesh_id}/content"
|
|
try:
|
|
content_resp = http_requests.get(content_url, timeout=60)
|
|
except Exception as e:
|
|
pytest.fail(f"{case_id}: mesh download failed: {e}")
|
|
|
|
if content_resp.status_code != 200:
|
|
pytest.fail(f"{case_id}: mesh download failed: {content_resp.text}")
|
|
|
|
content = content_resp.content
|
|
# Shape-only Hunyuan3D meshes are returned as OBJ, painted meshes
|
|
# as GLB. Pick the extension from the content magic so trimesh.load
|
|
# (which dispatches on the file extension) parses it correctly,
|
|
# instead of raising "incorrect header on GLB file" when an OBJ
|
|
# body is saved under a .glb name.
|
|
ext = ".glb" if content[:4] == b"glTF" else ".obj"
|
|
temp_path = Path(tempfile.gettempdir()) / f"mesh_test_{mesh_id}{ext}"
|
|
temp_path.write_bytes(content)
|
|
MESH_OUTPUT_PATHS[case_id] = str(temp_path)
|
|
|
|
logger.info(f"[Mesh Gen] Mesh downloaded to {temp_path}")
|
|
return (mesh_id, b"")
|
|
elif status == "failed":
|
|
error = status_data.get("error", {})
|
|
pytest.fail(f"{case_id}: mesh generation failed: {error}")
|
|
|
|
pytest.fail(f"{case_id}: mesh generation timed out after {max_wait}s")
|
|
|
|
def generate_action(case_id, client) -> tuple[str, bytes]:
|
|
"""VLA action generation using /v1/actions/generations."""
|
|
import numpy as np
|
|
import requests as http_requests
|
|
|
|
extra = dict(sampling_params.extras)
|
|
action_horizon = int(extra.get("action_horizon", 50))
|
|
action_dim = int(extra.get("action_dim", 32))
|
|
state_dim = int(extra.get("state_dim", action_dim))
|
|
image_size = int(extra.get("image_size", 64))
|
|
camera_order = tuple(
|
|
extra.get(
|
|
"camera_order",
|
|
("base_0_rgb", "left_wrist_0_rgb", "right_wrist_0_rgb"),
|
|
)
|
|
)
|
|
|
|
def tensor_payload(array):
|
|
return {
|
|
"dtype": str(array.dtype),
|
|
"shape": list(array.shape),
|
|
"values": array.tolist(),
|
|
}
|
|
|
|
def image_payload(camera_index: int):
|
|
y = np.arange(image_size, dtype=np.uint16)[:, None]
|
|
x = np.arange(image_size, dtype=np.uint16)[None, :]
|
|
image = np.stack(
|
|
(
|
|
(x + camera_index * 17) % 256 + np.zeros_like(y),
|
|
(y + camera_index * 29) % 256 + np.zeros_like(x),
|
|
(x + y + camera_index * 41) % 256,
|
|
),
|
|
axis=-1,
|
|
)
|
|
return tensor_payload(image.astype(np.uint8))
|
|
|
|
rng = np.random.default_rng(int(extra.get("seed", 0)))
|
|
request_id = f"{case_id}-{int(time.time() * 1000)}"
|
|
payload = {
|
|
"request_id": request_id,
|
|
"model": model_path,
|
|
"input": {
|
|
"task": sampling_params.prompt or "pick up the blue block",
|
|
"observation": {
|
|
"images": {
|
|
camera: image_payload(index)
|
|
for index, camera in enumerate(camera_order)
|
|
},
|
|
"camera_order": list(camera_order),
|
|
"state": tensor_payload(
|
|
np.linspace(-0.5, 0.5, state_dim, dtype=np.float32)
|
|
),
|
|
"noise": tensor_payload(
|
|
rng.standard_normal((action_horizon, action_dim)).astype(
|
|
np.float32
|
|
)
|
|
),
|
|
},
|
|
},
|
|
"parameters": {
|
|
"action_horizon": action_horizon,
|
|
"action_dim": action_dim,
|
|
"num_inference_steps": int(extra.get("num_inference_steps", 2)),
|
|
},
|
|
"runtime": {
|
|
"return_timing": True,
|
|
"prefix_cache": bool(extra.get("enable_prefix_cache", False)),
|
|
"cuda_graph": bool(extra.get("enable_cuda_graph", True)),
|
|
"output_format": "list",
|
|
},
|
|
}
|
|
|
|
base_url = str(client.base_url).rstrip("/")
|
|
endpoint = (
|
|
f"{base_url}/actions/generations"
|
|
if base_url.endswith("/v1")
|
|
else f"{base_url}/v1/actions/generations"
|
|
)
|
|
response = http_requests.post(endpoint, json=payload, timeout=600)
|
|
if response.status_code != 200:
|
|
pytest.fail(f"{case_id}: action generation failed: {response.text}")
|
|
|
|
body = response.json()
|
|
action = body["data"][0]["action"]
|
|
if action["shape"] != [action_horizon, action_dim]:
|
|
pytest.fail(
|
|
f"{case_id}: action shape mismatch: {action['shape']} "
|
|
f"!= {[action_horizon, action_dim]}"
|
|
)
|
|
values = action["values"]
|
|
if not all(
|
|
isinstance(value, (int, float)) and np.isfinite(value)
|
|
for row in values
|
|
for value in row
|
|
):
|
|
pytest.fail(f"{case_id}: action response contains non-finite values")
|
|
return body["id"], response.content
|
|
|
|
if modality == "3d":
|
|
fn = generate_mesh
|
|
elif modality == "action":
|
|
fn = generate_action
|
|
elif modality == "video":
|
|
if sampling_params.realtime_num_chunks is not None:
|
|
fn = generate_realtime_video
|
|
elif sampling_params.image_path and sampling_params.prompt:
|
|
if getattr(sampling_params, "direct_url_test", False):
|
|
fn = generate_text_url_image_to_video
|
|
else:
|
|
fn = generate_text_image_to_video
|
|
elif sampling_params.image_path:
|
|
fn = generate_image_to_video
|
|
else:
|
|
fn = generate_video
|
|
elif sampling_params.prompt and sampling_params.image_path:
|
|
if getattr(sampling_params, "direct_url_test", False):
|
|
fn = generate_image_edit_url
|
|
else:
|
|
fn = generate_image_edit
|
|
else:
|
|
fn = generate_image
|
|
|
|
return fn
|