2026-01-12 11:00:42 +08:00
|
|
|
# SPDX-License-Identifier: Apache-2.0
|
2026-08-25 09:10:17 +08:00
|
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
2026-01-12 11:00:42 +08:00
|
|
|
|
2026-07-29 23:53:03 +08:00
|
|
|
"""DiT tensor-parallel parity and perf checks for diffusion models.
|
2026-02-10 17:36:10 +08:00
|
|
|
|
2026-07-29 23:53:03 +08:00
|
|
|
Compares TP=2 against TP=1 on output correctness, latency, and peak memory.
|
|
|
|
|
Uses ``Tongyi-MAI/Z-Image-Turbo`` as the parity model.
|
2026-02-10 17:36:10 +08:00
|
|
|
"""
|
|
|
|
|
|
2026-07-29 23:53:03 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-08-28 14:34:11 +08:00
|
|
|
import gc
|
2026-01-12 11:00:42 +08:00
|
|
|
import os
|
|
|
|
|
import time
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import numpy as np
|
2026-03-14 17:07:13 +08:00
|
|
|
import pytest
|
2026-01-12 11:00:42 +08:00
|
|
|
import torch
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
2026-04-20 14:23:42 +08:00
|
|
|
from tests.helpers.mark import hardware_test
|
2026-08-25 09:10:17 +08:00
|
|
|
from tests.helpers.monitor import DeviceMemoryMonitor
|
2026-04-20 14:23:42 +08:00
|
|
|
from tests.helpers.runtime import OmniRunner
|
2026-02-09 16:49:45 +08:00
|
|
|
from vllm_omni.diffusion.data import DiffusionParallelConfig
|
2026-01-28 16:07:42 +08:00
|
|
|
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
2026-02-09 16:49:45 +08:00
|
|
|
from vllm_omni.outputs import OmniRequestOutput
|
|
|
|
|
from vllm_omni.platforms import current_omni_platform
|
2026-01-28 16:07:42 +08:00
|
|
|
|
2026-01-21 00:25:25 +08:00
|
|
|
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
2026-01-12 11:00:42 +08:00
|
|
|
|
|
|
|
|
PROMPT = "a photo of a cat sitting on a laptop keyboard"
|
2026-07-29 23:53:03 +08:00
|
|
|
DEFAULT_MODEL = "Tongyi-MAI/Z-Image-Turbo"
|
2026-01-12 11:00:42 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _pil_to_float_rgb_tensor(img: Image.Image) -> torch.Tensor:
|
|
|
|
|
arr = np.asarray(img.convert("RGB"), dtype=np.float32) / 255.0
|
|
|
|
|
return torch.from_numpy(arr)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _diff_metrics(a: Image.Image, b: Image.Image) -> tuple[float, float]:
|
|
|
|
|
ta = _pil_to_float_rgb_tensor(a)
|
|
|
|
|
tb = _pil_to_float_rgb_tensor(b)
|
|
|
|
|
assert ta.shape == tb.shape, f"Image shapes differ: {ta.shape} vs {tb.shape}"
|
|
|
|
|
abs_diff = torch.abs(ta - tb)
|
2026-03-11 20:30:54 +08:00
|
|
|
p99_abs_diff = torch.quantile(abs_diff.flatten(), 0.99).item()
|
|
|
|
|
return abs_diff.mean().item(), p99_abs_diff
|
2026-01-12 11:00:42 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_single_image(outputs) -> Image.Image:
|
|
|
|
|
first_output = outputs[0]
|
|
|
|
|
assert first_output.final_output_type == "image"
|
2026-08-12 22:58:23 +08:00
|
|
|
if not isinstance(first_output, OmniRequestOutput) or not first_output:
|
2026-01-12 11:00:42 +08:00
|
|
|
raise ValueError("No request_output found in OmniRequestOutput")
|
|
|
|
|
|
2026-08-12 22:58:23 +08:00
|
|
|
req_out = first_output
|
2026-01-12 11:00:42 +08:00
|
|
|
if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"):
|
|
|
|
|
raise ValueError("Invalid request_output structure or missing 'images' key")
|
|
|
|
|
|
|
|
|
|
images = req_out.images
|
|
|
|
|
if images is None or len(images) != 1:
|
|
|
|
|
raise ValueError(f"Expected 1 image, got {0 if images is None else len(images)}")
|
|
|
|
|
return images[0]
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 23:53:03 +08:00
|
|
|
def _run_generate(
|
2026-02-10 17:36:10 +08:00
|
|
|
*,
|
|
|
|
|
tp_size: int,
|
|
|
|
|
height: int,
|
|
|
|
|
width: int,
|
|
|
|
|
num_inference_steps: int,
|
|
|
|
|
seed: int,
|
|
|
|
|
enforce_eager: bool,
|
|
|
|
|
num_requests: int = 4,
|
2026-01-19 14:20:31 +08:00
|
|
|
) -> tuple[Image.Image, float, float]:
|
2026-02-10 17:36:10 +08:00
|
|
|
if num_requests < 2:
|
|
|
|
|
raise ValueError("num_requests must be >= 2 (1 warmup + >=1 timed)")
|
|
|
|
|
|
2026-08-28 14:34:11 +08:00
|
|
|
gc.collect()
|
2026-03-10 23:19:36 -05:00
|
|
|
current_omni_platform.empty_cache()
|
|
|
|
|
device_index = current_omni_platform.current_device()
|
2026-08-28 14:34:11 +08:00
|
|
|
with current_omni_platform.device(device_index):
|
|
|
|
|
free_bytes, total_bytes = current_omni_platform.mem_get_info()
|
|
|
|
|
initial_used_mb = (total_bytes - free_bytes) / (1024**2)
|
|
|
|
|
|
2026-03-10 23:19:36 -05:00
|
|
|
monitor = DeviceMemoryMonitor(device_index=device_index, interval=0.02)
|
2026-01-19 14:20:31 +08:00
|
|
|
monitor.start()
|
2026-01-12 11:00:42 +08:00
|
|
|
try:
|
2026-04-13 14:47:55 +08:00
|
|
|
with OmniRunner(
|
2026-07-29 23:53:03 +08:00
|
|
|
DEFAULT_MODEL,
|
|
|
|
|
parallel_config=DiffusionParallelConfig(tensor_parallel_size=tp_size),
|
2026-04-13 14:47:55 +08:00
|
|
|
enforce_eager=enforce_eager,
|
|
|
|
|
) as runner:
|
|
|
|
|
gen = runner.omni.generate(
|
|
|
|
|
[PROMPT] * num_requests,
|
|
|
|
|
OmniDiffusionSamplingParams(
|
|
|
|
|
height=height,
|
|
|
|
|
width=width,
|
|
|
|
|
num_inference_steps=num_inference_steps,
|
|
|
|
|
guidance_scale=0.0,
|
|
|
|
|
seed=seed,
|
|
|
|
|
num_outputs_per_prompt=1,
|
|
|
|
|
),
|
|
|
|
|
py_generator=True,
|
|
|
|
|
)
|
2026-01-12 11:00:42 +08:00
|
|
|
|
2026-04-13 14:47:55 +08:00
|
|
|
warmup_output = next(gen)
|
2026-02-10 17:36:10 +08:00
|
|
|
|
2026-04-13 14:47:55 +08:00
|
|
|
t_prev = time.perf_counter()
|
|
|
|
|
per_request_times_s: list[float] = []
|
|
|
|
|
last_output = warmup_output
|
|
|
|
|
for _ in range(num_requests - 1):
|
|
|
|
|
last_output = next(gen)
|
|
|
|
|
t_now = time.perf_counter()
|
|
|
|
|
per_request_times_s.append(t_now - t_prev)
|
|
|
|
|
t_prev = t_now
|
2026-01-12 11:00:42 +08:00
|
|
|
|
2026-04-13 14:47:55 +08:00
|
|
|
for _ in gen:
|
|
|
|
|
pass
|
2026-01-12 11:00:42 +08:00
|
|
|
|
2026-04-13 14:47:55 +08:00
|
|
|
median_time_s = float(np.median(per_request_times_s))
|
2026-08-28 14:34:11 +08:00
|
|
|
# DeviceMemoryMonitor samples absolute device usage. Normalize by
|
|
|
|
|
# this run's starting usage so compiler and allocator caches retained
|
|
|
|
|
# by the preceding TP=1 run are not charged to the TP=2 run.
|
|
|
|
|
peak_memory_mb = max(0.0, monitor.peak_used_mb - initial_used_mb)
|
2026-04-13 14:47:55 +08:00
|
|
|
return _extract_single_image([last_output]), median_time_s, peak_memory_mb
|
2026-01-12 11:00:42 +08:00
|
|
|
finally:
|
2026-01-21 15:12:45 +08:00
|
|
|
monitor.stop()
|
2026-01-12 11:00:42 +08:00
|
|
|
|
|
|
|
|
|
2026-03-14 17:07:13 +08:00
|
|
|
@pytest.mark.advanced_model
|
|
|
|
|
@pytest.mark.diffusion
|
|
|
|
|
@pytest.mark.parallel
|
|
|
|
|
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 4, "rocm": 2})
|
2026-07-29 23:53:03 +08:00
|
|
|
def test_tensor_parallel_tp2(tmp_path: Path):
|
2026-04-11 19:54:22 +08:00
|
|
|
if current_omni_platform.is_npu():
|
2026-07-29 23:53:03 +08:00
|
|
|
pytest.skip("Tensor parallel parity test is only supported on CUDA and ROCm for now.")
|
2026-03-14 17:07:13 +08:00
|
|
|
if not current_omni_platform.is_available() or current_omni_platform.device_count() < 2:
|
2026-07-29 23:53:03 +08:00
|
|
|
pytest.skip("Tensor parallel TP=2 requires >= 2 devices.")
|
2026-01-12 11:00:42 +08:00
|
|
|
|
2026-07-29 23:53:03 +08:00
|
|
|
model_name = DEFAULT_MODEL
|
2026-03-14 17:07:13 +08:00
|
|
|
enforce_eager = False
|
|
|
|
|
height = 512
|
|
|
|
|
width = 512
|
|
|
|
|
num_inference_steps = 2
|
|
|
|
|
seed = 42
|
2026-01-12 11:00:42 +08:00
|
|
|
|
2026-07-29 23:53:03 +08:00
|
|
|
tp1_img, tp1_time_s, tp1_peak_mem = _run_generate(
|
2026-03-14 17:07:13 +08:00
|
|
|
tp_size=1,
|
|
|
|
|
height=height,
|
|
|
|
|
width=width,
|
|
|
|
|
num_inference_steps=num_inference_steps,
|
|
|
|
|
seed=seed,
|
|
|
|
|
enforce_eager=enforce_eager,
|
|
|
|
|
)
|
2026-07-29 23:53:03 +08:00
|
|
|
tp2_img, tp2_time_s, tp2_peak_mem = _run_generate(
|
2026-03-14 17:07:13 +08:00
|
|
|
tp_size=2,
|
|
|
|
|
height=height,
|
|
|
|
|
width=width,
|
|
|
|
|
num_inference_steps=num_inference_steps,
|
|
|
|
|
seed=seed,
|
|
|
|
|
enforce_eager=enforce_eager,
|
|
|
|
|
)
|
2026-01-12 11:00:42 +08:00
|
|
|
|
2026-07-29 23:53:03 +08:00
|
|
|
tp1_path = tmp_path / "tp1.png"
|
|
|
|
|
tp2_path = tmp_path / "tp2.png"
|
2026-03-14 17:07:13 +08:00
|
|
|
tp1_img.save(tp1_path)
|
|
|
|
|
tp2_img.save(tp2_path)
|
2026-01-12 11:00:42 +08:00
|
|
|
|
2026-03-14 17:07:13 +08:00
|
|
|
assert tp1_img.width == width and tp1_img.height == height
|
|
|
|
|
assert tp2_img.width == width and tp2_img.height == height
|
2026-01-12 11:00:42 +08:00
|
|
|
|
2026-03-14 17:07:13 +08:00
|
|
|
mean_abs_diff, p99_abs_diff = _diff_metrics(tp1_img, tp2_img)
|
|
|
|
|
mean_threshold = 3e-2
|
|
|
|
|
p99_threshold = 2.5e-1
|
|
|
|
|
print(
|
2026-07-29 23:53:03 +08:00
|
|
|
f"{model_name} TP image diff (TP=1 vs TP=2): "
|
2026-03-14 17:07:13 +08:00
|
|
|
f"mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e}; "
|
|
|
|
|
f"thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e}; "
|
|
|
|
|
f"tp1_img={tp1_path}, tp2_img={tp2_path}"
|
|
|
|
|
)
|
|
|
|
|
assert mean_abs_diff <= mean_threshold and p99_abs_diff <= p99_threshold, (
|
|
|
|
|
f"Image diff exceeded threshold: mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e} "
|
|
|
|
|
f"(thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e})"
|
|
|
|
|
)
|
2026-01-12 11:00:42 +08:00
|
|
|
|
2026-07-29 23:53:03 +08:00
|
|
|
print(f"TP perf (lower is better): tp1_time_s={tp1_time_s:.6f}, tp2_time_s={tp2_time_s:.6f}")
|
2026-04-11 19:54:22 +08:00
|
|
|
if not current_omni_platform.is_rocm():
|
|
|
|
|
assert tp2_time_s < tp1_time_s, f"Expected TP=2 to be faster than TP=1 (tp1={tp1_time_s}, tp2={tp2_time_s})"
|
2026-01-19 14:20:31 +08:00
|
|
|
|
2026-07-29 23:53:03 +08:00
|
|
|
print(f"TP peak memory (MB): tp1_peak_mem={tp1_peak_mem:.2f}, tp2_peak_mem={tp2_peak_mem:.2f}")
|
2026-03-14 17:07:13 +08:00
|
|
|
assert tp2_peak_mem < tp1_peak_mem, (
|
|
|
|
|
f"Expected TP=2 to use less peak memory than TP=1 (tp1={tp1_peak_mem}, tp2={tp2_peak_mem})"
|
|
|
|
|
)
|