Files
vllm-omni/tests/diffusion/models/qwen_image_21/test_pipeline.py
T
3dc35694b3 [New model] Qwen-Image-2.1 support (rebase + review follow-ups) (#8099)
Signed-off-by: NumberWan <wantszkin2003@gmail.com>
Signed-off-by: SamitHuang <285365963@qq.com>
Signed-off-by: Samit <285365963@qq.com>
Co-authored-by: Isotr0py <Isotr0py@outlook.com>
Co-authored-by: Tianyu Guo <guoty9@mail2.sysu.edu.cn>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: SamitHuang <285365963@qq.com>
Co-authored-by: Gao Han <hgaoaf@connect.ust.hk>
2026-10-07 21:23:59 +08:00

403 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""CPU unit tests for QwenImage21Pipeline helpers: prepare_latents duplication
order, KV-cache assembly across step phases, and the post-process func."""
import json
from types import SimpleNamespace
import pytest
import torch
from torch import nn
from vllm_omni.diffusion.models.qwen_image_21.pipeline_qwen_image_21 import (
QwenImage21Pipeline,
get_qwen_image_21_post_process_func,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
def _toy_pipeline() -> QwenImage21Pipeline:
"""A pipeline shell with the attributes prepare_latents reads, no weights."""
pipeline = QwenImage21Pipeline.__new__(QwenImage21Pipeline)
nn.Module.__init__(pipeline)
pipeline.vae_scale_factor = 16
pipeline.latent_channels = 4
return pipeline
# ---------------------------------------------------------------------------
# prepare_latents
# ---------------------------------------------------------------------------
def test_prepare_latents_duplicates_condition_latents_request_major():
"""batch=2 prompts x n=2 outputs must order condition latents A,A,B,B.
``encode_prompt`` expands prompt embeddings request-major (A,A,B,B); the
condition latents must follow the same order or generations 2 and 3 swap
condition images. ``repeat`` would produce the wrong (A,B,A,B) tile-major
order; ``repeat_interleave`` is correct.
"""
pipeline = _toy_pipeline()
cond_a = torch.full((1, 4, 1, 8, 8), 1.0)
cond_b = torch.full((1, 4, 1, 8, 8), 2.0)
stacked = torch.cat([cond_a, cond_b], dim=0) # one row per request
latents, image_latents = pipeline.prepare_latents(
[stacked],
batch_size=4,
num_channels_latents=4,
height=64,
width=64,
dtype=torch.float32,
device=torch.device("cpu"),
generator=torch.Generator().manual_seed(0),
)
assert latents.shape == (4, 16, 4) # packed: 4x4 latent tokens, 4 channels
assert image_latents.shape == (4, 64, 4) # packed: 8x8 condition tokens
row_means = image_latents.mean(dim=(1, 2))
torch.testing.assert_close(row_means, torch.tensor([1.0, 1.0, 2.0, 2.0]))
def test_prepare_latents_without_duplication_when_rows_match_batch():
pipeline = _toy_pipeline()
stacked = torch.arange(4, dtype=torch.float32).view(4, 1, 1, 1, 1).expand(4, 4, 1, 2, 2).contiguous()
_, image_latents = pipeline.prepare_latents(
[stacked],
batch_size=4,
num_channels_latents=4,
height=32,
width=32,
dtype=torch.float32,
device=torch.device("cpu"),
generator=torch.Generator().manual_seed(0),
)
torch.testing.assert_close(image_latents.mean(dim=(1, 2)), torch.arange(4, dtype=torch.float32))
def test_prepare_latents_rejects_non_divisible_duplication():
pipeline = _toy_pipeline()
stacked = torch.zeros(2, 4, 1, 8, 8)
with pytest.raises(ValueError, match="Cannot duplicate"):
pipeline.prepare_latents(
[stacked],
batch_size=3,
num_channels_latents=4,
height=64,
width=64,
dtype=torch.float32,
device=torch.device("cpu"),
generator=torch.Generator().manual_seed(0),
)
def test_prepare_latents_rejects_generator_list_length_mismatch():
pipeline = _toy_pipeline()
with pytest.raises(ValueError, match="list of generators"):
pipeline.prepare_latents(
None,
batch_size=2,
num_channels_latents=4,
height=32,
width=32,
dtype=torch.float32,
device=torch.device("cpu"),
generator=[torch.Generator().manual_seed(0)],
)
def test_prepare_latents_concatenates_multiple_condition_images():
pipeline = _toy_pipeline()
first = torch.full((2, 4, 1, 4, 4), 1.0)
second = torch.full((2, 4, 1, 2, 2), 3.0)
_, image_latents = pipeline.prepare_latents(
[first, second],
batch_size=2,
num_channels_latents=4,
height=32,
width=32,
dtype=torch.float32,
device=torch.device("cpu"),
generator=torch.Generator().manual_seed(0),
)
# Token dim concatenates the packed condition images: 16 + 4 per row.
assert image_latents.shape == (2, 20, 4)
torch.testing.assert_close(image_latents[:, :16].mean(), torch.tensor(1.0))
torch.testing.assert_close(image_latents[:, 16:].mean(), torch.tensor(3.0))
# ---------------------------------------------------------------------------
# KV-cache assembly across step phases
# ---------------------------------------------------------------------------
def _state(cache) -> SimpleNamespace:
return SimpleNamespace(extra={"kv_cache": cache})
def _decode_cache(rows: int, value: float) -> list[dict]:
return [
{
"cond": {
"key": torch.full((rows, 2), value),
"value": torch.full((rows, 2), value + 0.5),
}
}
]
def test_kv_cache_phase_classification():
assert QwenImage21Pipeline._kv_cache_phase(None) == "none"
assert QwenImage21Pipeline._kv_cache_phase([{}]) == "prefill"
assert QwenImage21Pipeline._kv_cache_phase(_decode_cache(1, 0.0)) == "decode"
def test_assemble_kv_cache_all_none():
assert QwenImage21Pipeline._assemble_kv_cache([_state(None), _state(None)]) == (None, False)
def test_assemble_kv_cache_prefill_returns_fresh_owned_cache():
states = [_state([{} for _ in range(3)]), _state([{} for _ in range(3)])]
kv_cache, take_ownership = QwenImage21Pipeline._assemble_kv_cache(states)
assert take_ownership is True
assert kv_cache == [{}, {}, {}]
# Fresh blocks, not aliases of either request's cache: the batched prefill
# writes into these and _scatter_kv_cache splits them back per request.
for block in kv_cache:
assert all(block is not request_block for state in states for request_block in state.extra["kv_cache"])
def test_assemble_kv_cache_decode_concatenates_in_request_order():
first, second = _decode_cache(2, 1.0), _decode_cache(1, 3.0)
states = [_state(first), _state(second)]
kv_cache, take_ownership = QwenImage21Pipeline._assemble_kv_cache(states)
assert take_ownership is False
assert len(kv_cache) == 1
cond = kv_cache[0]["cond"]
torch.testing.assert_close(cond["key"], torch.tensor([[1.0, 1.0], [1.0, 1.0], [3.0, 3.0]]))
torch.testing.assert_close(cond["value"], torch.tensor([[1.5, 1.5], [1.5, 1.5], [3.5, 3.5]]))
# Decode only reads the cache: per-request tensors must stay untouched.
assert first[0]["cond"]["key"].shape == (2, 2)
assert second[0]["cond"]["key"].shape == (1, 2)
def test_assemble_kv_cache_decode_merges_every_part():
first = [{"cond": {"key": torch.ones(1, 1), "value": torch.ones(1, 1), "key_scale": torch.ones(1)}}]
second = [{"cond": {"key": torch.zeros(1, 1), "value": torch.zeros(1, 1), "key_scale": torch.zeros(1)}}]
kv_cache, take_ownership = QwenImage21Pipeline._assemble_kv_cache([_state(first), _state(second)])
assert take_ownership is False
assert set(kv_cache[0]["cond"]) == {"key", "value", "key_scale"}
torch.testing.assert_close(kv_cache[0]["cond"]["key_scale"], torch.tensor([1.0, 0.0]))
def test_assemble_kv_cache_mixed_phases_raises():
"""Defensive backstop: a late prefill must never join a decoding batch.
The preprocessor sets ``allow_mixed_step_phases = False`` so the
StepScheduler defers late requests until the batch finishes decoding; this
raise is the last line of defense if a mixed batch is ever assembled.
"""
states = [_state([{}]), _state(_decode_cache(1, 0.0))]
with pytest.raises(ValueError, match="mixed KV-cache phases"):
QwenImage21Pipeline._assemble_kv_cache(states)
with pytest.raises(ValueError, match="mixed KV-cache phases"):
QwenImage21Pipeline._assemble_kv_cache([_state(None), _state(_decode_cache(1, 0.0))])
def test_assemble_kv_cache_decode_rejects_mismatched_prefill_prompt_seq_len():
"""Homogeneous merge invariant: denoise_step must sub-batch before assembling."""
short = _state(_decode_cache(1, 1.0))
short.extra["prefill_prompt_seq_len"] = 4
long = _state(_decode_cache(1, 2.0))
long.extra["prefill_prompt_seq_len"] = 8
with pytest.raises(ValueError, match="sub-batch by prefill_prompt_seq_len"):
QwenImage21Pipeline._assemble_kv_cache([short, long])
# Same baked length remains batchable.
same_a = _state(_decode_cache(1, 1.0))
same_a.extra["prefill_prompt_seq_len"] = 4
same_b = _state(_decode_cache(1, 2.0))
same_b.extra["prefill_prompt_seq_len"] = 4
_, take_ownership = QwenImage21Pipeline._assemble_kv_cache([same_a, same_b])
assert take_ownership is False
def test_split_decode_groups_by_prefill_prompt_seq_len():
"""Routine serving: separate prefills → joint decode must split, not fail."""
short = _state(_decode_cache(1, 1.0))
short.request_id = "short"
short.extra["prefill_prompt_seq_len"] = 4
long = _state(_decode_cache(1, 2.0))
long.request_id = "long"
long.extra["prefill_prompt_seq_len"] = 8
short2 = _state(_decode_cache(1, 3.0))
short2.request_id = "short2"
short2.extra["prefill_prompt_seq_len"] = 4
states = [short, long, short2]
assert QwenImage21Pipeline._needs_decode_seq_len_split(states) is True
groups = QwenImage21Pipeline._split_decode_groups(states)
assert [[s.request_id for s in g] for g in groups] == [["short", "short2"], ["long"]]
same = [_state(_decode_cache(1, 1.0)), _state(_decode_cache(1, 2.0))]
for s in same:
s.extra["prefill_prompt_seq_len"] = 4
assert QwenImage21Pipeline._needs_decode_seq_len_split(same) is False
assert QwenImage21Pipeline._split_decode_groups(same) == [same]
def test_scatter_kv_cache_splits_rows_back_per_request():
states = [_state([{}]), _state([{}])]
merged = [
{
"cond": {
"key": torch.tensor([[1.0], [2.0], [3.0]]),
"value": torch.tensor([[4.0], [5.0], [6.0]]),
}
}
]
QwenImage21Pipeline._scatter_kv_cache(states, [1, 2], merged)
torch.testing.assert_close(states[0].extra["kv_cache"][0]["cond"]["key"], torch.tensor([[1.0]]))
torch.testing.assert_close(states[0].extra["kv_cache"][0]["cond"]["value"], torch.tensor([[4.0]]))
torch.testing.assert_close(states[1].extra["kv_cache"][0]["cond"]["key"], torch.tensor([[2.0], [3.0]]))
scattered = states[1].extra["kv_cache"][0]["cond"]["key"]
assert scattered.is_contiguous()
# Must own storage (clone), not a view into the merged prefill tensor —
# otherwise step-mode CUDA-graph decode never matches owner identity.
assert scattered.data_ptr() != merged[0]["cond"]["key"][1:3].data_ptr()
merged[0]["cond"]["key"].fill_(0.0)
torch.testing.assert_close(scattered, torch.tensor([[2.0], [3.0]]))
def test_encode_prompt_expands_masks_request_major():
"""Two prompts × n=2 must keep embeds and masks in A,A,B,B order.
A 2D ``mask.repeat(1, n, 1).view(B*n, S)`` would wrongly produce A,B,A,B.
"""
pipeline = _toy_pipeline()
embeds = torch.tensor([[[1.0], [1.0]], [[2.0], [0.0]]]) # [B=2, S=2, D=1]
mask = torch.tensor([[True, True], [True, False]])
image_pad = torch.tensor([[False, True], [False, False]])
def _fake_get(prompt, images_per_prompt, max_sequence_length=None, prompt_name="prompt"):
del prompt, images_per_prompt, max_sequence_length, prompt_name
return embeds.clone(), mask.clone(), image_pad.clone()
pipeline._get_qwen_prompt_embeds = _fake_get # type: ignore[method-assign]
out_embeds, out_mask, out_pad = pipeline.encode_prompt(
["short", "loooooonger"],
num_images_per_prompt=2,
)
assert out_embeds.shape[0] == 4
# embeds request-major: A,A,B,B
torch.testing.assert_close(out_embeds[:, 0, 0], torch.tensor([1.0, 1.0, 2.0, 2.0]))
# masks must follow the same order (not A,B,A,B)
assert out_mask.tolist() == [
[True, True],
[True, True],
[True, False],
[True, False],
]
assert out_pad.tolist() == [
[False, True],
[False, True],
[False, False],
[False, False],
]
def test_resolve_prompt_seq_len_keeps_prefill_padding_after_batch_shrink():
"""Longest-prompt cancel must not shrink the surviving request's KV layout."""
short = _state([{}])
short.extra["image_pad_mask"] = torch.tensor([[True, False]])
short.extra["prefill_prompt_seq_len"] = 4 # baked in when batched with a longer prompt
# Current InputBatch only sees the short prompt's native length.
assert QwenImage21Pipeline._resolve_prompt_seq_len([short], current_seq_len=2) == 4
padded = QwenImage21Pipeline._pad_rows_to_seq_len([short.extra["image_pad_mask"]], 4)
assert padded.tolist() == [[True, False, False, False]]
# ---------------------------------------------------------------------------
# post-process func
# ---------------------------------------------------------------------------
@pytest.fixture
def postprocess(tmp_path):
vae_dir = tmp_path / "vae"
vae_dir.mkdir()
(vae_dir / "config.json").write_text(json.dumps({"temperal_downsample": [False] * 4}))
return get_qwen_image_21_post_process_func(SimpleNamespace(model=str(tmp_path)))
def test_postprocess_tensor_returns_pil_images(postprocess):
from PIL import Image
images = torch.rand(2, 4, 16, 16) * 2 - 1
result = postprocess(images)
assert len(result) == 2
assert all(isinstance(image, Image.Image) for image in result)
def test_postprocess_envelope_unwraps_payload_and_keeps_metadata(postprocess):
from PIL import Image
envelope = {
"payload": {"image": torch.rand(1, 4, 16, 16), "seed": 42},
"metadata": {"request_id": "abc"},
}
result = postprocess(envelope)
assert result["metadata"] == {"request_id": "abc"}
assert result["payload"]["seed"] == 42
assert len(result["payload"]["image"]) == 1
assert isinstance(result["payload"]["image"][0], Image.Image)
def test_postprocess_envelope_requires_image_payload(postprocess):
with pytest.raises(ValueError, match=r"payload\['image'\]"):
postprocess({"payload": {"seed": 42}, "metadata": {}})
def test_release_captured_graphs_delegates_to_transformer() -> None:
pipeline = QwenImage21Pipeline.__new__(QwenImage21Pipeline)
released: list[bool] = []
pipeline.transformer = SimpleNamespace(release_captured_graphs=lambda: released.append(True))
pipeline.release_captured_graphs()
assert released == [True]
def test_postprocess_envelope_normalizes_non_dict_metadata(postprocess):
result = postprocess({"payload": {"image": torch.rand(1, 4, 16, 16)}, "metadata": "bogus"})
assert result["metadata"] == {}