Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:38:16 +08:00

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