Signed-off-by: zwhzzz0821 <zwhzzz0821@users.noreply.github.com> Co-authored-by: zwhzzz0821 <zwhzzz0821@users.noreply.github.com> Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
448 lines
17 KiB
Python
448 lines
17 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
import torch
|
|
from transformers.utils.generic import ModelOutput
|
|
|
|
import vllm_omni.diffusion.models.hunyuan_image3.pipeline_hunyuan_image3 as pipeline_module
|
|
from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode
|
|
from vllm_omni.diffusion.diffusion_kv.request import DiffusionKVRequest
|
|
from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_image3_tokenizer import TokenizerEncodeOutput
|
|
from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_image3_transformer import (
|
|
ImageInfo,
|
|
JointImageInfo,
|
|
)
|
|
from vllm_omni.diffusion.models.hunyuan_image3.pipeline_hunyuan_image3 import (
|
|
HunyuanImage3Pipeline,
|
|
get_hunyuan_image_3_pre_process_func,
|
|
)
|
|
from vllm_omni.diffusion.models.hunyuan_image3.request_layout import (
|
|
HunyuanPreparedLayout,
|
|
build_hunyuan_diffusion_kv_requests,
|
|
extract_hunyuan_prompt_inputs,
|
|
hunyuan_num_image_tokens,
|
|
normalize_hunyuan_cot_text,
|
|
prepare_hunyuan_layout,
|
|
)
|
|
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
|
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
|
|
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
|
|
|
|
|
def test_dense_legacy_preprocess_does_not_initialize_layout_tokenizer(monkeypatch) -> None:
|
|
hf_config = SimpleNamespace(vae_downsample_factor=(8, 8), patch_size=2)
|
|
image_processor = SimpleNamespace(vision_encoder_processor=SimpleNamespace(patch_size=1))
|
|
monkeypatch.setattr(pipeline_module, "get_config", lambda *_args, **_kwargs: hf_config)
|
|
monkeypatch.setattr(pipeline_module, "HunyuanImage3ImageProcessor", lambda _config: image_processor)
|
|
monkeypatch.setattr(
|
|
pipeline_module,
|
|
"TokenizerWrapper",
|
|
lambda _model: pytest.fail("dense_legacy must not initialize the Diffusion KV tokenizer"),
|
|
)
|
|
|
|
get_hunyuan_image_3_pre_process_func(
|
|
SimpleNamespace(model="model", diffusion_kv_mode=DiffusionKVCacheMode.DENSE_LEGACY)
|
|
)
|
|
|
|
|
|
class _FakeTokenizerWrapper:
|
|
def __init__(self, *, prefix_lens: list[int]) -> None:
|
|
self.prefix_lens = prefix_lens
|
|
self.calls: list[dict] = []
|
|
self.pad_token_id = 0
|
|
self.eos_token_id = 2
|
|
self.boi_token_id = 3
|
|
self.end_recaption_token_id = 4
|
|
self.end_answer_token_id = 5
|
|
self.special_token_map = {f"<img_ratio_{index}>": 100 + index for index in range(33)}
|
|
|
|
def apply_chat_template(self, **kwargs):
|
|
self.calls.append(kwargs)
|
|
rows = len(self.prefix_lens)
|
|
has_cond_image = kwargs.get("batch_cond_image_info") is not None
|
|
seq_lens = [prefix_len + 20 for prefix_len in self.prefix_lens]
|
|
padded_seq_len = max(seq_lens)
|
|
positions = torch.arange(padded_seq_len)
|
|
joint_image_slices = [[slice(1, 3), slice(3, 5)] if has_cond_image else [] for _ in range(rows)]
|
|
gen_image_slices = [[slice(length + 1, length + 17)] for length in self.prefix_lens]
|
|
all_image_slices = [joint + generated for joint, generated in zip(joint_image_slices, gen_image_slices)]
|
|
cond_vae_image_mask = (
|
|
torch.stack([(positions >= 1) & (positions < 3) for _ in range(rows)]) if has_cond_image else None
|
|
)
|
|
cond_vit_image_mask = (
|
|
torch.stack([(positions >= 3) & (positions < 5) for _ in range(rows)]) if has_cond_image else None
|
|
)
|
|
sections = []
|
|
for _ in range(rows):
|
|
row_sections = []
|
|
if has_cond_image:
|
|
row_sections.append(
|
|
{
|
|
"type": "joint_image",
|
|
"token_height": [1, 1],
|
|
"token_width": [2, 2],
|
|
}
|
|
)
|
|
row_sections.append({"type": "gen_image", "token_height": 4, "token_width": 4})
|
|
sections.append(row_sections)
|
|
return {
|
|
"output": TokenizerEncodeOutput(
|
|
tokens=torch.arange(rows * padded_seq_len, dtype=torch.long).reshape(rows, padded_seq_len),
|
|
gen_timestep_scatter_index=torch.tensor(self.prefix_lens, dtype=torch.long).reshape(rows, 1),
|
|
cond_timestep_scatter_index=(torch.zeros(rows, 1, dtype=torch.long) if has_cond_image else None),
|
|
all_image_slices=all_image_slices,
|
|
gen_image_mask=torch.stack(
|
|
[(positions >= length + 1) & (positions < length + 17) for length in self.prefix_lens]
|
|
),
|
|
cond_vae_image_mask=cond_vae_image_mask,
|
|
cond_vit_image_mask=cond_vit_image_mask,
|
|
joint_image_slices=joint_image_slices,
|
|
gen_image_slices=gen_image_slices,
|
|
real_pos=torch.tensor(seq_lens, dtype=torch.long).reshape(rows, 1),
|
|
),
|
|
"sections": sections,
|
|
}
|
|
|
|
|
|
class _FakeImageProcessor:
|
|
def __init__(self, image_token_length: int = 16) -> None:
|
|
self.image_token_length = image_token_length
|
|
self.image_sizes: list[tuple[int, int]] = []
|
|
self.vision_encoder_processor = SimpleNamespace(patch_size=1)
|
|
|
|
def build_image_info(self, image_size):
|
|
self.image_sizes.append(image_size)
|
|
return ImageInfo(
|
|
image_type="gen_image",
|
|
image_width=image_size[1],
|
|
image_height=image_size[0],
|
|
token_width=4,
|
|
token_height=4,
|
|
image_token_length=self.image_token_length,
|
|
base_size=1024,
|
|
ratio_index=0,
|
|
)
|
|
|
|
|
|
def _components(prefix_lens: list[int]):
|
|
return _FakeTokenizerWrapper(prefix_lens=prefix_lens), _FakeImageProcessor()
|
|
|
|
|
|
def _prepare(
|
|
request: OmniDiffusionRequest,
|
|
tokenizer: _FakeTokenizerWrapper,
|
|
image_processor: _FakeImageProcessor,
|
|
) -> HunyuanPreparedLayout:
|
|
prepared_layout = prepare_hunyuan_layout(
|
|
request,
|
|
tokenizer_wrapper=tokenizer,
|
|
image_processor=image_processor,
|
|
generation_config=SimpleNamespace(sequence_template="instruct", drop_think=False),
|
|
image_base_size=1024,
|
|
)
|
|
request.prepared_layout = prepared_layout
|
|
return prepared_layout
|
|
|
|
|
|
def _request(
|
|
*,
|
|
guidance_scale: float,
|
|
prompt="draw a cat",
|
|
request_id: str = "req",
|
|
) -> OmniDiffusionRequest:
|
|
return OmniDiffusionRequest(
|
|
prompt=prompt,
|
|
sampling_params=OmniDiffusionSamplingParams(
|
|
height=768,
|
|
width=1024,
|
|
guidance_scale=guidance_scale,
|
|
num_inference_steps=4,
|
|
),
|
|
request_id=request_id,
|
|
)
|
|
|
|
|
|
def test_builds_kv_request_lengths_without_model_execution() -> None:
|
|
tokenizer, image_processor = _components([12])
|
|
request = _request(guidance_scale=1.0)
|
|
prepared_layout = _prepare(request, tokenizer, image_processor)
|
|
|
|
kv_requests = build_hunyuan_diffusion_kv_requests(request, prepared_layout)
|
|
|
|
assert isinstance(request.prepared_layout, HunyuanPreparedLayout)
|
|
assert all(isinstance(item, DiffusionKVRequest) for item in kv_requests)
|
|
assert [(item.prefix_len, item.target_len, item.seq_len) for item in kv_requests] == [(12, 17, 32)]
|
|
assert image_processor.image_sizes == [(768, 1024)]
|
|
assert tokenizer.calls[0]["sequence_template"] == "instruct"
|
|
assert tokenizer.calls[0]["cfg_factor"] == 1
|
|
assert kv_requests[0].block_hashes == []
|
|
assert kv_requests[0].kv_contexts == ()
|
|
assert kv_requests[0].skip_reading_prefix_cache is True
|
|
|
|
|
|
def test_builds_one_kv_request_per_cfg_row() -> None:
|
|
tokenizer, image_processor = _components([12, 14])
|
|
request = _request(guidance_scale=5.0)
|
|
prepared_layout = _prepare(request, tokenizer, image_processor)
|
|
|
|
kv_requests = build_hunyuan_diffusion_kv_requests(request, prepared_layout)
|
|
|
|
assert [item.sequence_id for item in kv_requests] == [0, 1]
|
|
assert [item.prefix_len for item in kv_requests] == [12, 14]
|
|
assert [item.seq_len for item in kv_requests] == [32, 34]
|
|
assert tokenizer.calls[0]["cfg_factor"] == 2
|
|
|
|
|
|
def _reference_image() -> JointImageInfo:
|
|
return JointImageInfo(
|
|
vae_image_info=ImageInfo(
|
|
image_type="vae",
|
|
token_width=8,
|
|
token_height=8,
|
|
image_token_length=64,
|
|
),
|
|
vision_image_info=ImageInfo(
|
|
image_type="siglip2",
|
|
token_width=4,
|
|
token_height=4,
|
|
image_token_length=16,
|
|
),
|
|
vision_encoder_kwargs={
|
|
"spatial_shapes": torch.tensor([4, 4]),
|
|
"pixel_attention_mask": torch.ones(4, dtype=torch.bool),
|
|
},
|
|
)
|
|
|
|
|
|
def test_passes_preprocessed_reference_image_geometry_to_tokenizer() -> None:
|
|
tokenizer, image_processor = _components([20])
|
|
joint_image = _reference_image()
|
|
prompt = {
|
|
"prompt": "edit this image",
|
|
"additional_information": {"batch_cond_image_info": [joint_image]},
|
|
}
|
|
request = _request(guidance_scale=1.0, prompt=prompt)
|
|
prepared_layout = _prepare(request, tokenizer, image_processor)
|
|
|
|
kv_requests = build_hunyuan_diffusion_kv_requests(request, prepared_layout)
|
|
|
|
assert tokenizer.calls[0]["batch_cond_image_info"] == [[joint_image]]
|
|
assert kv_requests[0].prefix_len == 20
|
|
assert kv_requests[0].target_len == 17
|
|
assert kv_requests[0].seq_len == 40
|
|
assert kv_requests[0].kv_contexts == ()
|
|
|
|
|
|
def _assert_nested_equal(actual, expected) -> None:
|
|
if isinstance(expected, torch.Tensor):
|
|
torch.testing.assert_close(actual, expected)
|
|
elif isinstance(expected, dict):
|
|
assert actual.keys() == expected.keys()
|
|
for key in expected:
|
|
_assert_nested_equal(actual[key], expected[key])
|
|
elif isinstance(expected, list | tuple):
|
|
assert isinstance(actual, type(expected))
|
|
assert len(actual) == len(expected)
|
|
for actual_item, expected_item in zip(actual, expected):
|
|
_assert_nested_equal(actual_item, expected_item)
|
|
else:
|
|
assert actual == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("prefix_lens", "guidance_scale", "with_reference_image"),
|
|
[
|
|
pytest.param([12], 1.0, False, id="cfg-1"),
|
|
pytest.param([12, 14], 5.0, False, id="cfg-2"),
|
|
pytest.param([20], 1.0, True, id="cfg-1-reference-image"),
|
|
pytest.param([20, 22], 5.0, True, id="cfg-2-reference-image"),
|
|
],
|
|
)
|
|
def test_prepared_model_inputs_match_local_tokenization(
|
|
monkeypatch,
|
|
prefix_lens: list[int],
|
|
guidance_scale: float,
|
|
with_reference_image: bool,
|
|
) -> None:
|
|
tokenizer, image_processor = _components(prefix_lens)
|
|
prompt: str | dict = "draw a cat"
|
|
if with_reference_image:
|
|
prompt = {
|
|
"prompt": "edit this image",
|
|
"additional_information": {"batch_cond_image_info": [_reference_image()]},
|
|
}
|
|
request = _request(guidance_scale=guidance_scale, prompt=prompt)
|
|
prepared_layout = _prepare(request, tokenizer, image_processor)
|
|
|
|
prompts, cot_texts, system_prompt, batch_cond_image_info, bot_task = extract_hunyuan_prompt_inputs(
|
|
[request.prompt],
|
|
request.sampling_params.extra_args or {},
|
|
request_id=request.request_id,
|
|
allow_cond_image=True,
|
|
)
|
|
cot_text = (
|
|
[normalize_hunyuan_cot_text(text) for text in cot_texts]
|
|
if any(text is not None for text in cot_texts)
|
|
else None
|
|
)
|
|
|
|
pipeline = object.__new__(HunyuanImage3Pipeline)
|
|
pipeline._tkwrapper = tokenizer
|
|
pipeline.image_processor = image_processor
|
|
pipeline.generation_config = SimpleNamespace(sequence_template="instruct", drop_think=False)
|
|
pipeline.config = SimpleNamespace(
|
|
image_base_size=1024,
|
|
attention_head_dim=2,
|
|
rope_theta=10000.0,
|
|
)
|
|
monkeypatch.setattr(HunyuanImage3Pipeline, "device", property(lambda _self: torch.device("cpu")))
|
|
|
|
def fake_encode_cond_image(batch_cond_image_info_list, cfg_factor=1, generator=None):
|
|
del generator
|
|
rows = len(batch_cond_image_info_list) * cfg_factor
|
|
return (
|
|
torch.arange(rows * 4, dtype=torch.float32).reshape(rows, 1, 2, 2),
|
|
torch.arange(rows, dtype=torch.float32),
|
|
torch.arange(rows * 3, dtype=torch.float32).reshape(rows, 3),
|
|
)
|
|
|
|
pipeline._encode_cond_image = fake_encode_cond_image
|
|
|
|
def fake_build_batch_2d_rope(*, image_infos, seq_len, **_kwargs):
|
|
geometry_sum = sum(
|
|
int(image_slice.start or 0) + int(image_slice.stop or 0) + height + width
|
|
for row in image_infos
|
|
for image_slice, (height, width) in row
|
|
)
|
|
values = torch.tensor([len(image_infos), seq_len, geometry_sum], dtype=torch.float32)
|
|
return values, values + 1
|
|
|
|
monkeypatch.setattr(pipeline_module, "build_batch_2d_rope", fake_build_batch_2d_rope)
|
|
common_kwargs = dict(
|
|
prompt=prompts,
|
|
cot_text=cot_text,
|
|
system_prompt=system_prompt,
|
|
mode="gen_image",
|
|
guidance_scale=guidance_scale,
|
|
image_size=(request.sampling_params.height, request.sampling_params.width),
|
|
generator=[torch.Generator().manual_seed(0)],
|
|
batch_cond_image_info=batch_cond_image_info,
|
|
bot_task=bot_task,
|
|
)
|
|
|
|
local_inputs = pipeline.prepare_model_inputs(**common_kwargs)
|
|
prepared_inputs = pipeline.prepare_model_inputs(**common_kwargs, prepared_layout=prepared_layout)
|
|
|
|
comparable_fields = (
|
|
"input_ids",
|
|
"position_ids",
|
|
"custom_pos_emb",
|
|
"image_mask",
|
|
"gen_timestep_scatter_index",
|
|
"cond_vae_images",
|
|
"cond_timestep",
|
|
"cond_vae_image_mask",
|
|
"cond_vit_images",
|
|
"cond_vit_image_mask",
|
|
"vit_kwargs",
|
|
"cond_timestep_scatter_index",
|
|
)
|
|
for field in comparable_fields:
|
|
_assert_nested_equal(prepared_inputs[field], local_inputs[field])
|
|
|
|
tokenizer_fields = (
|
|
"tokens",
|
|
"gen_image_mask",
|
|
"cond_vae_image_mask",
|
|
"cond_vit_image_mask",
|
|
"gen_timestep_scatter_index",
|
|
"cond_timestep_scatter_index",
|
|
"real_pos",
|
|
"all_image_slices",
|
|
"joint_image_slices",
|
|
"gen_image_slices",
|
|
)
|
|
for field in tokenizer_fields:
|
|
_assert_nested_equal(
|
|
getattr(prepared_inputs["tokenizer_output"], field),
|
|
getattr(local_inputs["tokenizer_output"], field),
|
|
)
|
|
|
|
local_mask = pipeline._prepare_attention_mask_for_generation(
|
|
local_inputs["input_ids"],
|
|
pipeline.generation_config,
|
|
local_inputs,
|
|
)
|
|
prepared_mask = pipeline._prepare_attention_mask_for_generation(
|
|
prepared_inputs["input_ids"],
|
|
pipeline.generation_config,
|
|
prepared_inputs,
|
|
)
|
|
torch.testing.assert_close(prepared_mask, local_mask)
|
|
assert prepared_inputs["full_attn_spans"] == local_inputs["full_attn_spans"]
|
|
|
|
num_image_tokens = hunyuan_num_image_tokens(prepared_layout.generated_image_info)
|
|
local_inputs.update(attention_mask=local_mask, num_image_tokens=num_image_tokens)
|
|
prepared_inputs.update(attention_mask=prepared_mask, num_image_tokens=num_image_tokens)
|
|
local_step_inputs = pipeline._update_model_kwargs_for_generation(ModelOutput(), local_inputs)
|
|
prepared_step_inputs = pipeline._update_model_kwargs_for_generation(ModelOutput(), prepared_inputs)
|
|
|
|
for field in ("position_ids", "attention_mask", "gen_timestep_scatter_index", "full_attn_spans"):
|
|
_assert_nested_equal(prepared_step_inputs[field], local_step_inputs[field])
|
|
|
|
|
|
def test_rejects_tokenizer_cfg_row_mismatch() -> None:
|
|
tokenizer, image_processor = _components([12])
|
|
request = _request(guidance_scale=5.0)
|
|
prepared_layout = _prepare(request, tokenizer, image_processor)
|
|
|
|
with pytest.raises(ValueError, match="sequence count does not match"):
|
|
build_hunyuan_diffusion_kv_requests(request, prepared_layout)
|
|
|
|
|
|
def test_sender_endpoint_does_not_change_local_kv_request() -> None:
|
|
tokenizer, image_processor = _components([12])
|
|
request = _request(guidance_scale=1.0)
|
|
request.kv_sender_info = {"host": "127.0.0.1", "zmq_port": 5000}
|
|
prepared_layout = _prepare(request, tokenizer, image_processor)
|
|
|
|
kv_requests = build_hunyuan_diffusion_kv_requests(request, prepared_layout)
|
|
|
|
assert kv_requests[0].seq_len == 32
|
|
assert kv_requests[0].num_computed_tokens == 0
|
|
|
|
|
|
def test_paged_preprocess_attaches_layout_and_scheduler_kv_requests(monkeypatch) -> None:
|
|
tokenizer, image_processor = _components([12])
|
|
hf_config = SimpleNamespace(
|
|
vae_downsample_factor=(8, 8),
|
|
patch_size=2,
|
|
image_base_size=1024,
|
|
)
|
|
monkeypatch.setattr(pipeline_module, "get_config", lambda *_args, **_kwargs: hf_config)
|
|
monkeypatch.setattr(pipeline_module, "HunyuanImage3ImageProcessor", lambda _config: image_processor)
|
|
monkeypatch.setattr(pipeline_module, "TokenizerWrapper", lambda _model: tokenizer)
|
|
monkeypatch.setattr(
|
|
pipeline_module.GenerationConfig,
|
|
"from_pretrained",
|
|
lambda _model: SimpleNamespace(sequence_template="instruct", drop_think=False),
|
|
)
|
|
preprocess = get_hunyuan_image_3_pre_process_func(
|
|
SimpleNamespace(model="model", diffusion_kv_mode=DiffusionKVCacheMode.PAGED_SCHEDULER)
|
|
)
|
|
request = _request(guidance_scale=1.0)
|
|
|
|
prepared_request = preprocess(request)
|
|
|
|
assert prepared_request is request
|
|
assert isinstance(request.prepared_layout, HunyuanPreparedLayout)
|
|
assert request.diffusion_kv_requests is not None
|
|
assert [(item.prefix_len, item.target_len, item.seq_len) for item in request.diffusion_kv_requests] == [
|
|
(12, 17, 32)
|
|
]
|
|
assert len(tokenizer.calls) == 1
|