[diffusion] multi-platform: support diffusion on amd and fix encoder loading on MI325 (#13760)

Co-authored-by: Sabre Shao <sabre.shao@amd.com>
Co-authored-by: Yusheng (Ethan) Su <yushengsu.thu@gmail.com>
Co-authored-by: Hubert Lu <Hubert.Lu@amd.com>
Co-authored-by: xsun <sunxiao04@gmail.com>
This commit is contained in:
Yuzhen Zhou
2025-12-19 02:38:46 -05:00
committed by GitHub
parent f2d64e6782
commit 4bf06635fc
34 changed files with 823 additions and 72 deletions

View File

@@ -5,7 +5,7 @@ Usage:
python3 run_suite.py --suite <suite_name> --partition-id <id> --total-partitions <num>
Example:
python3 run_suite.py --suite 1-gpu --partition-id 0 --total-partitions 2
python3 run_suite.py --suite 1-gpu --partition-id 0 --total-partitions 4
"""
import argparse
@@ -60,16 +60,51 @@ def parse_args():
default="server",
help="Base directory for tests relative to this script's parent",
)
parser.add_argument(
"-k",
"--filter",
type=str,
default=None,
help="Pytest filter expression (passed to pytest -k)",
)
return parser.parse_args()
def run_pytest(files):
def collect_test_items(files, filter_expr=None):
"""Collect test item node IDs from the given files using pytest --collect-only."""
cmd = [sys.executable, "-m", "pytest", "--collect-only", "-q"]
if filter_expr:
cmd.extend(["-k", filter_expr])
cmd.extend(files)
result = subprocess.run(cmd, capture_output=True, text=True)
# Parse the output to extract test node IDs
# pytest -q outputs lines like: test_file.py::TestClass::test_method[param]
test_items = []
for line in result.stdout.strip().split("\n"):
line = line.strip()
# Skip empty lines and summary lines
if line and "::" in line and not line.startswith(("=", "-", " ")):
# Handle lines that might have extra info after the test ID
test_id = line.split()[0] if " " in line else line
if "::" in test_id:
test_items.append(test_id)
return test_items
def run_pytest(files, filter_expr=None):
if not files:
print("No files to run.")
return 0
base_cmd = [sys.executable, "-m", "pytest", "-s", "-v", "--log-cli-level=INFO"]
# Add pytest -k filter if provided
if filter_expr:
base_cmd.extend(["-k", filter_expr])
max_retries = 4
# retry if the perf assertion failed, for {max_retries} times
for i in range(max_retries + 1):
@@ -107,6 +142,15 @@ def run_pytest(files):
if returncode == 0:
return 0
# Exit code 5 means no tests were collected/selected - treat as success
# when using filters, since some partitions may have all tests filtered out
if returncode == 5:
logger.info(
"No tests collected (exit code 5). This is expected when filters "
"deselect all tests in a partition. Treating as success."
)
return 0
# check if the failure is due to an assertion in test_server_utils.py
full_output = "".join(output_lines)
is_perf_assertion = (
@@ -150,26 +194,34 @@ def main():
print(f"No valid test files found for suite '{args.suite}'.")
sys.exit(0)
# 3. partitioning
my_files = [
f
for i, f in enumerate(suite_files_abs)
# 3. collect all test items and partition by items (not files)
all_test_items = collect_test_items(suite_files_abs, filter_expr=args.filter)
if not all_test_items:
print(f"No test items found for suite '{args.suite}'.")
sys.exit(0)
# Partition by test items
my_items = [
item
for i, item in enumerate(all_test_items)
if i % args.total_partitions == args.partition_id
]
print(
f"Suite: {args.suite} | Partition: {args.partition_id}/{args.total_partitions}"
)
print(f"Selected {len(my_files)} files:")
for f in my_files:
print(f"Selected {len(suite_files_abs)} files:")
for f in suite_files_abs:
print(f" - {os.path.basename(f)}")
print(f"Running {len(my_items)} items in this shard: {', '.join(my_items)}")
if not my_files:
print("No files assigned to this partition. Exiting success.")
if not my_items:
print("No items assigned to this partition. Exiting success.")
sys.exit(0)
# 4. execute
exit_code = run_pytest(my_files)
# 4. execute with the specific test items
exit_code = run_pytest(my_items)
sys.exit(exit_code)

View File

@@ -16,6 +16,7 @@ import pytest
import requests
from openai import OpenAI
from sglang.multimodal_gen.runtime.utils.common import is_hip
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from sglang.multimodal_gen.runtime.utils.perf_logger import RequestPerfRecord
from sglang.multimodal_gen.test.server.conftest import _GLOBAL_PERF_RESULTS
@@ -47,9 +48,18 @@ logger = init_logger(__name__)
@pytest.fixture
def diffusion_server(case: DiffusionTestCase) -> ServerContext:
"""Start a diffusion server for a single case and tear it down afterwards."""
server_args = case.server_args
# Skip ring attention tests on AMD/ROCm - Ring Attention requires Flash Attention
# which is not available on AMD. Use Ulysses parallelism instead.
if is_hip() and server_args.ring_degree is not None and server_args.ring_degree > 1:
pytest.skip(
f"Skipping {case.id}: Ring Attention (ring_degree={server_args.ring_degree}) "
"requires Flash Attention which is not available on AMD/ROCm"
)
default_port = get_dynamic_server_port()
port = int(os.environ.get("SGLANG_TEST_SERVER_PORT", default_port))
server_args = case.server_args
sampling_params = case.sampling_params
extra_args = os.environ.get("SGLANG_TEST_SERVE_ARGS", "")
extra_args += f" --num-gpus {server_args.num_gpus}"
@@ -78,7 +88,10 @@ def diffusion_server(case: DiffusionTestCase) -> ServerContext:
try:
# Reconstruct output size for OpenAI API
output_size = sampling_params.output_size
# Allow override via environment variable (useful for AMD where large resolutions can cause GPU hang)
output_size = os.environ.get(
"SGLANG_TEST_OUTPUT_SIZE", sampling_params.output_size
)
warmup = WarmupRunner(
port=ctx.port,
model=server_args.model_path,

View File

@@ -21,7 +21,7 @@ import pytest
from openai import Client, OpenAI
from sglang.multimodal_gen.benchmarks.compare_perf import calculate_upper_bound
from sglang.multimodal_gen.runtime.utils.common import kill_process_tree
from sglang.multimodal_gen.runtime.utils.common import is_hip, kill_process_tree
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from sglang.multimodal_gen.runtime.utils.perf_logger import RequestPerfRecord
from sglang.multimodal_gen.test.server.testcase_configs import (
@@ -97,6 +97,97 @@ class ServerContext:
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 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()
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."""
@@ -113,8 +204,72 @@ class ServerManager:
self.wait_deadline = wait_deadline
self.extra_args = extra_args
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 is_hip():
self._wait_for_rocm_gpu_memory_clear()
log_dir, perf_log_path = prepare_perf_log()
safe_model_name = self.model.replace("/", "_")
@@ -336,15 +491,38 @@ class PerformanceValidator:
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.
"""
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)"
)
# Check if running on AMD GPU
is_amd = 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
@@ -481,6 +659,8 @@ def get_generate_fn(
sampling_params: DiffusionSamplingParams,
) -> Callable[[str, Client], str]:
"""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)
def _create_and_download_video(
client,
@@ -513,7 +693,14 @@ def get_generate_fn(
job_completed = False
is_baseline_generation_mode = os.environ.get("SGLANG_GEN_BASELINE", "0") == "1"
timeout = 3600.0 if is_baseline_generation_mode else 1200.0
# Check if running on AMD GPU - use longer timeout
is_amd = 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]
@@ -531,12 +718,21 @@ def get_generate_fn(
if not job_completed:
if is_baseline_generation_mode:
logger.warning(
f"{id}: video job {video_id} timed out during baseline generation. "
f"{case_id}: video job {video_id} timed out during baseline generation. "
"Attempting to collect performance data anyway."
)
return video_id
pytest.fail(f"{id}: video job {video_id} did not complete in time")
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]
@@ -568,7 +764,7 @@ def get_generate_fn(
model=model_path,
prompt=sampling_params.prompt,
n=1,
size=sampling_params.output_size,
size=output_size,
response_format="b64_json",
)
result = response.parse()
@@ -616,7 +812,7 @@ def get_generate_fn(
image=images,
prompt=sampling_params.prompt,
n=1,
size=sampling_params.output_size,
size=output_size,
response_format="b64_json",
)
finally:
@@ -653,7 +849,7 @@ def get_generate_fn(
case_id,
model=model_path,
prompt=sampling_params.prompt,
size=sampling_params.output_size,
size=output_size,
seconds=video_seconds,
)
@@ -675,7 +871,7 @@ def get_generate_fn(
case_id,
model=model_path,
prompt=sampling_params.prompt,
size=sampling_params.output_size,
size=output_size,
seconds=video_seconds,
input_reference=fh,
)
@@ -698,7 +894,7 @@ def get_generate_fn(
case_id,
model=model_path,
prompt=sampling_params.prompt,
size=sampling_params.output_size,
size=output_size,
seconds=video_seconds,
input_reference=fh,
)