[Test] Unify L2/L3 test layout, Buildkite steps, and test helpers (#2556)
Signed-off-by: wangyu <410167048@qq.com> Signed-off-by: Huang, Zeyu <11222265+fhfuih@users.noreply.github.com> Signed-off-by: wangyu <53896905+yenuo26@users.noreply.github.com> Co-authored-by: Huang, Zeyu <11222265+fhfuih@users.noreply.github.com>
This commit is contained in:
1 parent
b85833eeec
commit
6f2ad7b403
134 files changed
+4904
-4185
No files matched your search
@@ -0,0 +1,132 @@
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from safetensors.torch import save_file
|
||||
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
# ruff: noqa: E402
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
# This test is specific to Z-Image LoRA behavior. Keep it focused on a single
|
||||
# model to reduce runtime and avoid extra downloads.
|
||||
models = ["Tongyi-MAI/Z-Image-Turbo"]
|
||||
omni_runner_params = [(model_name, None) for model_name in models]
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.parametrize("omni_runner", omni_runner_params, indirect=True)
|
||||
def test_diffusion_model(tmp_path: Path, omni_runner):
|
||||
def _extract_images(outputs: list[OmniRequestOutput]):
|
||||
if not outputs:
|
||||
raise ValueError("Empty outputs from Omni.generate()")
|
||||
first_output = outputs[0]
|
||||
assert first_output.final_output_type == "image"
|
||||
if not hasattr(first_output, "request_output") or not first_output.request_output:
|
||||
raise ValueError("No request_output found in OmniRequestOutput")
|
||||
|
||||
req_out = first_output.request_output
|
||||
if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"):
|
||||
raise ValueError("Invalid request_output structure or missing 'images' key")
|
||||
return req_out.images
|
||||
|
||||
def _write_zimage_lora(adapter_dir: Path) -> str:
|
||||
adapter_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Z-Image transformer uses dim=3840 by default (see ZImageTransformer2DModel).
|
||||
dim = 3840
|
||||
module_name = "transformer.layers.0.attention.to_qkv"
|
||||
rank = 1
|
||||
lora_a = torch.zeros((rank, dim), dtype=torch.float32)
|
||||
lora_a[0, 0] = 1.0
|
||||
|
||||
# QKVParallelLinear packs (Q, K, V). With tp=1 and n_kv_heads==n_heads in Z-Image,
|
||||
# each slice is `dim`, so total out dim is `3 * dim`.
|
||||
lora_b = torch.zeros((3 * dim, rank), dtype=torch.float32)
|
||||
# Apply a visible delta to the Q slice only to keep the perturbation bounded.
|
||||
lora_b[:dim, 0] = 0.1
|
||||
|
||||
save_file(
|
||||
{
|
||||
f"base_model.model.{module_name}.lora_A.weight": lora_a,
|
||||
f"base_model.model.{module_name}.lora_B.weight": lora_b,
|
||||
},
|
||||
str(adapter_dir / "adapter_model.safetensors"),
|
||||
)
|
||||
(adapter_dir / "adapter_config.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"r": rank,
|
||||
"lora_alpha": rank,
|
||||
"target_modules": [module_name],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return str(adapter_dir)
|
||||
|
||||
runner = omni_runner
|
||||
m = runner.omni
|
||||
# high resolution may cause OOM on L4
|
||||
height = 256
|
||||
width = 256
|
||||
prompt = "a photo of a cat sitting on a laptop keyboard"
|
||||
|
||||
outputs = m.generate(
|
||||
prompt,
|
||||
OmniDiffusionSamplingParams(
|
||||
height=height,
|
||||
width=width,
|
||||
num_inference_steps=2,
|
||||
guidance_scale=0.0,
|
||||
generator=torch.Generator(current_omni_platform.device_type).manual_seed(42),
|
||||
num_outputs_per_prompt=1,
|
||||
),
|
||||
)
|
||||
images = _extract_images(outputs)
|
||||
|
||||
assert len(images) == 1
|
||||
# check image size
|
||||
assert images[0].width == width
|
||||
assert images[0].height == height
|
||||
|
||||
# Real LoRA E2E: generate again with a real on-disk PEFT adapter and
|
||||
# verify that output changes.
|
||||
from vllm_omni.lora.request import LoRARequest
|
||||
from vllm_omni.lora.utils import stable_lora_int_id
|
||||
|
||||
lora_dir = _write_zimage_lora(tmp_path / "zimage_lora")
|
||||
lora_request = LoRARequest(
|
||||
lora_name="test",
|
||||
lora_int_id=stable_lora_int_id(lora_dir),
|
||||
lora_path=lora_dir,
|
||||
)
|
||||
outputs_lora = m.generate(
|
||||
prompt,
|
||||
OmniDiffusionSamplingParams(
|
||||
height=height,
|
||||
width=width,
|
||||
num_inference_steps=2,
|
||||
guidance_scale=0.0,
|
||||
generator=torch.Generator(current_omni_platform.device_type).manual_seed(42),
|
||||
num_outputs_per_prompt=1,
|
||||
lora_request=lora_request,
|
||||
lora_scale=2.0,
|
||||
),
|
||||
)
|
||||
images_lora = _extract_images(outputs_lora)
|
||||
assert len(images_lora) == 1
|
||||
assert images_lora[0].width == width
|
||||
assert images_lora[0].height == height
|
||||
|
||||
import numpy as np
|
||||
|
||||
diff = np.abs(np.array(images[0], dtype=np.int16) - np.array(images_lora[0], dtype=np.int16)).mean()
|
||||
assert diff > 0.0
|
||||
@@ -0,0 +1,206 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""
|
||||
E2E online serving test for /v1/images/generations with per-request LoRA.
|
||||
|
||||
This validates:
|
||||
- The API server accepts a per-request `lora` object in the Images API payload.
|
||||
- LoRA can be switched per request (adapter A -> adapter B -> no LoRA).
|
||||
- Output correctness is asserted using a small image slice with tolerance.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import requests
|
||||
import torch
|
||||
from PIL import Image
|
||||
from safetensors.torch import save_file
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.runtime import OmniServer, OmniServerParams
|
||||
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
|
||||
MODEL = "Tongyi-MAI/Z-Image-Turbo"
|
||||
|
||||
|
||||
PROMPT = "a photo of a cat sitting on a laptop keyboard"
|
||||
SIZE = "256x256"
|
||||
SEED = 42
|
||||
|
||||
|
||||
server_params = [
|
||||
pytest.param(
|
||||
OmniServerParams(
|
||||
model=MODEL,
|
||||
server_args=[
|
||||
"--num-gpus",
|
||||
"1",
|
||||
],
|
||||
),
|
||||
id="zimage_turbo_lora",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _write_zimage_lora(adapter_dir: Path, *, q_scale: float = 0.0, k_scale: float = 0.0, v_scale: float = 0.0):
|
||||
adapter_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Z-Image transformer uses dim=3840 by default.
|
||||
dim = 3840
|
||||
module_name = "transformer.layers.0.attention.to_qkv"
|
||||
rank = 1
|
||||
|
||||
lora_a = torch.zeros((rank, dim), dtype=torch.float32)
|
||||
lora_a[0, 0] = 1.0
|
||||
|
||||
# QKVParallelLinear packs (Q, K, V) => out dim is 3 * dim (tp=1).
|
||||
lora_b = torch.zeros((3 * dim, rank), dtype=torch.float32)
|
||||
if q_scale:
|
||||
lora_b[:dim, 0] = q_scale
|
||||
if k_scale:
|
||||
lora_b[dim : 2 * dim, 0] = k_scale
|
||||
if v_scale:
|
||||
lora_b[2 * dim :, 0] = v_scale
|
||||
|
||||
save_file(
|
||||
{
|
||||
f"base_model.model.{module_name}.lora_A.weight": lora_a,
|
||||
f"base_model.model.{module_name}.lora_B.weight": lora_b,
|
||||
},
|
||||
str(adapter_dir / "adapter_model.safetensors"),
|
||||
)
|
||||
(adapter_dir / "adapter_config.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"r": rank,
|
||||
"lora_alpha": rank,
|
||||
"target_modules": [module_name],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _post_images(server: OmniServer, payload: dict) -> Image.Image:
|
||||
url = f"http://{server.host}:{server.port}/v1/images/generations"
|
||||
resp = requests.post(url, json=payload, headers={"Authorization": "Bearer EMPTY"}, timeout=900)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
b64 = data["data"][0]["b64_json"]
|
||||
img_bytes = base64.b64decode(b64)
|
||||
img = Image.open(BytesIO(img_bytes))
|
||||
img.load()
|
||||
return img.convert("RGB")
|
||||
|
||||
|
||||
def _image_blue_tail_slice(img: Image.Image) -> np.ndarray:
|
||||
arr = np.asarray(img, dtype=np.uint8)
|
||||
assert arr.ndim == 3 and arr.shape[-1] == 3
|
||||
tail = arr[-3:, -3:, -1].astype(np.float32)
|
||||
assert tail.shape == (3, 3)
|
||||
return tail
|
||||
|
||||
|
||||
def _slice_diff_stats(actual: np.ndarray, expected: np.ndarray) -> tuple[float, float]:
|
||||
diff = np.abs(actual - expected)
|
||||
return float(diff.max()), float(diff.mean())
|
||||
|
||||
|
||||
def _assert_slice_close(
|
||||
actual: np.ndarray,
|
||||
expected: np.ndarray,
|
||||
*,
|
||||
label: str,
|
||||
base_max: float,
|
||||
base_mean: float,
|
||||
) -> None:
|
||||
assert actual.shape == (3, 3)
|
||||
assert expected.shape == (3, 3)
|
||||
max_diff, mean_diff = _slice_diff_stats(actual, expected)
|
||||
# NOTE: Different attention backends / torch.compile can introduce small
|
||||
# floating-point drift that shows up as a few LSBs in uint8 pixels. Keep
|
||||
# the reset check tolerant but bounded to avoid flaky CI.
|
||||
max_thresh = max(10.0, base_max + 4.0)
|
||||
mean_thresh = max(6.0, base_mean + 4.0)
|
||||
assert max_diff <= max_thresh and mean_diff <= mean_thresh, (
|
||||
f"{label} slice mismatch (max={max_diff:.1f} > {max_thresh:.1f} or "
|
||||
f"mean={mean_diff:.1f} > {mean_thresh:.1f}): {actual.tolist()}"
|
||||
)
|
||||
|
||||
|
||||
def _assert_slice_diff(actual: np.ndarray, baseline: np.ndarray, *, label: str) -> None:
|
||||
assert actual.shape == (3, 3)
|
||||
assert baseline.shape == (3, 3)
|
||||
diff = np.abs(actual - baseline).mean()
|
||||
assert diff > 0.1, f"{label} slice diff too small: {diff} ({actual.tolist()} vs {baseline.tolist()})"
|
||||
|
||||
|
||||
def _basic_payload() -> dict:
|
||||
return {
|
||||
"prompt": PROMPT,
|
||||
"n": 1,
|
||||
"size": SIZE,
|
||||
"num_inference_steps": 2,
|
||||
"guidance_scale": 0.0,
|
||||
"seed": SEED,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.diffusion
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325", "xpu": "B60"})
|
||||
@pytest.mark.parametrize("omni_server", server_params, indirect=True)
|
||||
def test_images_generations_per_request_lora_switching(omni_server: OmniServer, tmp_path: Path) -> None:
|
||||
# Base generation.
|
||||
base_img = _post_images(omni_server, _basic_payload())
|
||||
base_slice = _image_blue_tail_slice(base_img)
|
||||
base_ref_img = _post_images(omni_server, _basic_payload())
|
||||
base_ref_slice = _image_blue_tail_slice(base_ref_img)
|
||||
base_ref_max, base_ref_mean = _slice_diff_stats(base_ref_slice, base_slice)
|
||||
|
||||
# Adapter A: apply delta to V slice only.
|
||||
lora_a_dir = tmp_path / "zimage_lora_a"
|
||||
_write_zimage_lora(lora_a_dir, v_scale=8.0)
|
||||
payload_a = _basic_payload()
|
||||
payload_a["lora"] = {"name": "a", "path": str(lora_a_dir), "scale": 64.0}
|
||||
img_a = _post_images(omni_server, payload_a)
|
||||
a_slice = _image_blue_tail_slice(img_a)
|
||||
_assert_slice_diff(a_slice, base_slice, label="lora_a_vs_base")
|
||||
a_vs_base = float(np.abs(a_slice - base_slice).mean())
|
||||
|
||||
# Adapter B: apply delta to K slice only (should differ from adapter A).
|
||||
lora_b_dir = tmp_path / "zimage_lora_b"
|
||||
_write_zimage_lora(lora_b_dir, k_scale=4.0)
|
||||
payload_b = _basic_payload()
|
||||
payload_b["lora"] = {"name": "b", "path": str(lora_b_dir), "scale": 64.0}
|
||||
img_b = _post_images(omni_server, payload_b)
|
||||
b_slice = _image_blue_tail_slice(img_b)
|
||||
_assert_slice_diff(b_slice, base_slice, label="lora_b_vs_base")
|
||||
_assert_slice_diff(b_slice, a_slice, label="lora_b_vs_lora_a")
|
||||
b_vs_base = float(np.abs(b_slice - base_slice).mean())
|
||||
b_vs_a = float(np.abs(b_slice - a_slice).mean())
|
||||
|
||||
# Ensure switching back to no-LoRA restores the base output.
|
||||
base_img_2 = _post_images(omni_server, _basic_payload())
|
||||
base_slice_2 = _image_blue_tail_slice(base_img_2)
|
||||
_, base_reset_mean = _slice_diff_stats(base_slice_2, base_slice)
|
||||
_assert_slice_close(
|
||||
base_slice_2,
|
||||
base_slice,
|
||||
label="base_after_reset",
|
||||
base_max=base_ref_max,
|
||||
base_mean=base_ref_mean,
|
||||
)
|
||||
|
||||
# Ensure LoRA effects are clearly above the baseline drift.
|
||||
min_delta = max(base_reset_mean + 1.0, 1.5)
|
||||
assert a_vs_base > min_delta, f"lora_a_vs_base drift too small: {a_vs_base} <= {min_delta}"
|
||||
assert b_vs_base > min_delta, f"lora_b_vs_base drift too small: {b_vs_base} <= {min_delta}"
|
||||
assert b_vs_a > min_delta, f"lora_b_vs_lora_a drift too small: {b_vs_a} <= {min_delta}"
|
||||
Reference in new issue
Block a user