# 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"] == {}