[diffusion] CI: fix generate mode and add cli test (#16174)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
@@ -91,7 +91,7 @@ def generate_cmd(args: argparse.Namespace):
|
||||
server_args = ServerArgs.from_cli_args(args)
|
||||
sampling_params_kwargs = SamplingParams.get_cli_args(args)
|
||||
generator = DiffGenerator.from_pretrained(
|
||||
model_path=server_args.model_path, server_args=server_args
|
||||
model_path=server_args.model_path, server_args=server_args, local_mode=True
|
||||
)
|
||||
|
||||
results = generator.generate(sampling_params_kwargs=sampling_params_kwargs)
|
||||
|
||||
@@ -77,6 +77,7 @@ class DiffGenerator:
|
||||
@classmethod
|
||||
def from_pretrained(
|
||||
cls,
|
||||
local_mode: bool = True,
|
||||
**kwargs,
|
||||
) -> "DiffGenerator":
|
||||
"""
|
||||
@@ -100,10 +101,12 @@ class DiffGenerator:
|
||||
else:
|
||||
server_args = ServerArgs.from_kwargs(**kwargs)
|
||||
|
||||
return cls.from_server_args(server_args)
|
||||
return cls.from_server_args(server_args, local_mode=local_mode)
|
||||
|
||||
@classmethod
|
||||
def from_server_args(cls, server_args: ServerArgs) -> "DiffGenerator":
|
||||
def from_server_args(
|
||||
cls, server_args: ServerArgs, local_mode: bool = True
|
||||
) -> "DiffGenerator":
|
||||
"""
|
||||
Create a DiffGenerator with the specified arguments.
|
||||
|
||||
@@ -116,9 +119,8 @@ class DiffGenerator:
|
||||
instance = cls(
|
||||
server_args=server_args,
|
||||
)
|
||||
is_local_mode = server_args.is_local_mode
|
||||
logger.info(f"Local mode: {is_local_mode}")
|
||||
if is_local_mode:
|
||||
logger.info(f"Local mode: {local_mode}")
|
||||
if local_mode:
|
||||
instance.local_scheduler_process = instance._start_local_server_if_needed()
|
||||
else:
|
||||
# In remote mode, we just need to connect and check.
|
||||
|
||||
@@ -227,9 +227,9 @@ class PerformanceLogger:
|
||||
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||
with open(abs_path, "w", encoding="utf-8") as f:
|
||||
json.dump(report, f, indent=2)
|
||||
logger.info(f"[Performance] Metrics dumped to: {abs_path}")
|
||||
logger.info(f"Metrics dumped to: {abs_path}")
|
||||
except IOError as e:
|
||||
logger.error(f"[Performance] Failed to dump metrics to {abs_path}: {e}")
|
||||
logger.error(f"Failed to dump metrics to {abs_path}: {e}")
|
||||
|
||||
@classmethod
|
||||
def log_request_summary(
|
||||
|
||||
@@ -3,105 +3,138 @@
|
||||
"""
|
||||
Common generate cli test, one test for image and video each
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from sglang.multimodal_gen.test.test_utils import (
|
||||
TestCLIBase,
|
||||
check_image_size,
|
||||
is_mp4,
|
||||
run_command,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import DataType
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
from sglang.multimodal_gen.test.test_utils import check_image_size
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class TestGenerate(TestCLIBase):
|
||||
model_path = "black-forest-labs/FLUX.1-dev"
|
||||
launch_file_name = "launch_flux.json"
|
||||
output_name = "FLUX.1-dev, single gpu"
|
||||
ext = "jpg"
|
||||
@dataclasses.dataclass
|
||||
class TestResult:
|
||||
name: str
|
||||
key: str
|
||||
duration: Optional[float]
|
||||
succeed: bool
|
||||
|
||||
def test_generate_with_config(self):
|
||||
test_dir = Path(__file__).parent
|
||||
config_path = (
|
||||
(test_dir / ".." / "test_files" / self.launch_file_name)
|
||||
.resolve()
|
||||
.as_posix()
|
||||
@property
|
||||
def duration_str(self):
|
||||
return f"{self.duration:.4f}" if self.duration else "NA"
|
||||
|
||||
|
||||
def run_command(command) -> Optional[float]:
|
||||
"""Runs a command and returns the execution time and status."""
|
||||
print(f"Running command: {shlex.join(command)}")
|
||||
|
||||
duration = None
|
||||
with subprocess.Popen(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
) as process:
|
||||
for line in process.stdout:
|
||||
sys.stdout.write(line)
|
||||
if "Pixel data generated" in line:
|
||||
words = line.split(" ")
|
||||
duration = float(words[-2])
|
||||
|
||||
if process.returncode == 0:
|
||||
return duration
|
||||
else:
|
||||
print(f"Command failed with exit code {process.returncode}")
|
||||
return None
|
||||
|
||||
|
||||
class CLIBase(unittest.TestCase):
|
||||
model_path: str = None
|
||||
extra_args = []
|
||||
data_type: DataType = None
|
||||
# tested on h100
|
||||
|
||||
width: int = 720
|
||||
height: int = 720
|
||||
output_path: str = "test_outputs"
|
||||
|
||||
def get_base_command(self):
|
||||
return [
|
||||
"sglang",
|
||||
"generate",
|
||||
"--prompt",
|
||||
"A curious raccoon",
|
||||
"--save-output",
|
||||
"--log-level=debug",
|
||||
f"--width={self.width}",
|
||||
f"--height={self.height}",
|
||||
f"--output-path={self.output_path}",
|
||||
]
|
||||
|
||||
results = []
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.results = []
|
||||
|
||||
def _run_command(self, name: str, model_path: str, test_key: str = "", args=[]):
|
||||
command = (
|
||||
self.get_base_command()
|
||||
+ [f"--model-path={model_path}"]
|
||||
+ shlex.split(args or "")
|
||||
+ ["--output-file-name", f"{name}"]
|
||||
+ self.extra_args
|
||||
)
|
||||
command = [
|
||||
"sgl_diffusion",
|
||||
"generate",
|
||||
f"--config={config_path}",
|
||||
]
|
||||
duration = run_command(command)
|
||||
status = "Success" if duration else "Failed"
|
||||
succeed = duration is not None
|
||||
|
||||
self.assertIsNotNone(duration, f"Run command failed: {command}")
|
||||
duration = float(duration) if succeed else None
|
||||
self.results.append(TestResult(name, test_key, duration, succeed))
|
||||
|
||||
# verify
|
||||
self.verify_image(self.output_name)
|
||||
return name, duration, status
|
||||
|
||||
def test_generate_multiple_outputs(self):
|
||||
command = [
|
||||
"sglang",
|
||||
"generate",
|
||||
"--prompt",
|
||||
"A curious raccoon",
|
||||
"--output-path=outputs",
|
||||
f"--model-path={self.model_path}",
|
||||
"--save-output",
|
||||
f"--output-file-name={self.output_name}",
|
||||
"--num-outputs-per-prompt=2",
|
||||
"--width=720",
|
||||
"--height=720",
|
||||
]
|
||||
duration = run_command(command)
|
||||
self.assertIsNotNone(duration, f"Run command failed: {command}")
|
||||
def _run_test(self, name: str, args, model_path: str, test_key: str):
|
||||
name, duration, status = self._run_command(
|
||||
name, args=args, model_path=model_path, test_key=test_key
|
||||
)
|
||||
self.verify(status, name, duration)
|
||||
|
||||
self.verify_image(f"{self.output_name}_0.{self.ext}")
|
||||
self.verify_image(f"{self.output_name}_1.{self.ext}")
|
||||
def verify(self, status, name, duration):
|
||||
print("-" * 80)
|
||||
print("\n" * 3)
|
||||
|
||||
def verify_image(self, output_name):
|
||||
path = os.path.join("outputs", output_name)
|
||||
with Image.open(path) as image:
|
||||
check_image_size(self, image, 720, 720)
|
||||
# test task status
|
||||
self.assertEqual(status, "Success", f"{name} command failed")
|
||||
self.assertIsNotNone(duration, f"Could not parse duration for {name}")
|
||||
|
||||
def verify_video(self, output_name):
|
||||
path = os.path.join("outputs", output_name)
|
||||
with open(path, "rb") as f:
|
||||
header = f.read(12)
|
||||
assert is_mp4(header)
|
||||
# test output file
|
||||
path = os.path.join(
|
||||
self.output_path, f"{name}.{self.data_type.get_default_extension()}"
|
||||
)
|
||||
self.assertTrue(os.path.exists(path), f"Output file not exist for {path}")
|
||||
if self.data_type == DataType.IMAGE:
|
||||
with Image.open(path) as image:
|
||||
check_image_size(self, image, self.width, self.height)
|
||||
logger.info(f"{name} passed in {duration:.4f}s")
|
||||
|
||||
def model_name(self):
|
||||
return self.model_path.split("/")[-1]
|
||||
|
||||
class TestWanGenerate(TestGenerate):
|
||||
model_path = "Wan-AI/Wan2.1-T2V-1.3B-Diffusers"
|
||||
launch_file_name = "launch_wan.json"
|
||||
output_name = "Wan2.1-T2V-1.3B-Diffusers, single gpu"
|
||||
ext = "mp4"
|
||||
|
||||
def test_generate_multiple_outputs(self):
|
||||
command = [
|
||||
"sglang",
|
||||
"generate",
|
||||
"--prompt",
|
||||
"A curious raccoon",
|
||||
"--output-path=outputs",
|
||||
f"--model-path={self.model_path}",
|
||||
"--save-output",
|
||||
f"--output-file-name={self.output_name}",
|
||||
"--num-outputs-per-prompt=2",
|
||||
"--width=720",
|
||||
"--height=720",
|
||||
]
|
||||
duration = run_command(command)
|
||||
self.assertIsNotNone(duration, f"Run command failed: {command}")
|
||||
|
||||
self.verify_video(f"{self.output_name}_0.{self.ext}")
|
||||
# FIXME: second video is a meaningless output
|
||||
self.verify_video(f"{self.output_name}_1.{self.ext}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
def test_single_gpu(self):
|
||||
"""single gpu"""
|
||||
self._run_test(
|
||||
name=f"{self.model_name()}_single_gpu",
|
||||
args=None,
|
||||
model_path=self.model_path,
|
||||
test_key="test_single_gpu",
|
||||
)
|
||||
|
||||
@@ -1,132 +1,22 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import DataType
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
from sglang.multimodal_gen.test.test_utils import TestGenerateBase
|
||||
from sglang.multimodal_gen.test.cli.test_generate_common import CLIBase
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class TestFlux_T2V(TestGenerateBase):
|
||||
class TestFlux_T2V(CLIBase):
|
||||
model_path = "black-forest-labs/FLUX.1-dev"
|
||||
extra_args = []
|
||||
data_type: DataType = DataType.IMAGE
|
||||
thresholds = {
|
||||
"test_single_gpu": 6.5 * 1.05,
|
||||
"test_usp": 8.3 * 1.05,
|
||||
}
|
||||
|
||||
def test_cfg_parallel(self):
|
||||
pass
|
||||
|
||||
def test_mixed(self):
|
||||
pass
|
||||
|
||||
|
||||
class TestQwenImage(TestGenerateBase):
|
||||
model_path = "Qwen/Qwen-Image"
|
||||
extra_args = []
|
||||
data_type: DataType = DataType.IMAGE
|
||||
thresholds = {
|
||||
"test_single_gpu": 10.4 * 1.05,
|
||||
"test_usp": 20.2 * 1.05,
|
||||
}
|
||||
|
||||
def test_cfg_parallel(self):
|
||||
pass
|
||||
|
||||
def test_mixed(self):
|
||||
pass
|
||||
|
||||
|
||||
class TestQwenImageEdit(TestGenerateBase):
|
||||
model_path = "Qwen/Qwen-Image-Edit"
|
||||
extra_args = []
|
||||
data_type: DataType = DataType.IMAGE
|
||||
thresholds = {
|
||||
"test_single_gpu": 33.4 * 1.05,
|
||||
"test_usp": 26.9 * 1.05,
|
||||
}
|
||||
|
||||
prompt: str | None = (
|
||||
"Change the rabbit's color to purple, with a flash light background."
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
test_dir = Path(__file__).parent
|
||||
img_path = (test_dir / ".." / "test_files" / "rabbit.jpg").resolve().as_posix()
|
||||
self.base_command = [
|
||||
"sglang",
|
||||
"generate",
|
||||
"--text-encoder-cpu-offload",
|
||||
"--pin-cpu-memory",
|
||||
f"--prompt",
|
||||
f"{self.prompt}",
|
||||
"--save-output",
|
||||
"--log-level=debug",
|
||||
f"--width={self.width}",
|
||||
f"--height={self.height}",
|
||||
f"--output-path={self.output_path}",
|
||||
] + [f"--image-path={img_path}"]
|
||||
|
||||
def test_cfg_parallel(self):
|
||||
pass
|
||||
|
||||
def test_mixed(self):
|
||||
pass
|
||||
|
||||
|
||||
class TestQwenImageEditPlusMultiImageURL(TestGenerateBase):
|
||||
"""CLI-level test for multi-image URL input with Qwen-Image-Edit."""
|
||||
|
||||
model_path = "Qwen/Qwen-Image-Edit-2509"
|
||||
extra_args = []
|
||||
data_type: DataType = DataType.IMAGE
|
||||
|
||||
thresholds = {
|
||||
"test_single_gpu": 33.4 * 1.05,
|
||||
}
|
||||
|
||||
prompt: str | None = (
|
||||
"The magician bear is on the left, the alchemist bear is on the right, facing each other in the central park square."
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
img_urls = [
|
||||
"https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen-Image/edit2509/edit2509_1.jpg",
|
||||
"https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen-Image/edit2509/edit2509_2.jpg",
|
||||
]
|
||||
|
||||
self.base_command = [
|
||||
"sglang",
|
||||
"generate",
|
||||
"--text-encoder-cpu-offload",
|
||||
"--pin-cpu-memory",
|
||||
"--prompt",
|
||||
f"{self.prompt}",
|
||||
"--save-output",
|
||||
"--log-level=debug",
|
||||
f"--width={self.width}",
|
||||
f"--height={self.height}",
|
||||
f"--output-path={self.output_path}",
|
||||
]
|
||||
self.base_command += [
|
||||
"--image-path",
|
||||
*img_urls,
|
||||
]
|
||||
|
||||
def test_cfg_parallel(self):
|
||||
pass
|
||||
|
||||
def test_mixed(self):
|
||||
pass
|
||||
del CLIBase
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
del TestGenerateBase
|
||||
unittest.main()
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
import unittest
|
||||
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import DataType
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
from sglang.multimodal_gen.test.test_utils import TestGenerateBase
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class TestFastWan2_1_T2V(TestGenerateBase):
|
||||
model_path = "FastVideo/FastWan2.1-T2V-1.3B-Diffusers"
|
||||
extra_args = ["--attention-backend=video_sparse_attn"]
|
||||
data_type: DataType = DataType.VIDEO
|
||||
thresholds = {
|
||||
"test_single_gpu": 13.0,
|
||||
"test_cfg_parallel": 15.0,
|
||||
"test_usp": 15.0,
|
||||
"test_mixed": 15.0 * 1.05,
|
||||
}
|
||||
|
||||
# disabled for vsa
|
||||
def test_usp(self):
|
||||
pass
|
||||
|
||||
|
||||
class TestFastWan2_2_T2V(TestGenerateBase):
|
||||
model_path = "FastVideo/FastWan2.2-TI2V-5B-FullAttn-Diffusers"
|
||||
extra_args = []
|
||||
data_type: DataType = DataType.VIDEO
|
||||
thresholds = {
|
||||
"test_single_gpu": 25.0,
|
||||
"test_cfg_parallel": 30.0,
|
||||
"test_usp": 30.0,
|
||||
"test_mixed": 30.0,
|
||||
}
|
||||
|
||||
|
||||
class TestWan2_1_T2V(TestGenerateBase):
|
||||
model_path = "Wan-AI/Wan2.1-T2V-1.3B-Diffusers"
|
||||
extra_args = []
|
||||
data_type: DataType = DataType.VIDEO
|
||||
thresholds = {
|
||||
"test_single_gpu": 76.0 * 1.05,
|
||||
"test_cfg_parallel": 46.5 * 1.05,
|
||||
"test_usp": 39.8 * 1.05,
|
||||
"test_mixed": 37.3 * 1.05,
|
||||
}
|
||||
|
||||
def test_mixed(self):
|
||||
pass
|
||||
|
||||
def test_cfg_parallel(self):
|
||||
pass
|
||||
|
||||
|
||||
class TestWan2_2_T2V(TestGenerateBase):
|
||||
model_path = "Wan-AI/Wan2.2-T2V-A14B-Diffusers"
|
||||
extra_args = []
|
||||
data_type: DataType = DataType.VIDEO
|
||||
thresholds = {
|
||||
"test_single_gpu": 904.3 * 1.05,
|
||||
"test_cfg_parallel": 446,
|
||||
"test_usp": 316 * 1.05,
|
||||
"test_mixed": 159,
|
||||
}
|
||||
|
||||
def test_single_gpu(self):
|
||||
pass
|
||||
|
||||
def test_mixed(self):
|
||||
pass
|
||||
|
||||
def test_cfg_parallel(self):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
del TestGenerateBase
|
||||
unittest.main()
|
||||
@@ -1,71 +0,0 @@
|
||||
import unittest
|
||||
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import DataType
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
from sglang.multimodal_gen.test.test_utils import TestGenerateBase
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class TestGenerateTI2VBase(TestGenerateBase):
|
||||
data_type: DataType = DataType.VIDEO
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.base_command = [
|
||||
"sglang",
|
||||
"generate",
|
||||
"--prompt",
|
||||
"Summer beach vacation style, a white cat wearing sunglasses sits on a surfboard. The fluffy-furred feline gazes directly at the camera with a relaxed expression. Blurred beach scenery forms the background featuring crystal-clear waters, distant green hills, and a blue sky dotted with white clouds. The cat assumes a naturally relaxed posture, as if savoring the sea breeze and warm sunlight. A close-up shot highlights the feline's intricate details and the refreshing atmosphere of the seaside.",
|
||||
"--image-path",
|
||||
"https://github.com/Wan-Video/Wan2.2/blob/990af50de458c19590c245151197326e208d7191/examples/i2v_input.JPG?raw=true",
|
||||
"--save-output",
|
||||
"--log-level=debug",
|
||||
f"--output-path={cls.output_path}",
|
||||
] + cls.extra_args
|
||||
|
||||
def test_single_gpu(self):
|
||||
pass
|
||||
|
||||
def test_cfg_parallel(self):
|
||||
pass
|
||||
|
||||
def test_mixed(self):
|
||||
pass
|
||||
|
||||
|
||||
class TestWan2_1_I2V_14B_480P(TestGenerateTI2VBase):
|
||||
model_path = "Wan-AI/Wan2.1-I2V-14B-480P-Diffusers"
|
||||
thresholds = {
|
||||
"test_usp": 557.9 * 1.05,
|
||||
}
|
||||
|
||||
|
||||
class TestWan2_1_I2V_14B_720P(TestGenerateTI2VBase):
|
||||
model_path = "Wan-AI/Wan2.1-I2V-14B-720P-Diffusers"
|
||||
thresholds = {
|
||||
"test_usp": 558.4 * 1.05,
|
||||
}
|
||||
|
||||
|
||||
class TestWan2_2_TI2V_5B(TestGenerateTI2VBase):
|
||||
model_path = "Wan-AI/Wan2.2-TI2V-5B-Diffusers"
|
||||
# FIXME: doesn't work with vsa at the moment
|
||||
# extra_args = ["--attention-backend=video_sparse_attn"]
|
||||
thresholds = {
|
||||
"test_usp": 82.3 * 1.05,
|
||||
}
|
||||
|
||||
|
||||
# OOM
|
||||
# class TestWan2_2_I2V_A14B(TestGenerateTI2VBase):
|
||||
# model_path = "Wan-AI/Wan2.2-I2V-A14B-Diffusers"
|
||||
# # FIXME: doesn't work with vsa at the moment
|
||||
# thresholds = {
|
||||
# "test_usp": 66.3 * 1.05,
|
||||
# }
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
del TestGenerateTI2VBase, TestGenerateBase
|
||||
unittest.main()
|
||||
@@ -1,301 +0,0 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from sglang.multimodal_gen.runtime.utils.common import kill_process_tree
|
||||
from sglang.multimodal_gen.test.test_utils import is_mp4, is_png, wait_for_port
|
||||
|
||||
|
||||
@contextmanager
|
||||
def downloaded_temp_file(url: str, prefix: str = "i2v_input_", suffix: str = ".jpg"):
|
||||
tmp_path = Path(tempfile.gettempdir()) / f"{prefix}{uuid.uuid4().hex}{suffix}"
|
||||
with urlopen(url) as resp:
|
||||
tmp_path.write_bytes(resp.read())
|
||||
try:
|
||||
yield tmp_path
|
||||
finally:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def wait_for_video_completion(client, video_id, timeout=300, check_interval=3):
|
||||
start = time.time()
|
||||
video = client.videos.retrieve(video_id)
|
||||
|
||||
while video.status not in ("completed", "failed"):
|
||||
time.sleep(check_interval)
|
||||
video = client.videos.retrieve(video_id)
|
||||
assert time.time() - start < timeout, "video generate timeout"
|
||||
|
||||
return video
|
||||
|
||||
|
||||
class TestVideoHttpServer(unittest.TestCase):
|
||||
model_name = "Wan-AI/Wan2.1-T2V-1.3B-Diffusers"
|
||||
timeout = 500
|
||||
extra_args = []
|
||||
|
||||
def _create_wait_and_download(
|
||||
self, client: OpenAI, prompt: str, size: str
|
||||
) -> bytes:
|
||||
|
||||
video = client.videos.create(prompt=prompt, size=size)
|
||||
video_id = video.id
|
||||
self.assertEqual(video.status, "queued")
|
||||
|
||||
video = wait_for_video_completion(client, video_id, timeout=self.timeout)
|
||||
self.assertEqual(video.status, "completed", "video generate failed")
|
||||
|
||||
response = client.videos.download_content(
|
||||
video_id=video_id,
|
||||
)
|
||||
content = response.read()
|
||||
return content
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.base_command = [
|
||||
"sglang",
|
||||
"serve",
|
||||
"--model-path",
|
||||
f"{cls.model_name}",
|
||||
"--port",
|
||||
"30010",
|
||||
]
|
||||
|
||||
process = subprocess.Popen(
|
||||
cls.base_command + cls.extra_args,
|
||||
# stdout=subprocess.PIPE,
|
||||
# stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
cls.pid = process.pid
|
||||
wait_for_port(host="127.0.0.1", port=30010)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
kill_process_tree(cls.pid)
|
||||
|
||||
def test_http_server_basic(self):
|
||||
client = OpenAI(
|
||||
api_key="sk-proj-1234567890", base_url="http://localhost:30010/v1"
|
||||
)
|
||||
content = self._create_wait_and_download(
|
||||
client, "A plane is taking off.", "832x480"
|
||||
)
|
||||
self.assertTrue(is_mp4(content))
|
||||
|
||||
def test_concurrent_requests(self):
|
||||
client = OpenAI(
|
||||
api_key="sk-proj-1234567890", base_url="http://localhost:30010/v1"
|
||||
)
|
||||
|
||||
num_requests = 2
|
||||
|
||||
async def generate_and_check_video(prompt, size):
|
||||
content = await asyncio.to_thread(
|
||||
self._create_wait_and_download, client, prompt, size
|
||||
)
|
||||
self.assertTrue(is_mp4(content))
|
||||
|
||||
async def send_concurrent_requests():
|
||||
tasks = [
|
||||
generate_and_check_video(
|
||||
"A ship is beside the port.",
|
||||
"832x480",
|
||||
)
|
||||
for _ in range(num_requests)
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
asyncio.run(send_concurrent_requests())
|
||||
|
||||
|
||||
class TestImage2VideoHttpServer(unittest.TestCase):
|
||||
model_name = "Wan-AI/Wan2.2-I2V-A14B-Diffusers"
|
||||
timeout = 1200
|
||||
extra_args = []
|
||||
|
||||
def _create_wait_and_download(
|
||||
self, client: OpenAI, prompt: str, size: str
|
||||
) -> bytes:
|
||||
|
||||
image_url = "https://github.com/Wan-Video/Wan2.2/blob/990af50de458c19590c245151197326e208d7191/examples/i2v_input.JPG?raw=true"
|
||||
with downloaded_temp_file(
|
||||
image_url, prefix="i2v_input_", suffix=".jpg"
|
||||
) as tmp_path:
|
||||
video = client.videos.create(
|
||||
prompt=prompt,
|
||||
input_reference=tmp_path,
|
||||
size=size,
|
||||
seconds=10,
|
||||
extra_body={"fps": 16, "num_frames": 125},
|
||||
)
|
||||
# TODO: Some combinations of num_frames and fps may cause errors and need further investigation.
|
||||
video_id = video.id
|
||||
self.assertEqual(video.status, "queued")
|
||||
|
||||
video = wait_for_video_completion(client, video_id, timeout=self.timeout)
|
||||
self.assertEqual(video.status, "completed", "video generate failed")
|
||||
|
||||
response = client.videos.download_content(
|
||||
video_id=video_id,
|
||||
)
|
||||
content = response.read()
|
||||
return content
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.base_command = [
|
||||
"sglang",
|
||||
"serve",
|
||||
"--model-path",
|
||||
f"{cls.model_name}",
|
||||
"--num-gpus",
|
||||
"4",
|
||||
"--ulysses-degree",
|
||||
"4",
|
||||
"--port",
|
||||
"30010",
|
||||
]
|
||||
|
||||
process = subprocess.Popen(
|
||||
cls.base_command + cls.extra_args,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
cls.pid = process.pid
|
||||
wait_for_port(host="127.0.0.1", port=30010)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
kill_process_tree(cls.pid)
|
||||
|
||||
def test_http_server_basic(self):
|
||||
client = OpenAI(
|
||||
api_key="sk-proj-1234567890", base_url="http://localhost:30010/v1"
|
||||
)
|
||||
content = self._create_wait_and_download(
|
||||
client, "A cat surfing on the sea.", "832x480"
|
||||
)
|
||||
self.assertTrue(is_mp4(content))
|
||||
|
||||
def test_concurrent_requests(self):
|
||||
client = OpenAI(
|
||||
api_key="sk-proj-1234567890", base_url="http://localhost:30010/v1"
|
||||
)
|
||||
|
||||
num_requests = 2
|
||||
|
||||
async def generate_and_check_video(prompt, size):
|
||||
content = await asyncio.to_thread(
|
||||
self._create_wait_and_download, client, prompt, size
|
||||
)
|
||||
self.assertTrue(is_mp4(content))
|
||||
|
||||
async def send_concurrent_requests():
|
||||
tasks = [
|
||||
generate_and_check_video(
|
||||
"A cat surfing on the sea.",
|
||||
"832x480",
|
||||
)
|
||||
for _ in range(num_requests)
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
asyncio.run(send_concurrent_requests())
|
||||
|
||||
|
||||
class TestImageHttpServer(unittest.TestCase):
|
||||
|
||||
def _create_wait_and_download(
|
||||
self, client: OpenAI, prompt: str, size: str
|
||||
) -> bytes:
|
||||
img = client.images.generate(
|
||||
model="gpt-image-1",
|
||||
prompt=prompt,
|
||||
n=1,
|
||||
size=size,
|
||||
response_format="b64_json",
|
||||
output_format="png",
|
||||
)
|
||||
image_bytes = base64.b64decode(img.data[0].b64_json)
|
||||
return image_bytes
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.base_command = [
|
||||
"sglang",
|
||||
"serve",
|
||||
"--model-path",
|
||||
"Qwen/Qwen-Image",
|
||||
"--port",
|
||||
"30020",
|
||||
]
|
||||
|
||||
process = subprocess.Popen(
|
||||
cls.base_command,
|
||||
# stdout=subprocess.PIPE,
|
||||
# stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
cls.pid = process.pid
|
||||
wait_for_port(host="127.0.0.1", port=30020)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
kill_process_tree(cls.pid)
|
||||
|
||||
def test_http_server_basic(self):
|
||||
client = OpenAI(
|
||||
api_key="sk-proj-1234567890", base_url="http://localhost:30020/v1"
|
||||
)
|
||||
content = self._create_wait_and_download(
|
||||
client, "A calico cat playing a piano on stage", "832x480"
|
||||
)
|
||||
self.assertTrue(is_png(content))
|
||||
|
||||
def test_concurrent_requests(self):
|
||||
client = OpenAI(
|
||||
api_key="sk-proj-1234567890", base_url="http://localhost:30020/v1"
|
||||
)
|
||||
|
||||
num_requests = 2
|
||||
|
||||
async def generate_and_check_image(prompt, size):
|
||||
content = await asyncio.to_thread(
|
||||
self._create_wait_and_download, client, prompt, size
|
||||
)
|
||||
self.assertTrue(is_png(content))
|
||||
|
||||
async def send_concurrent_requests():
|
||||
tasks = [
|
||||
generate_and_check_image(
|
||||
"A dog playing a piano on stage",
|
||||
"832x480",
|
||||
)
|
||||
for _ in range(num_requests)
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
asyncio.run(send_concurrent_requests())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# del TestPerform·anceBase
|
||||
unittest.main()
|
||||
@@ -23,6 +23,8 @@ SUITES = {
|
||||
"test_server_a.py",
|
||||
"test_server_b.py",
|
||||
"test_lora_format_adapter.py",
|
||||
# cli test
|
||||
"../cli/test_generate_t2i_perf.py",
|
||||
# add new 1-gpu test files here
|
||||
],
|
||||
"2-gpu": [
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
"""
|
||||
Testing the performance of generate command of sgl_diffusion' CLI
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.entrypoints.diffusion_generator import DiffGenerator
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class TestGeneratorAPIBase(unittest.TestCase):
|
||||
# server args
|
||||
server_kwargs = {}
|
||||
|
||||
# sampling
|
||||
output_path: str = "test_outputs"
|
||||
|
||||
results = []
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.results = []
|
||||
|
||||
def verify_single_generation_result(self, result):
|
||||
self.assertIsNotNone(result, "Generation failed")
|
||||
self.assertTrue(
|
||||
"samples" in result and isinstance(result["samples"], torch.Tensor),
|
||||
f"Incorrect Generation result",
|
||||
)
|
||||
|
||||
def _run_test(self, name, server_kwargs, test_key: str):
|
||||
generator = DiffGenerator.from_pretrained(**server_kwargs)
|
||||
result = generator.generate(prompt="A curious raccoon")
|
||||
self.verify_single_generation_result(result)
|
||||
|
||||
def test_single_gpu(self):
|
||||
self._run_test(
|
||||
name=self.server_kwargs["model_path"],
|
||||
server_kwargs=self.server_kwargs | dict(num_gpus=1),
|
||||
test_key="test_single_gpu",
|
||||
)
|
||||
|
||||
def test_cfg_parallel(self):
|
||||
self._run_test(
|
||||
name=self.server_kwargs["model_path"],
|
||||
server_kwargs=self.server_kwargs
|
||||
| dict(num_gpus=2, enable_cfg_parallel=True),
|
||||
test_key="test_cfg_parallel",
|
||||
)
|
||||
|
||||
def test_multiple_prompts(self):
|
||||
generator = DiffGenerator.from_pretrained(
|
||||
**self.server_kwargs | dict(num_gpus=2, enable_cfg_parallel=True)
|
||||
)
|
||||
prompts = ["A curious raccoon", "A curious cat"]
|
||||
results = generator.generate(prompt=prompts)
|
||||
|
||||
self.assertEqual(len(results), len(prompts), "Some generation tasks fail")
|
||||
for result in results:
|
||||
self.verify_single_generation_result(result)
|
||||
|
||||
|
||||
class TestWan2_1_T2V(TestGeneratorAPIBase):
|
||||
server_kwargs = {"model_path": "Wan-AI/Wan2.1-T2V-1.3B-Diffusers"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
del TestGeneratorAPIBase
|
||||
unittest.main()
|
||||
@@ -1,21 +1,14 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
import base64
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import cv2
|
||||
from PIL import Image
|
||||
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import DataType
|
||||
from sglang.multimodal_gen.runtime.utils.common import get_bool_env_var
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
from sglang.multimodal_gen.runtime.utils.perf_logger import (
|
||||
@@ -35,31 +28,6 @@ def is_image_url(image_path: str | Path | None) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def run_command(command) -> Optional[float]:
|
||||
"""Runs a command and returns the execution time and status."""
|
||||
print(f"Running command: {shlex.join(command)}")
|
||||
|
||||
duration = None
|
||||
with subprocess.Popen(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
) as process:
|
||||
for line in process.stdout:
|
||||
sys.stdout.write(line)
|
||||
if "Pixel data generated" in line:
|
||||
words = line.split(" ")
|
||||
duration = float(words[-2])
|
||||
|
||||
if process.returncode == 0:
|
||||
return duration
|
||||
else:
|
||||
print(f"Command failed with exit code {process.returncode}")
|
||||
return None
|
||||
|
||||
|
||||
def probe_port(host="127.0.0.1", port=30010, timeout=2.0) -> bool:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(timeout)
|
||||
@@ -397,199 +365,3 @@ def validate_video_file(
|
||||
assert (
|
||||
actual_height == expected_height
|
||||
), f"Video height mismatch: expected {expected_height}, got {actual_height}"
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class TestResult:
|
||||
name: str
|
||||
key: str
|
||||
duration: Optional[float]
|
||||
succeed: bool
|
||||
|
||||
@property
|
||||
def duration_str(self):
|
||||
return f"{self.duration:.4f}" if self.duration else "NA"
|
||||
|
||||
|
||||
class TestCLIBase(unittest.TestCase):
|
||||
model_path: str = None
|
||||
extra_args = []
|
||||
data_type: DataType = None
|
||||
# tested on h100
|
||||
thresholds = {}
|
||||
|
||||
width: int = 720
|
||||
height: int = 720
|
||||
output_path: str = "test_outputs"
|
||||
|
||||
base_command = [
|
||||
"sglang",
|
||||
"generate",
|
||||
"--text-encoder-cpu-offload",
|
||||
"--pin-cpu-memory",
|
||||
"--prompt",
|
||||
"A curious raccoon",
|
||||
"--save-output",
|
||||
"--log-level=debug",
|
||||
f"--width={width}",
|
||||
f"--height={height}",
|
||||
f"--output-path={output_path}",
|
||||
]
|
||||
|
||||
results = []
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.results = []
|
||||
|
||||
def _run_command(self, name: str, model_path: str, test_key: str = "", args=[]):
|
||||
command = (
|
||||
self.base_command
|
||||
+ [f"--model-path={model_path}"]
|
||||
+ shlex.split(args or "")
|
||||
+ ["--output-file-name", f"{name}"]
|
||||
+ self.extra_args
|
||||
)
|
||||
duration = run_command(command)
|
||||
status = "Success" if duration else "Failed"
|
||||
succeed = duration is not None
|
||||
|
||||
duration = float(duration) if succeed else None
|
||||
self.results.append(TestResult(name, test_key, duration, succeed))
|
||||
|
||||
return name, duration, status
|
||||
|
||||
|
||||
class TestGenerateBase(TestCLIBase):
|
||||
model_path: str = None
|
||||
extra_args = []
|
||||
data_type: DataType = None
|
||||
# tested on h100
|
||||
thresholds = {}
|
||||
|
||||
width: int = 720
|
||||
height: int = 720
|
||||
output_path: str = "test_outputs"
|
||||
image_path: str | None = None
|
||||
prompt: str | None = "A curious raccoon"
|
||||
|
||||
base_command = [
|
||||
"sglang",
|
||||
"generate",
|
||||
# "--text-encoder-cpu-offload",
|
||||
# "--pin-cpu-memory",
|
||||
f"--prompt",
|
||||
f"{prompt}",
|
||||
"--save-output",
|
||||
"--log-level=debug",
|
||||
f"--width={width}",
|
||||
f"--height={height}",
|
||||
f"--output-path={output_path}",
|
||||
]
|
||||
|
||||
results: list[TestResult] = []
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.results = []
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
# Print markdown table
|
||||
print("\n## Test Results\n")
|
||||
print("| Test Case | Duration | Status |")
|
||||
print("|--------------------------------|----------|---------|")
|
||||
test_keys = ["test_single_gpu", "test_cfg_parallel", "test_usp", "test_mixed"]
|
||||
test_key_to_order = {
|
||||
test_key: order for order, test_key in enumerate(test_keys)
|
||||
}
|
||||
|
||||
ordered_results: list[TestResult] = [None] * len(test_keys)
|
||||
for result in cls.results:
|
||||
order = test_key_to_order[result.key]
|
||||
ordered_results[order] = result
|
||||
|
||||
for result in ordered_results:
|
||||
if not result:
|
||||
continue
|
||||
status = (
|
||||
"Succeed"
|
||||
if (
|
||||
result.succeed
|
||||
and float(result.duration) <= float(cls.thresholds[result.key])
|
||||
)
|
||||
else "Failed"
|
||||
)
|
||||
print(f"| {result.name:<30} | {result.duration_str:<8} | {status:<7} |")
|
||||
print()
|
||||
durations = [result.duration_str for result in cls.results]
|
||||
print(" | ".join([""] + durations + [""]))
|
||||
|
||||
def _run_test(self, name: str, args, model_path: str, test_key: str):
|
||||
time_threshold = self.thresholds[test_key]
|
||||
name, duration, status = self._run_command(
|
||||
name, args=args, model_path=model_path, test_key=test_key
|
||||
)
|
||||
self.verify(status, name, duration, time_threshold)
|
||||
|
||||
def verify(self, status, name, duration, time_threshold):
|
||||
print("-" * 80)
|
||||
print("\n" * 3)
|
||||
|
||||
# test task status
|
||||
self.assertEqual(status, "Success", f"{name} command failed")
|
||||
self.assertIsNotNone(duration, f"Could not parse duration for {name}")
|
||||
self.assertLessEqual(
|
||||
duration,
|
||||
time_threshold,
|
||||
f"{name} failed with {duration:.4f}s > {time_threshold}s",
|
||||
)
|
||||
|
||||
# test output file
|
||||
path = os.path.join(
|
||||
self.output_path, f"{name}.{self.data_type.get_default_extension()}"
|
||||
)
|
||||
self.assertTrue(os.path.exists(path), f"Output file not exist for {path}")
|
||||
if self.data_type == DataType.IMAGE:
|
||||
with Image.open(path) as image:
|
||||
check_image_size(self, image, self.width, self.height)
|
||||
logger.info(f"{name} passed in {duration:.4f}s (threshold: {time_threshold}s)")
|
||||
|
||||
def model_name(self):
|
||||
return self.model_path.split("/")[-1]
|
||||
|
||||
def test_single_gpu(self):
|
||||
"""single gpu"""
|
||||
self._run_test(
|
||||
name=f"{self.model_name()}_single_gpu",
|
||||
args=None,
|
||||
model_path=self.model_path,
|
||||
test_key="test_single_gpu",
|
||||
)
|
||||
|
||||
def test_cfg_parallel(self):
|
||||
"""cfg parallel"""
|
||||
self._run_test(
|
||||
name=f"{self.model_name()}_cfg_parallel",
|
||||
args="--num-gpus 2 --enable-cfg-parallel",
|
||||
model_path=self.model_path,
|
||||
test_key="test_cfg_parallel",
|
||||
)
|
||||
|
||||
def test_usp(self):
|
||||
"""usp"""
|
||||
self._run_test(
|
||||
name=f"{self.model_name()}_usp",
|
||||
args="--num-gpus 4 --ulysses-degree=2 --ring-degree=2",
|
||||
model_path=self.model_path,
|
||||
test_key="test_usp",
|
||||
)
|
||||
|
||||
def test_mixed(self):
|
||||
"""mixed"""
|
||||
self._run_test(
|
||||
name=f"{self.model_name()}_mixed",
|
||||
args="--num-gpus 4 --ulysses-degree=2 --ring-degree=1 --enable-cfg-parallel",
|
||||
model_path=self.model_path,
|
||||
test_key="test_mixed",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user