Signed-off-by: nagisa-kun <1434936049@qq.com> Signed-off-by: nagisa <1434936049@qq.com> Signed-off-by: hurukawa <61525444+nagisa-kunhah@users.noreply.github.com>
1637 lines
56 KiB
Python
1637 lines
56 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
|
|
import json
|
|
import logging
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
import torch
|
|
import torch.nn.functional as F
|
|
from PIL import Image
|
|
|
|
from vllm_omni.diffusion.attention.layer import Attention
|
|
from vllm_omni.diffusion.config import set_current_diffusion_config
|
|
from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec
|
|
from vllm_omni.diffusion.model_metadata import get_diffusion_model_metadata
|
|
from vllm_omni.diffusion.models.joy_image import (
|
|
pipeline_joy_image_edit as joy_pipeline_module,
|
|
)
|
|
from vllm_omni.diffusion.models.joy_image.cfg_parallel import JoyImageEditCFGParallelMixin
|
|
from vllm_omni.diffusion.models.joy_image.joy_image_edit_transformer import (
|
|
JoyImageAttention,
|
|
JoyImageEditTransformer3DModel,
|
|
_raise_if_unsupported_tensor_parallel,
|
|
)
|
|
from vllm_omni.diffusion.models.joy_image.pipeline_joy_image_edit import (
|
|
JOY_MAX_IMAGE_SEQ_LEN,
|
|
JOY_PROMPT_TEMPLATE,
|
|
JOY_VISION_TOKEN,
|
|
JoyImageEditPipeline,
|
|
_cast_floating_model_inputs,
|
|
_format_qwen_multimodal_prompt,
|
|
_get_transformer_config_kwargs_from_od_config,
|
|
_raise_if_unsupported_hsdp,
|
|
_resize_center_crop,
|
|
_should_defer_component_device_placement,
|
|
_uses_model_level_cpu_offload,
|
|
get_joy_image_edit_pre_process_func,
|
|
retrieve_timesteps,
|
|
)
|
|
from vllm_omni.diffusion.offloader.base import OffloadConfig, OffloadStrategy
|
|
from vllm_omni.diffusion.offloader.layerwise_backend import LayerWiseOffloadBackend
|
|
from vllm_omni.diffusion.offloader.module_collector import ModuleDiscovery
|
|
from vllm_omni.diffusion.request import DUMMY_DIFFUSION_REQUEST_ID, OmniDiffusionRequest
|
|
|
|
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
|
|
|
|
|
def _torch_sdpa_diffusion_config():
|
|
return SimpleNamespace(
|
|
diffusion_attention_config=AttentionConfig(default=AttentionSpec(backend="TORCH_SDPA")),
|
|
parallel_config=SimpleNamespace(ring_degree=1),
|
|
diffusion_kv_cache_dtype=None,
|
|
diffusion_kv_cache_skip_step_indices=None,
|
|
diffusion_kv_cache_skip_layer_indices=None,
|
|
)
|
|
|
|
|
|
def _make_joy_attention(*, dtype: torch.dtype = torch.float32) -> JoyImageAttention:
|
|
with set_current_diffusion_config(_torch_sdpa_diffusion_config()):
|
|
attention = JoyImageAttention(
|
|
dim=32,
|
|
num_attention_heads=4,
|
|
attention_head_dim=8,
|
|
prefix="double_blocks.0.attn",
|
|
)
|
|
return attention.to(dtype=dtype)
|
|
|
|
|
|
def _reference_joy_attention_output(
|
|
attention: JoyImageAttention,
|
|
hidden_states: torch.Tensor,
|
|
encoder_hidden_states: torch.Tensor,
|
|
attention_mask: torch.Tensor | None = None,
|
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
img_query, img_key, img_value = attention._stream_qkv(
|
|
attention.img_attn_qkv,
|
|
attention.img_attn_q_norm,
|
|
attention.img_attn_k_norm,
|
|
hidden_states,
|
|
)
|
|
txt_query, txt_key, txt_value = attention._stream_qkv(
|
|
attention.txt_attn_qkv,
|
|
attention.txt_attn_q_norm,
|
|
attention.txt_attn_k_norm,
|
|
encoder_hidden_states,
|
|
)
|
|
joint_query = torch.cat([img_query, txt_query], dim=1)
|
|
joint_key = torch.cat([img_key, txt_key], dim=1)
|
|
joint_value = torch.cat([img_value, txt_value], dim=1)
|
|
|
|
if attention_mask is not None and attention_mask.ndim == 2:
|
|
attention_mask = attention_mask.to(torch.bool)[:, None, None, :]
|
|
|
|
joint_hidden_states = F.scaled_dot_product_attention(
|
|
joint_query.transpose(1, 2),
|
|
joint_key.transpose(1, 2),
|
|
joint_value.transpose(1, 2),
|
|
attn_mask=attention_mask,
|
|
dropout_p=0.0,
|
|
is_causal=False,
|
|
scale=1.0 / (attention.head_dim**0.5),
|
|
)
|
|
joint_hidden_states = joint_hidden_states.transpose(1, 2).flatten(2, 3).to(joint_query.dtype)
|
|
|
|
image_seq_len = hidden_states.shape[1]
|
|
return (
|
|
attention.img_attn_proj(joint_hidden_states[:, :image_seq_len]),
|
|
attention.txt_attn_proj(joint_hidden_states[:, image_seq_len:]),
|
|
)
|
|
|
|
|
|
def _assert_attention_outputs_close(
|
|
actual: tuple[torch.Tensor, torch.Tensor],
|
|
expected: tuple[torch.Tensor, torch.Tensor],
|
|
*,
|
|
dtype: torch.dtype,
|
|
) -> None:
|
|
atol = rtol = 5e-3 if dtype is torch.bfloat16 else 1e-5
|
|
max_abs = max(
|
|
(actual_item - expected_item).abs().max().item() for actual_item, expected_item in zip(actual, expected)
|
|
)
|
|
assert max_abs <= atol
|
|
for actual_item, expected_item in zip(actual, expected):
|
|
torch.testing.assert_close(actual_item, expected_item, atol=atol, rtol=rtol)
|
|
|
|
|
|
def _write_model_configs(tmp_path):
|
|
(tmp_path / "vae").mkdir()
|
|
(tmp_path / "transformer").mkdir()
|
|
(tmp_path / "vae" / "config.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"scale_factor_spatial": 8,
|
|
"z_dim": 16,
|
|
"latents_mean": [0.0] * 16,
|
|
"latents_std": [1.0] * 16,
|
|
}
|
|
)
|
|
)
|
|
(tmp_path / "transformer" / "config.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"patch_size": [1, 2, 2],
|
|
"hidden_size": 32,
|
|
"num_attention_heads": 4,
|
|
"num_layers": 1,
|
|
"in_channels": 4,
|
|
"out_channels": 4,
|
|
"text_dim": 16,
|
|
}
|
|
)
|
|
)
|
|
return SimpleNamespace(
|
|
model=str(tmp_path),
|
|
model_class_name="JoyImageEditPipeline",
|
|
)
|
|
|
|
|
|
def _make_params(**overrides):
|
|
guidance_scale = overrides.pop("guidance_scale", None)
|
|
values = {
|
|
"height": None,
|
|
"width": None,
|
|
"guidance_scale": guidance_scale if guidance_scale is not None else 1.0,
|
|
"guidance_scale_provided": guidance_scale is not None,
|
|
"true_cfg_scale": None,
|
|
"num_outputs_per_prompt": 1,
|
|
"num_inference_steps": None,
|
|
"timesteps": None,
|
|
"sigmas": None,
|
|
"max_sequence_length": None,
|
|
"latents": None,
|
|
"output_type": None,
|
|
"generator": None,
|
|
"seed": None,
|
|
"cfg_normalize": False,
|
|
"guidance_scale_2": None,
|
|
"do_classifier_free_guidance": False,
|
|
}
|
|
values.update(overrides)
|
|
return SimpleNamespace(**values)
|
|
|
|
|
|
def _make_request(*, prompt=None, params=None):
|
|
prompt = prompt or {
|
|
"prompt": "make it brighter",
|
|
"multi_modal_data": {"image": Image.new("RGB", (2048, 1024), color="white")},
|
|
}
|
|
params = params or _make_params()
|
|
return SimpleNamespace(
|
|
prompts=[prompt],
|
|
sampling_params=params,
|
|
request_id="joy-test",
|
|
is_dummy_run=lambda: False,
|
|
)
|
|
|
|
|
|
def _make_single_omni_request(*, prompt=None, params=None):
|
|
prompt = prompt or {
|
|
"prompt": "make it brighter",
|
|
"multi_modal_data": {"image": Image.new("RGB", (2048, 1024), color="white")},
|
|
}
|
|
params = params or _make_params()
|
|
return OmniDiffusionRequest(
|
|
prompt=prompt,
|
|
sampling_params=params,
|
|
request_id="joy-test",
|
|
)
|
|
|
|
|
|
class _TinyJoyLayerwisePipeline(torch.nn.Module):
|
|
_dit_modules = ["transformer"]
|
|
_encoder_modules: list[str] = []
|
|
_vae_modules: list[str] = []
|
|
_resident_modules: list[str] = []
|
|
_offload_transformer_if_model_level = JoyImageEditPipeline._offload_transformer_if_model_level
|
|
|
|
def __init__(self, *, device: torch.device, cleanup_mode: str):
|
|
super().__init__()
|
|
self.device = device
|
|
self.scheduler = SimpleNamespace(
|
|
set_timesteps=lambda num_inference_steps, device: setattr(
|
|
self.scheduler,
|
|
"timesteps",
|
|
torch.tensor([1.0], device=device),
|
|
)
|
|
)
|
|
self.latent_channels = 4
|
|
self.od_config = SimpleNamespace(
|
|
enable_cpu_offload=False,
|
|
enable_layerwise_offload=True,
|
|
pin_cpu_memory=False,
|
|
parallel_config=SimpleNamespace(use_hsdp=False),
|
|
)
|
|
self.transformer = JoyImageEditTransformer3DModel(
|
|
in_channels=4,
|
|
out_channels=4,
|
|
hidden_size=32,
|
|
text_dim=16,
|
|
num_layers=2,
|
|
num_attention_heads=4,
|
|
patch_size=(1, 2, 2),
|
|
).to(device=device, dtype=torch.bfloat16)
|
|
self.vae = torch.nn.Linear(1, 1)
|
|
self.cleanup_mode = cleanup_mode
|
|
|
|
def encode_prompt(self, *args, **kwargs):
|
|
return (
|
|
torch.zeros(1, 5, 16, device=self.device, dtype=torch.bfloat16),
|
|
torch.ones(1, 5, dtype=torch.long, device=self.device),
|
|
)
|
|
|
|
def resolve_effective_true_cfg_scale(self, req, default_true_cfg_scale=4.0):
|
|
return JoyImageEditPipeline.resolve_effective_true_cfg_scale(
|
|
req,
|
|
default_true_cfg_scale=default_true_cfg_scale,
|
|
)
|
|
|
|
def _prepare_latents(self, **kwargs):
|
|
latents = torch.randn(1, 2, 4, 1, 4, 4, device=self.device, dtype=torch.bfloat16)
|
|
return latents, latents[:, :1].clone()
|
|
|
|
def diffuse(self, **kwargs):
|
|
return self.transformer(
|
|
hidden_states=kwargs["latents"],
|
|
timestep=kwargs["timesteps"][0],
|
|
encoder_hidden_states=kwargs["prompt_embeds"],
|
|
encoder_hidden_states_mask=kwargs["prompt_embeds_mask"],
|
|
return_dict=False,
|
|
)[0]
|
|
|
|
def _decode_latents(self, latents):
|
|
JoyImageEditPipeline._prepare_vae_for_decode(self)
|
|
return latents[:, -1, :, 0]
|
|
|
|
|
|
def _make_layerwise_forward_request(*, dummy: bool = False):
|
|
request = _make_request(
|
|
prompt={
|
|
"prompt": "make it brighter",
|
|
"additional_information": {
|
|
"image_tensor": torch.zeros(1, 3, 1, 16, 16),
|
|
"prompt_image": Image.new("RGB", (16, 16)),
|
|
"height": 16,
|
|
"width": 16,
|
|
},
|
|
},
|
|
params=_make_params(guidance_scale=1.0, num_inference_steps=1),
|
|
)
|
|
request.is_dummy_run = lambda: dummy
|
|
return request
|
|
|
|
|
|
def _run_tiny_joy_layerwise_sequence(first_mode: str):
|
|
device = torch.device("cuda:0")
|
|
pipeline = _TinyJoyLayerwisePipeline(device=device, cleanup_mode=first_mode)
|
|
backend = LayerWiseOffloadBackend(
|
|
OffloadConfig(strategy=OffloadStrategy.LAYER_WISE, pin_cpu_memory=False),
|
|
device,
|
|
)
|
|
backend.enable(pipeline)
|
|
try:
|
|
if first_mode == "dummy":
|
|
first_request = _make_layerwise_forward_request(dummy=True)
|
|
first_output_type = "pil"
|
|
elif first_mode == "latent":
|
|
first_request = _make_layerwise_forward_request(dummy=False)
|
|
first_output_type = "latent"
|
|
elif first_mode == "decoded":
|
|
first_request = _make_layerwise_forward_request(dummy=False)
|
|
first_output_type = "pil"
|
|
else:
|
|
raise AssertionError(f"unexpected first mode {first_mode!r}")
|
|
|
|
with torch.inference_mode():
|
|
JoyImageEditPipeline.forward(pipeline, first_request, output_type=first_output_type)
|
|
|
|
# These non-block modules execute before double_blocks[0], so layerwise
|
|
# hooks cannot repair them lazily after request cleanup.
|
|
assert next(pipeline.transformer.img_in.parameters()).device == device
|
|
assert next(pipeline.transformer.condition_embedder.parameters()).device == device
|
|
assert next(pipeline.transformer.proj_out.parameters()).device == device
|
|
|
|
second_request = _make_layerwise_forward_request(dummy=False)
|
|
with torch.inference_mode():
|
|
output = JoyImageEditPipeline.forward(pipeline, second_request, output_type="pil")
|
|
finally:
|
|
backend.disable()
|
|
torch.accelerator.empty_cache()
|
|
|
|
assert output.output.shape == (1, 4, 4, 4)
|
|
|
|
|
|
def test_joy_registry_and_metadata_entries(tmp_path):
|
|
from vllm_omni.diffusion.registry import (
|
|
DiffusionModelRegistry,
|
|
get_diffusion_post_process_func,
|
|
get_diffusion_pre_process_func,
|
|
)
|
|
|
|
od_config = _write_model_configs(tmp_path)
|
|
model_class = DiffusionModelRegistry._try_load_model_cls("JoyImageEditPipeline")
|
|
assert model_class is JoyImageEditPipeline
|
|
assert callable(get_diffusion_pre_process_func(od_config))
|
|
postprocess = get_diffusion_post_process_func(od_config)
|
|
assert callable(postprocess)
|
|
assert len(postprocess(torch.zeros(1, 3, 2, 2))) == 1
|
|
|
|
metadata = get_diffusion_model_metadata("JoyImageEditPipeline")
|
|
assert metadata.supports_multimodal_inputs is True
|
|
assert metadata.max_multimodal_image_inputs == 1
|
|
|
|
|
|
def test_get_model_path_downloads_only_runtime_configs(monkeypatch):
|
|
captured = {}
|
|
|
|
def fake_download(model_name, revision, allow_patterns, **kwargs):
|
|
captured.update(
|
|
{
|
|
"model_name": model_name,
|
|
"revision": revision,
|
|
"allow_patterns": allow_patterns,
|
|
"kwargs": kwargs,
|
|
}
|
|
)
|
|
return "/tmp/joy-configs"
|
|
|
|
monkeypatch.setattr(
|
|
joy_pipeline_module,
|
|
"download_weights_from_hf_specific",
|
|
fake_download,
|
|
)
|
|
|
|
assert joy_pipeline_module._get_model_path("repo/joy") == "/tmp/joy-configs"
|
|
assert captured == {
|
|
"model_name": "repo/joy",
|
|
"revision": None,
|
|
"allow_patterns": ["vae/config.json", "transformer/config.json"],
|
|
"kwargs": {"require_all": True},
|
|
}
|
|
|
|
|
|
def test_transformer_config_kwargs_from_od_config_filters_metadata():
|
|
class FakeTransformerConfig:
|
|
def to_dict(self):
|
|
return {
|
|
"_class_name": "JoyImageEditTransformer3DModel",
|
|
"_diffusers_version": "0.38.0",
|
|
"hidden_size": 32,
|
|
"num_layers": 1,
|
|
"num_attention_heads": 4,
|
|
}
|
|
|
|
od_config = SimpleNamespace(tf_model_config=FakeTransformerConfig())
|
|
|
|
assert _get_transformer_config_kwargs_from_od_config(od_config) == {
|
|
"hidden_size": 32,
|
|
"num_layers": 1,
|
|
"num_attention_heads": 4,
|
|
}
|
|
|
|
|
|
def test_defer_component_device_placement_for_offload_only():
|
|
assert (
|
|
_should_defer_component_device_placement(
|
|
SimpleNamespace(
|
|
enable_cpu_offload=False,
|
|
enable_layerwise_offload=False,
|
|
parallel_config=SimpleNamespace(use_hsdp=False),
|
|
)
|
|
)
|
|
is False
|
|
)
|
|
assert _should_defer_component_device_placement(SimpleNamespace(enable_cpu_offload=True)) is True
|
|
assert _should_defer_component_device_placement(SimpleNamespace(enable_layerwise_offload=True)) is True
|
|
assert (
|
|
_should_defer_component_device_placement(SimpleNamespace(parallel_config=SimpleNamespace(use_hsdp=True)))
|
|
is False
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("enable_cpu_offload", "enable_layerwise_offload", "expected"),
|
|
[
|
|
(True, False, True),
|
|
(False, True, False),
|
|
(True, True, False),
|
|
(False, False, False),
|
|
],
|
|
)
|
|
def test_model_level_cpu_offload_predicate_matches_effective_strategy(
|
|
enable_cpu_offload,
|
|
enable_layerwise_offload,
|
|
expected,
|
|
):
|
|
od_config = SimpleNamespace(
|
|
enable_cpu_offload=enable_cpu_offload,
|
|
enable_layerwise_offload=enable_layerwise_offload,
|
|
)
|
|
|
|
assert _uses_model_level_cpu_offload(od_config) is expected
|
|
|
|
|
|
def test_model_level_cpu_offload_cleanup_still_moves_transformer(monkeypatch):
|
|
pipeline = object.__new__(JoyImageEditPipeline)
|
|
torch.nn.Module.__init__(pipeline)
|
|
pipeline.od_config = SimpleNamespace(
|
|
enable_cpu_offload=True,
|
|
enable_layerwise_offload=False,
|
|
pin_cpu_memory=False,
|
|
)
|
|
pipeline.transformer = torch.nn.Linear(1, 1)
|
|
move_calls = []
|
|
|
|
def fake_move_params(module, target_device, **kwargs):
|
|
move_calls.append((module, target_device, kwargs))
|
|
|
|
monkeypatch.setattr(
|
|
joy_pipeline_module.SequentialOffloadHook,
|
|
"_move_params",
|
|
fake_move_params,
|
|
)
|
|
monkeypatch.setattr(joy_pipeline_module.current_omni_platform, "empty_cache", lambda: None)
|
|
|
|
JoyImageEditPipeline._offload_transformer_if_model_level(pipeline)
|
|
|
|
assert move_calls == [
|
|
(
|
|
pipeline.transformer,
|
|
torch.device("cpu"),
|
|
{"non_blocking": False, "pin_memory": False},
|
|
)
|
|
]
|
|
|
|
|
|
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
|
@pytest.mark.parametrize("first_mode", ["dummy", "decoded", "latent"])
|
|
def test_layerwise_joy_lifecycle_survives_second_forward(first_mode):
|
|
_run_tiny_joy_layerwise_sequence(first_mode)
|
|
|
|
|
|
def test_joy_hsdp_is_explicitly_unsupported():
|
|
with pytest.raises(ValueError, match="does not support HSDP"):
|
|
_raise_if_unsupported_hsdp(SimpleNamespace(parallel_config=SimpleNamespace(use_hsdp=True)))
|
|
|
|
_raise_if_unsupported_hsdp(SimpleNamespace(parallel_config=SimpleNamespace(use_hsdp=False)))
|
|
_raise_if_unsupported_hsdp(SimpleNamespace())
|
|
|
|
|
|
def test_joy_tensor_parallelism_is_explicitly_unsupported():
|
|
with pytest.raises(ValueError, match="do not support Tensor Parallelism"):
|
|
_raise_if_unsupported_tensor_parallel(SimpleNamespace(parallel_config=SimpleNamespace(tensor_parallel_size=2)))
|
|
|
|
_raise_if_unsupported_tensor_parallel(SimpleNamespace(parallel_config=SimpleNamespace(tensor_parallel_size=1)))
|
|
_raise_if_unsupported_tensor_parallel(SimpleNamespace(parallel_config=SimpleNamespace()))
|
|
_raise_if_unsupported_tensor_parallel(SimpleNamespace())
|
|
_raise_if_unsupported_tensor_parallel(None)
|
|
|
|
|
|
def test_transformer_rejects_tensor_parallelism_before_building_layers():
|
|
with pytest.raises(ValueError, match="do not support Tensor Parallelism"):
|
|
JoyImageEditTransformer3DModel(
|
|
od_config=SimpleNamespace(parallel_config=SimpleNamespace(tensor_parallel_size=2)),
|
|
in_channels=4,
|
|
out_channels=4,
|
|
hidden_size=32,
|
|
text_dim=16,
|
|
num_layers=1,
|
|
num_attention_heads=4,
|
|
patch_size=(1, 2, 2),
|
|
)
|
|
|
|
|
|
def test_pipeline_rejects_tensor_parallelism_before_loading_components():
|
|
with pytest.raises(ValueError, match="do not support Tensor Parallelism"):
|
|
JoyImageEditPipeline(
|
|
od_config=SimpleNamespace(parallel_config=SimpleNamespace(use_hsdp=False, tensor_parallel_size=2))
|
|
)
|
|
|
|
|
|
def test_component_discovery_treats_vae_as_offload_peer():
|
|
pipeline = object.__new__(JoyImageEditPipeline)
|
|
torch.nn.Module.__init__(pipeline)
|
|
pipeline.transformer = torch.nn.Linear(1, 1)
|
|
pipeline.text_encoder = torch.nn.Linear(1, 1)
|
|
pipeline.vae = torch.nn.Linear(1, 1)
|
|
|
|
modules = ModuleDiscovery.discover(pipeline)
|
|
|
|
assert modules.dit_names == ["transformer"]
|
|
assert modules.encoder_names == ["text_encoder", "vae"]
|
|
assert modules.vaes == []
|
|
|
|
|
|
def test_preprocess_requires_exactly_one_image(tmp_path):
|
|
od_config = _write_model_configs(tmp_path)
|
|
preprocess = get_joy_image_edit_pre_process_func(od_config)
|
|
no_image = _make_request(prompt={"prompt": "x", "multi_modal_data": {}})
|
|
with pytest.raises(ValueError, match="exactly one input image"):
|
|
preprocess(no_image)
|
|
|
|
multi_image = _make_request(
|
|
prompt={
|
|
"prompt": "x",
|
|
"multi_modal_data": {"image": [Image.new("RGB", (32, 32)), Image.new("RGB", (32, 32))]},
|
|
}
|
|
)
|
|
with pytest.raises(ValueError, match="exactly one image"):
|
|
preprocess(multi_image)
|
|
|
|
|
|
def test_preprocess_rejects_batched_prompts_and_partial_size(tmp_path):
|
|
od_config = _write_model_configs(tmp_path)
|
|
preprocess = get_joy_image_edit_pre_process_func(od_config)
|
|
batched = _make_request()
|
|
batched.prompts.append(batched.prompts[0])
|
|
with pytest.raises(ValueError, match="exactly one prompt"):
|
|
preprocess(batched)
|
|
|
|
partial_size = _make_request(params=_make_params(height=512, width=None))
|
|
with pytest.raises(ValueError, match="both `height` and `width`"):
|
|
preprocess(partial_size)
|
|
|
|
|
|
def test_preprocess_uses_diffusers_bucket_for_default_image_size(tmp_path):
|
|
od_config = _write_model_configs(tmp_path)
|
|
preprocess = get_joy_image_edit_pre_process_func(od_config)
|
|
|
|
request = preprocess(_make_request())
|
|
prompt = request.prompts[0]
|
|
info = prompt["additional_information"]
|
|
height = info["height"]
|
|
width = info["width"]
|
|
|
|
assert (height, width) == (704, 1408)
|
|
assert (height // 16) * (width // 16) <= JOY_MAX_IMAGE_SEQ_LEN
|
|
assert info["image_tensor"].shape == (1, 3, 1, height, width)
|
|
assert info["original_size"] == (2048, 1024)
|
|
assert info["resized_size"] == (width, height)
|
|
assert request.sampling_params.height == height
|
|
assert request.sampling_params.width == width
|
|
|
|
|
|
def test_preprocess_accepts_single_omni_request_prompt(tmp_path):
|
|
od_config = _write_model_configs(tmp_path)
|
|
preprocess = get_joy_image_edit_pre_process_func(od_config)
|
|
|
|
request = preprocess(_make_single_omni_request())
|
|
prompt = request.prompt
|
|
info = prompt["additional_information"]
|
|
|
|
assert isinstance(prompt, dict)
|
|
assert not hasattr(request, "prompts")
|
|
assert info["image_tensor"].shape == (1, 3, 1, 704, 1408)
|
|
assert request.sampling_params.height == 704
|
|
assert request.sampling_params.width == 1408
|
|
|
|
|
|
def test_resize_center_crop_crops_instead_of_stretching():
|
|
image = Image.new("RGB", (4, 2))
|
|
pixels = image.load()
|
|
colors = [
|
|
(255, 0, 0),
|
|
(0, 255, 0),
|
|
(0, 0, 255),
|
|
(255, 255, 255),
|
|
]
|
|
for x, color in enumerate(colors):
|
|
for y in range(2):
|
|
pixels[x, y] = color
|
|
|
|
cropped = _resize_center_crop(image, height=2, width=2)
|
|
cropped_pixels = cropped.load()
|
|
|
|
assert cropped.size == (2, 2)
|
|
assert [[cropped_pixels[x, y] for x in range(2)] for y in range(2)] == [
|
|
[(0, 255, 0), (0, 0, 255)],
|
|
[(0, 255, 0), (0, 0, 255)],
|
|
]
|
|
|
|
|
|
def test_joy_cfg_normalize_uses_channel_dimension():
|
|
mixin = JoyImageEditCFGParallelMixin()
|
|
noise_pred = torch.tensor([[[[[[3.0, 4.0]]], [[[4.0, 3.0]]]]]])
|
|
comb_pred = torch.tensor([[[[[[6.0, 8.0]]], [[[8.0, 6.0]]]]]])
|
|
|
|
normalized = mixin.cfg_normalize_function(noise_pred, comb_pred)
|
|
expected = comb_pred * (
|
|
torch.norm(noise_pred, dim=2, keepdim=True) / torch.norm(comb_pred, dim=2, keepdim=True).clamp_min(1e-6)
|
|
)
|
|
|
|
assert torch.equal(normalized, expected)
|
|
|
|
|
|
def test_joy_diffuse_preserves_scheduler_timestep_dtype_for_time_embedding():
|
|
class FakeProgressBar:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_value, traceback):
|
|
return False
|
|
|
|
def update(self):
|
|
pass
|
|
|
|
class FakeScheduler:
|
|
def set_begin_index(self, index):
|
|
self.begin_index = index
|
|
|
|
class FakeMixin(JoyImageEditCFGParallelMixin):
|
|
def __init__(self):
|
|
self.scheduler = FakeScheduler()
|
|
self.captured_timestep_dtype = None
|
|
self._interrupt = False
|
|
|
|
@property
|
|
def interrupt(self):
|
|
return self._interrupt
|
|
|
|
def progress_bar(self, total):
|
|
return FakeProgressBar()
|
|
|
|
def predict_noise_maybe_with_cfg(self, **kwargs):
|
|
self.captured_timestep_dtype = kwargs["positive_kwargs"]["timestep"].dtype
|
|
return torch.zeros_like(kwargs["positive_kwargs"]["hidden_states"])
|
|
|
|
def scheduler_step_maybe_with_cfg(self, noise_pred, timestep, latents, do_true_cfg):
|
|
return latents
|
|
|
|
mixin = FakeMixin()
|
|
latents = torch.zeros(1, 2, 4, 1, 4, 4, dtype=torch.bfloat16)
|
|
image_latents = torch.zeros(1, 1, 4, 1, 4, 4, dtype=torch.bfloat16)
|
|
|
|
mixin.diffuse(
|
|
latents=latents,
|
|
image_latents=image_latents,
|
|
prompt_embeds=torch.zeros(1, 2, 4, dtype=torch.bfloat16),
|
|
prompt_embeds_mask=torch.ones(1, 2, dtype=torch.long),
|
|
negative_prompt_embeds=None,
|
|
negative_prompt_embeds_mask=None,
|
|
timesteps=torch.tensor([999.125], dtype=torch.float32),
|
|
do_true_cfg=False,
|
|
true_cfg_scale=1.0,
|
|
)
|
|
|
|
assert mixin.captured_timestep_dtype == torch.float32
|
|
|
|
|
|
def test_joy_diffuse_replaces_reference_latents_out_of_place():
|
|
class FakeProgressBar:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_value, traceback):
|
|
return False
|
|
|
|
def update(self):
|
|
pass
|
|
|
|
class FakeScheduler:
|
|
def set_begin_index(self, index):
|
|
self.begin_index = index
|
|
|
|
class FakeMixin(JoyImageEditCFGParallelMixin):
|
|
def __init__(self):
|
|
self.scheduler = FakeScheduler()
|
|
self.scheduler_returned_latents = None
|
|
self._interrupt = False
|
|
|
|
@property
|
|
def interrupt(self):
|
|
return self._interrupt
|
|
|
|
def progress_bar(self, total):
|
|
return FakeProgressBar()
|
|
|
|
def predict_noise_maybe_with_cfg(self, **kwargs):
|
|
return torch.zeros_like(kwargs["positive_kwargs"]["hidden_states"])
|
|
|
|
def scheduler_step_maybe_with_cfg(self, noise_pred, timestep, latents, do_true_cfg):
|
|
stepped = latents.clone()
|
|
stepped[:, :1] = -5
|
|
self.scheduler_returned_latents = stepped
|
|
return stepped
|
|
|
|
mixin = FakeMixin()
|
|
latents = torch.arange(3, dtype=torch.float32).view(1, 3, 1, 1, 1, 1)
|
|
original_latents = latents.clone()
|
|
image_latents = torch.full((1, 1, 1, 1, 1, 1), 100.0)
|
|
|
|
output = mixin.diffuse(
|
|
latents=latents,
|
|
image_latents=image_latents,
|
|
prompt_embeds=torch.zeros(1, 2, 4),
|
|
prompt_embeds_mask=torch.ones(1, 2, dtype=torch.long),
|
|
negative_prompt_embeds=None,
|
|
negative_prompt_embeds_mask=None,
|
|
timesteps=torch.tensor([999.125], dtype=torch.float32),
|
|
do_true_cfg=False,
|
|
true_cfg_scale=1.0,
|
|
)
|
|
|
|
assert torch.equal(latents, original_latents)
|
|
assert mixin.scheduler_returned_latents is not None
|
|
assert torch.equal(mixin.scheduler_returned_latents[:, :1], torch.full_like(image_latents, -5))
|
|
assert torch.equal(output[:, :1], image_latents)
|
|
|
|
|
|
def test_format_qwen_multimodal_prompt_matches_diffusers_image_placeholder_replacement():
|
|
formatted = _format_qwen_multimodal_prompt("make it brighter")
|
|
|
|
assert formatted == (f"<|im_start|>user\n{JOY_VISION_TOKEN}make it brighter<|im_end|>\n")
|
|
|
|
|
|
def test_preprocess_maps_explicit_size_to_nearest_diffusers_bucket(tmp_path):
|
|
od_config = _write_model_configs(tmp_path)
|
|
preprocess = get_joy_image_edit_pre_process_func(od_config)
|
|
params = _make_params(height=2048, width=2048)
|
|
|
|
request = preprocess(_make_request(params=params))
|
|
|
|
assert request.sampling_params.height == 1024
|
|
assert request.sampling_params.width == 1024
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"params_kwargs, expected_scale",
|
|
[
|
|
({}, 4.0),
|
|
({"guidance_scale": 3.5}, 3.5),
|
|
({"true_cfg_scale": 4.0}, 4.0),
|
|
({"guidance_scale": 4.0, "true_cfg_scale": 4.0}, 4.0),
|
|
],
|
|
)
|
|
def test_resolve_effective_true_cfg_scale(params_kwargs, expected_scale):
|
|
request = _make_request(params=_make_params(**params_kwargs))
|
|
assert JoyImageEditPipeline.resolve_effective_true_cfg_scale(request) == expected_scale
|
|
|
|
|
|
@pytest.mark.parametrize("guidance_scale", [1.0, 3.0])
|
|
def test_explicit_guidance_scale_conflicts_with_true_cfg_scale(guidance_scale):
|
|
request = _make_request(params=_make_params(guidance_scale=guidance_scale, true_cfg_scale=4.0))
|
|
with pytest.raises(ValueError, match="compatibility alias"):
|
|
JoyImageEditPipeline.resolve_effective_true_cfg_scale(request)
|
|
|
|
|
|
def test_pad_prompt_embeds_keeps_last_tokens_and_builds_mask():
|
|
first = torch.arange(20, dtype=torch.float32).reshape(5, 4)
|
|
second = torch.arange(8, dtype=torch.float32).reshape(2, 4)
|
|
|
|
prompt_embeds, prompt_mask = JoyImageEditPipeline._pad_prompt_embeds(
|
|
[first, second],
|
|
max_sequence_length=3,
|
|
)
|
|
|
|
assert prompt_embeds.shape == (2, 3, 4)
|
|
assert torch.equal(prompt_embeds[0], first[-3:])
|
|
assert torch.equal(prompt_embeds[1, :2], second)
|
|
assert torch.equal(prompt_embeds[1, 2], torch.zeros(4))
|
|
assert torch.equal(
|
|
prompt_mask,
|
|
torch.tensor([[1, 1, 1], [1, 1, 0]], dtype=torch.long),
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="greater than 0"):
|
|
JoyImageEditPipeline._pad_prompt_embeds([first], max_sequence_length=0)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("prompt_embeds", "prompt_embeds_mask", "match"),
|
|
[
|
|
(
|
|
torch.zeros(2, 4),
|
|
torch.ones(2, 4, dtype=torch.long),
|
|
"prompt_embeds must be a 3D tensor",
|
|
),
|
|
(
|
|
torch.zeros(2, 4, 8),
|
|
torch.ones(2, 4, 1, dtype=torch.long),
|
|
"prompt_embeds_mask must be a 2D tensor",
|
|
),
|
|
(
|
|
torch.zeros(2, 4, 8),
|
|
torch.ones(3, 4, dtype=torch.long),
|
|
"same batch size",
|
|
),
|
|
(
|
|
torch.zeros(2, 4, 8),
|
|
torch.ones(2, 5, dtype=torch.long),
|
|
"same sequence length",
|
|
),
|
|
],
|
|
)
|
|
def test_encode_prompt_validates_precomputed_embeds_and_mask_shapes(
|
|
prompt_embeds,
|
|
prompt_embeds_mask,
|
|
match,
|
|
):
|
|
pipeline = object.__new__(JoyImageEditPipeline)
|
|
|
|
with pytest.raises(ValueError, match=match):
|
|
JoyImageEditPipeline.encode_prompt(
|
|
pipeline,
|
|
"prompt",
|
|
Image.new("RGB", (16, 16)),
|
|
num_images_per_prompt=1,
|
|
prompt_embeds=prompt_embeds,
|
|
prompt_embeds_mask=prompt_embeds_mask,
|
|
)
|
|
|
|
|
|
def test_encode_prompt_validation_uses_negative_parameter_names():
|
|
pipeline = object.__new__(JoyImageEditPipeline)
|
|
|
|
with pytest.raises(ValueError, match="negative_prompt_embeds_mask must be a 2D tensor"):
|
|
JoyImageEditPipeline.encode_prompt(
|
|
pipeline,
|
|
"",
|
|
Image.new("RGB", (16, 16)),
|
|
num_images_per_prompt=1,
|
|
prompt_embeds=torch.zeros(1, 4, 8),
|
|
prompt_embeds_mask=torch.ones(1, 4, 1, dtype=torch.long),
|
|
embeds_name="negative_prompt_embeds",
|
|
mask_name="negative_prompt_embeds_mask",
|
|
)
|
|
|
|
|
|
def test_last_layer_capture_passes_mm_token_type_ids():
|
|
class FakeDecoderLayer(torch.nn.Module):
|
|
def forward(self, hidden_states):
|
|
return (hidden_states + 1.0,)
|
|
|
|
class FakeLanguageModel(torch.nn.Module):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.layers = torch.nn.ModuleList([FakeDecoderLayer()])
|
|
|
|
class FakeTextEncoder(torch.nn.Module):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.model = SimpleNamespace(language_model=FakeLanguageModel())
|
|
self.calls = []
|
|
|
|
def forward(self, **kwargs):
|
|
self.calls.append(kwargs)
|
|
hidden_states = torch.zeros(1, 2, 4)
|
|
self.model.language_model.layers[-1](hidden_states)
|
|
|
|
pipeline = object.__new__(JoyImageEditPipeline)
|
|
torch.nn.Module.__init__(pipeline)
|
|
pipeline.text_encoder = FakeTextEncoder()
|
|
mm_token_type_ids = torch.tensor([[0, 1]], dtype=torch.long)
|
|
model_inputs = SimpleNamespace(
|
|
input_ids=torch.tensor([[1, 2]], dtype=torch.long),
|
|
attention_mask=torch.tensor([[1, 1]], dtype=torch.long),
|
|
pixel_values=torch.zeros(1, 3, 16, 16),
|
|
image_grid_thw=torch.tensor([[1, 1, 1]], dtype=torch.long),
|
|
mm_token_type_ids=mm_token_type_ids,
|
|
extra_processor_field=torch.tensor([7], dtype=torch.long),
|
|
)
|
|
|
|
hidden_states = JoyImageEditPipeline._get_last_layer_pre_norm_hidden(
|
|
pipeline,
|
|
model_inputs,
|
|
)
|
|
|
|
assert torch.equal(hidden_states, torch.ones(1, 2, 4))
|
|
assert pipeline.text_encoder.calls[0]["mm_token_type_ids"] is mm_token_type_ids
|
|
assert torch.equal(pipeline.text_encoder.calls[0]["extra_processor_field"], torch.tensor([7], dtype=torch.long))
|
|
assert pipeline.text_encoder.calls[0]["output_hidden_states"] is False
|
|
|
|
|
|
def test_cast_floating_model_inputs_only_casts_pixel_tensors():
|
|
class FakeModelInputs(dict):
|
|
def __getattr__(self, name):
|
|
return self[name]
|
|
|
|
model_inputs = FakeModelInputs(
|
|
input_ids=torch.tensor([[1, 2]], dtype=torch.long),
|
|
attention_mask=torch.tensor([[1, 1]], dtype=torch.long),
|
|
pixel_values=torch.zeros(1, 3, dtype=torch.float32),
|
|
image_grid_thw=torch.tensor([[1, 1, 1]], dtype=torch.long),
|
|
)
|
|
|
|
_cast_floating_model_inputs(model_inputs, torch.bfloat16)
|
|
|
|
assert model_inputs.input_ids.dtype == torch.long
|
|
assert model_inputs.attention_mask.dtype == torch.long
|
|
assert model_inputs.pixel_values.dtype == torch.bfloat16
|
|
assert model_inputs.image_grid_thw.dtype == torch.long
|
|
|
|
|
|
def test_qwen_prompt_embeds_casts_processor_pixel_values_to_encoder_dtype():
|
|
class FakeModelInputs(dict):
|
|
def __init__(self):
|
|
super().__init__(
|
|
input_ids=torch.tensor([[1, 2, 3]], dtype=torch.long),
|
|
attention_mask=torch.tensor([[1, 1, 1]], dtype=torch.long),
|
|
pixel_values=torch.zeros(1, 3, dtype=torch.float32),
|
|
image_grid_thw=torch.tensor([[1, 1, 1]], dtype=torch.long),
|
|
)
|
|
|
|
def __getattr__(self, name):
|
|
return self[name]
|
|
|
|
def __setattr__(self, name, value):
|
|
self[name] = value
|
|
|
|
def to(self, device):
|
|
for key, value in list(self.items()):
|
|
if isinstance(value, torch.Tensor):
|
|
self[key] = value.to(device)
|
|
return self
|
|
|
|
class FakeProcessor:
|
|
def __init__(self):
|
|
self.model_inputs = FakeModelInputs()
|
|
|
|
def __call__(self, **kwargs):
|
|
self.kwargs = kwargs
|
|
return self.model_inputs
|
|
|
|
class FakeTextEncoder:
|
|
dtype = torch.bfloat16
|
|
|
|
pipeline = object.__new__(JoyImageEditPipeline)
|
|
torch.nn.Module.__init__(pipeline)
|
|
pipeline.device = torch.device("cpu")
|
|
pipeline.processor = FakeProcessor()
|
|
pipeline.text_encoder = FakeTextEncoder()
|
|
pipeline.prompt_template_encode = JOY_PROMPT_TEMPLATE
|
|
pipeline.prompt_template_encode_start_idx = 0
|
|
pipeline.tokenizer_max_length = 8
|
|
captured = {}
|
|
|
|
def fake_get_hidden(model_inputs):
|
|
captured["pixel_dtype"] = model_inputs.pixel_values.dtype
|
|
captured["attention_mask_dtype"] = model_inputs.attention_mask.dtype
|
|
return torch.ones(1, 3, 4, dtype=torch.float32)
|
|
|
|
pipeline._get_last_layer_pre_norm_hidden = fake_get_hidden
|
|
|
|
prompt_embeds, prompt_mask = JoyImageEditPipeline._get_qwen_prompt_embeds(
|
|
pipeline,
|
|
"make it brighter",
|
|
Image.new("RGB", (16, 16)),
|
|
)
|
|
|
|
assert captured == {
|
|
"pixel_dtype": torch.bfloat16,
|
|
"attention_mask_dtype": torch.long,
|
|
}
|
|
text = pipeline.processor.kwargs["text"][0]
|
|
assert text.startswith("<|im_start|>system\n \\nDescribe")
|
|
assert "Describe the image by detailing the color, shape, size, texture" in text
|
|
assert (f"<|im_start|>user\n{JOY_VISION_TOKEN}make it brighter<|im_end|>\n") in text
|
|
assert text.endswith("<|im_start|>assistant\n")
|
|
assert prompt_embeds.dtype == torch.bfloat16
|
|
assert prompt_mask.dtype == torch.long
|
|
|
|
|
|
def test_prepare_latents_casts_vae_encoded_latents_to_requested_dtype():
|
|
pipeline = object.__new__(JoyImageEditPipeline)
|
|
torch.nn.Module.__init__(pipeline)
|
|
pipeline.latent_channels = 4
|
|
pipeline.vae_scale_factor = 8
|
|
|
|
def fake_encode(image, generator):
|
|
assert image.dtype == torch.bfloat16
|
|
return torch.ones(1, 4, 1, 8, 8, dtype=torch.float32)
|
|
|
|
pipeline._encode_vae_image = fake_encode
|
|
|
|
latents, image_latents = JoyImageEditPipeline._prepare_latents(
|
|
pipeline,
|
|
image=torch.zeros(1, 3, 1, 64, 64, dtype=torch.float32),
|
|
batch_size=1,
|
|
num_channels_latents=4,
|
|
height=64,
|
|
width=64,
|
|
dtype=torch.bfloat16,
|
|
device=torch.device("cpu"),
|
|
generator=None,
|
|
)
|
|
|
|
assert latents.dtype == torch.bfloat16
|
|
assert image_latents.dtype == torch.bfloat16
|
|
|
|
|
|
def test_prepare_latents_prepares_offloaded_vae_before_encode():
|
|
pipeline = object.__new__(JoyImageEditPipeline)
|
|
pipeline.vae_scale_factor = 8
|
|
calls = []
|
|
pipeline._prepare_vae_for_decode = lambda: calls.append("prepare")
|
|
|
|
def encode(image, generator):
|
|
calls.append("encode")
|
|
return torch.zeros(1, 4, 1, 2, 2, device=image.device, dtype=image.dtype)
|
|
|
|
pipeline._encode_vae_image = encode
|
|
pipeline._prepare_latents(
|
|
image=torch.zeros(1, 3, 1, 16, 16),
|
|
batch_size=1,
|
|
num_channels_latents=4,
|
|
height=16,
|
|
width=16,
|
|
dtype=torch.float32,
|
|
device=torch.device("cpu"),
|
|
generator=None,
|
|
)
|
|
|
|
assert calls == ["prepare", "encode"]
|
|
|
|
|
|
def test_encode_vae_image_does_not_forward_noise_generator_to_posterior_sample():
|
|
class FakeLatentDist:
|
|
def __init__(self):
|
|
self.received_generator = object()
|
|
|
|
def sample(self, generator=None):
|
|
self.received_generator = generator
|
|
return torch.zeros(1, 4, 1, 8, 8)
|
|
|
|
class FakeVAE:
|
|
def __init__(self):
|
|
self.config = SimpleNamespace(
|
|
latents_mean=[0.0, 0.0, 0.0, 0.0],
|
|
latents_std=[1.0, 1.0, 1.0, 1.0],
|
|
)
|
|
self.latent_dist = FakeLatentDist()
|
|
|
|
def encode(self, image):
|
|
return SimpleNamespace(latent_dist=self.latent_dist)
|
|
|
|
pipeline = object.__new__(JoyImageEditPipeline)
|
|
torch.nn.Module.__init__(pipeline)
|
|
pipeline.vae = FakeVAE()
|
|
pipeline.latent_channels = 4
|
|
|
|
generator = torch.Generator(device="cpu").manual_seed(123)
|
|
JoyImageEditPipeline._encode_vae_image(
|
|
pipeline,
|
|
torch.zeros(1, 3, 1, 64, 64),
|
|
generator,
|
|
)
|
|
|
|
assert pipeline.vae.latent_dist.received_generator is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"dtype",
|
|
[
|
|
torch.float32,
|
|
torch.bfloat16,
|
|
],
|
|
)
|
|
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
|
def test_joy_attention_native_sdpa_matches_torch_sdpa_without_mask(dtype):
|
|
torch.manual_seed(123)
|
|
device = torch.device("cuda")
|
|
attention = _make_joy_attention(dtype=dtype).to(device=device)
|
|
hidden_states = torch.randn(2, 3, 32, device=device, dtype=dtype)
|
|
encoder_hidden_states = torch.randn(2, 5, 32, device=device, dtype=dtype)
|
|
|
|
actual = attention(hidden_states, encoder_hidden_states)
|
|
expected = _reference_joy_attention_output(attention, hidden_states, encoder_hidden_states)
|
|
|
|
assert isinstance(attention.attn, Attention)
|
|
assert attention.attn.role == "joy_image.joint"
|
|
assert attention.attn.role_category == "self"
|
|
assert attention.attn.qkv_layout == "BSND"
|
|
_assert_attention_outputs_close(actual, expected, dtype=dtype)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"dtype",
|
|
[
|
|
torch.float32,
|
|
torch.bfloat16,
|
|
],
|
|
)
|
|
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
|
def test_joy_attention_native_sdpa_matches_torch_sdpa_with_padding_mask(dtype):
|
|
torch.manual_seed(456)
|
|
device = torch.device("cuda")
|
|
attention = _make_joy_attention(dtype=dtype).to(device=device)
|
|
hidden_states = torch.randn(2, 3, 32, device=device, dtype=dtype)
|
|
encoder_hidden_states = torch.randn(2, 5, 32, device=device, dtype=dtype)
|
|
attention_mask = torch.tensor(
|
|
[
|
|
[1, 1, 1, 1, 1, 1, 1, 1],
|
|
[1, 1, 1, 1, 1, 1, 0, 0],
|
|
],
|
|
device=device,
|
|
dtype=torch.bool,
|
|
)
|
|
|
|
actual = attention(hidden_states, encoder_hidden_states, attention_mask=attention_mask)
|
|
expected = _reference_joy_attention_output(
|
|
attention,
|
|
hidden_states,
|
|
encoder_hidden_states,
|
|
attention_mask=attention_mask,
|
|
)
|
|
|
|
_assert_attention_outputs_close(actual, expected, dtype=dtype)
|
|
|
|
|
|
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
|
def test_transformer_shape_and_masked_forward():
|
|
device = torch.device("cuda")
|
|
transformer = JoyImageEditTransformer3DModel(
|
|
in_channels=4,
|
|
out_channels=4,
|
|
hidden_size=32,
|
|
text_dim=16,
|
|
num_layers=1,
|
|
num_attention_heads=4,
|
|
patch_size=(1, 2, 2),
|
|
).to(device=device, dtype=torch.bfloat16)
|
|
hidden_states = torch.randn(2, 2, 4, 1, 4, 4, device=device, dtype=torch.bfloat16)
|
|
encoder_hidden_states = torch.randn(2, 5, 16, device=device, dtype=torch.bfloat16)
|
|
encoder_hidden_states_mask = torch.tensor([[1, 1, 1, 1, 1], [1, 1, 1, 0, 0]], device=device)
|
|
|
|
output = transformer(
|
|
hidden_states=hidden_states,
|
|
timestep=torch.tensor([1.0, 2.0], device=device),
|
|
encoder_hidden_states=encoder_hidden_states,
|
|
encoder_hidden_states_mask=encoder_hidden_states_mask,
|
|
return_dict=False,
|
|
)[0]
|
|
|
|
assert output.shape == hidden_states.shape
|
|
assert isinstance(transformer.double_blocks[0].attn.attn, Attention)
|
|
|
|
|
|
def test_transformer_from_config_file_loads_checkpoint_config(tmp_path):
|
|
_write_model_configs(tmp_path)
|
|
|
|
transformer = JoyImageEditTransformer3DModel.from_config_file(tmp_path / "transformer" / "config.json")
|
|
|
|
assert transformer.patch_size == (1, 2, 2)
|
|
assert transformer.hidden_size == 32
|
|
assert transformer.num_attention_heads == 4
|
|
assert transformer.num_layers == 1
|
|
assert transformer.text_dim == 16
|
|
assert transformer.theta == 256
|
|
assert len(transformer.double_blocks) == 1
|
|
|
|
|
|
def test_transformer_load_weights_maps_diffusers_prefix():
|
|
transformer = JoyImageEditTransformer3DModel(
|
|
in_channels=4,
|
|
out_channels=4,
|
|
hidden_size=32,
|
|
text_dim=16,
|
|
num_layers=1,
|
|
num_attention_heads=4,
|
|
patch_size=(1, 2, 2),
|
|
)
|
|
weight = torch.randn_like(transformer.img_in.weight)
|
|
|
|
loaded = transformer.load_weights([("transformer.patch_embedding.weight", weight)])
|
|
|
|
assert loaded == {"img_in.weight"}
|
|
assert torch.equal(transformer.img_in.weight, weight)
|
|
|
|
text_weight = torch.randn_like(transformer.condition_embedder.text_embedder.linear_1.weight)
|
|
loaded = transformer.load_weights([("transformer.condition_embedder.text_embedder.linear_1.weight", text_weight)])
|
|
|
|
assert loaded == {"condition_embedder.text_embedder.linear_1.weight"}
|
|
assert torch.equal(transformer.condition_embedder.text_embedder.linear_1.weight, text_weight)
|
|
|
|
|
|
def test_transformer_load_weights_warns_for_unknown_weight(caplog):
|
|
transformer = JoyImageEditTransformer3DModel(
|
|
in_channels=4,
|
|
out_channels=4,
|
|
hidden_size=32,
|
|
text_dim=16,
|
|
num_layers=1,
|
|
num_attention_heads=4,
|
|
patch_size=(1, 2, 2),
|
|
)
|
|
|
|
caplog.set_level(logging.WARNING)
|
|
loaded = transformer.load_weights([("transformer.unmapped.weight", torch.empty(1))])
|
|
|
|
assert loaded == set()
|
|
assert "Skipping JoyAI-Image-Edit transformer weight transformer.unmapped.weight" in caplog.text
|
|
assert "checkpoint mismatch" in caplog.text
|
|
|
|
|
|
def test_latent_normalization_round_trip():
|
|
pipeline = object.__new__(JoyImageEditPipeline)
|
|
pipeline.latent_channels = 4
|
|
pipeline.vae = SimpleNamespace(
|
|
config=SimpleNamespace(
|
|
latents_mean=[0.1, -0.2, 0.3, -0.4],
|
|
latents_std=[0.5, 0.75, 1.25, 2.0],
|
|
)
|
|
)
|
|
latents = torch.randn(2, 4, 1, 2, 2)
|
|
|
|
latents_mean, latents_std = JoyImageEditPipeline._latent_stats(
|
|
pipeline,
|
|
latents.device,
|
|
latents.dtype,
|
|
)
|
|
normalized = (latents - latents_mean) / latents_std
|
|
|
|
assert torch.allclose(normalized * latents_std + latents_mean, latents)
|
|
|
|
|
|
def test_decode_latents_unnormalizes_and_selects_target_slot():
|
|
class FakeVAE:
|
|
config = SimpleNamespace(
|
|
latents_mean=[0.1, -0.2, 0.3, -0.4],
|
|
latents_std=[0.5, 0.75, 1.25, 2.0],
|
|
)
|
|
|
|
def decode(self, latents, return_dict=False):
|
|
return (latents,)
|
|
|
|
pipeline = object.__new__(JoyImageEditPipeline)
|
|
pipeline.latent_channels = 4
|
|
pipeline.vae = FakeVAE()
|
|
latents = torch.zeros(1, 2, 4, 1, 2, 2)
|
|
latents[:, -1] = 2.0
|
|
|
|
decoded = JoyImageEditPipeline._decode_latents(pipeline, latents)
|
|
|
|
latents_mean, latents_std = JoyImageEditPipeline._latent_stats(
|
|
pipeline,
|
|
latents.device,
|
|
latents.dtype,
|
|
)
|
|
assert decoded.shape == (1, 4, 2, 2)
|
|
assert torch.equal(decoded, (latents[:, -1] * latents_std + latents_mean)[:, :, 0])
|
|
|
|
|
|
def test_prepare_latents_stacks_reference_first_target_last():
|
|
pipeline = object.__new__(JoyImageEditPipeline)
|
|
pipeline.vae_scale_factor = 8
|
|
pipeline.latent_channels = 4
|
|
image_latents = torch.ones(1, 4, 1, 2, 2)
|
|
noise_latents = torch.zeros(1, 1, 4, 1, 2, 2)
|
|
pipeline._encode_vae_image = lambda image, generator: image_latents
|
|
|
|
latents, reference_latents = JoyImageEditPipeline._prepare_latents(
|
|
pipeline,
|
|
image=torch.zeros(1, 3, 1, 16, 16),
|
|
batch_size=1,
|
|
num_channels_latents=4,
|
|
height=16,
|
|
width=16,
|
|
dtype=torch.float32,
|
|
device=torch.device("cpu"),
|
|
generator=None,
|
|
latents=noise_latents,
|
|
)
|
|
|
|
assert latents.shape == (1, 2, 4, 1, 2, 2)
|
|
assert torch.equal(latents[:, :1], reference_latents)
|
|
assert torch.equal(latents[:, -1:], noise_latents)
|
|
|
|
|
|
def test_prepare_latents_validates_generator_and_latent_shapes():
|
|
pipeline = object.__new__(JoyImageEditPipeline)
|
|
pipeline.vae_scale_factor = 8
|
|
pipeline.latent_channels = 4
|
|
image_latents = torch.ones(1, 4, 1, 2, 2)
|
|
pipeline._encode_vae_image = lambda image, generator: image_latents
|
|
|
|
with pytest.raises(ValueError, match="Generator list length"):
|
|
JoyImageEditPipeline._prepare_latents(
|
|
pipeline,
|
|
image=torch.zeros(1, 3, 1, 16, 16),
|
|
batch_size=2,
|
|
num_channels_latents=4,
|
|
height=16,
|
|
width=16,
|
|
dtype=torch.float32,
|
|
device=torch.device("cpu"),
|
|
generator=[torch.Generator()],
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="noise latents must have shape"):
|
|
JoyImageEditPipeline._prepare_latents(
|
|
pipeline,
|
|
image=torch.zeros(1, 3, 1, 16, 16),
|
|
batch_size=1,
|
|
num_channels_latents=4,
|
|
height=16,
|
|
width=16,
|
|
dtype=torch.float32,
|
|
device=torch.device("cpu"),
|
|
generator=None,
|
|
latents=torch.zeros(1, 1, 4, 1, 1, 2),
|
|
)
|
|
|
|
|
|
def test_prepare_latents_validates_image_latent_shape():
|
|
pipeline = object.__new__(JoyImageEditPipeline)
|
|
pipeline.vae_scale_factor = 8
|
|
pipeline.latent_channels = 4
|
|
pipeline._encode_vae_image = lambda image, generator: torch.ones(1, 4, 2, 2)
|
|
|
|
with pytest.raises(ValueError, match="image latents must have shape"):
|
|
JoyImageEditPipeline._prepare_latents(
|
|
pipeline,
|
|
image=torch.zeros(1, 3, 1, 16, 16),
|
|
batch_size=1,
|
|
num_channels_latents=4,
|
|
height=16,
|
|
width=16,
|
|
dtype=torch.float32,
|
|
device=torch.device("cpu"),
|
|
generator=None,
|
|
)
|
|
|
|
|
|
def test_diffuse_restores_reference_slots_each_step():
|
|
class FakeScheduler:
|
|
def set_begin_index(self, index):
|
|
self.begin_index = index
|
|
|
|
def step(self, noise_pred, timestep, latents, return_dict=False):
|
|
return (latents + 1.0,)
|
|
|
|
class FakeTransformer(torch.nn.Module):
|
|
def forward(self, hidden_states, **kwargs):
|
|
return (torch.zeros_like(hidden_states),)
|
|
|
|
pipeline = object.__new__(JoyImageEditPipeline)
|
|
torch.nn.Module.__init__(pipeline)
|
|
pipeline.scheduler = FakeScheduler()
|
|
pipeline.transformer = FakeTransformer()
|
|
pipeline._interrupt = False
|
|
pipeline._current_timestep = None
|
|
|
|
latents = torch.zeros(1, 2, 4, 1, 2, 2)
|
|
image_latents = torch.full((1, 1, 4, 1, 2, 2), 7.0)
|
|
result = JoyImageEditPipeline.diffuse(
|
|
pipeline,
|
|
latents=latents,
|
|
image_latents=image_latents,
|
|
prompt_embeds=torch.zeros(1, 3, 8),
|
|
prompt_embeds_mask=torch.ones(1, 3, dtype=torch.long),
|
|
negative_prompt_embeds=None,
|
|
negative_prompt_embeds_mask=None,
|
|
timesteps=torch.tensor([2.0, 1.0]),
|
|
do_true_cfg=False,
|
|
true_cfg_scale=1.0,
|
|
)
|
|
|
|
assert torch.equal(result[:, :1], image_latents)
|
|
assert torch.equal(result[:, -1:], torch.full((1, 1, 4, 1, 2, 2), 2.0))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"params_kwargs, expected_scale",
|
|
[
|
|
({}, 4.0),
|
|
({"guidance_scale": 2.0}, 2.0),
|
|
({"guidance_scale": 1.0}, 1.0),
|
|
({"guidance_scale": 0.0}, 0.0),
|
|
({"true_cfg_scale": 8.0}, 8.0),
|
|
],
|
|
)
|
|
def test_forward_uses_guidance_scale_for_cfg(params_kwargs, expected_scale):
|
|
class FakeScheduler:
|
|
def set_timesteps(self, num_inference_steps, device):
|
|
self.num_inference_steps = num_inference_steps
|
|
self.timesteps = torch.tensor([1.0], device=device)
|
|
|
|
pipeline = object.__new__(JoyImageEditPipeline)
|
|
torch.nn.Module.__init__(pipeline)
|
|
pipeline.device = torch.device("cpu")
|
|
pipeline.latent_channels = 4
|
|
pipeline.scheduler = FakeScheduler()
|
|
encode_calls = []
|
|
diffuse_calls = []
|
|
|
|
def encode_prompt(prompt, image, **kwargs):
|
|
encode_calls.append(prompt)
|
|
return torch.zeros(1, 3, 4), torch.ones(1, 3, dtype=torch.long)
|
|
|
|
def fake_prepare_latents(**kwargs):
|
|
latents = torch.zeros(1, 2, 4, 1, 2, 2)
|
|
image_latents = torch.ones(1, 1, 4, 1, 2, 2)
|
|
return latents, image_latents
|
|
|
|
def diffuse(**kwargs):
|
|
diffuse_calls.append(kwargs)
|
|
return kwargs["latents"]
|
|
|
|
pipeline.encode_prompt = encode_prompt
|
|
pipeline._prepare_latents = fake_prepare_latents
|
|
pipeline.diffuse = diffuse
|
|
pipeline._decode_latents = lambda latents: torch.zeros(1, 3, 2, 2)
|
|
|
|
request = _make_request(
|
|
prompt={
|
|
"prompt": "make it brighter",
|
|
"additional_information": {
|
|
"image_tensor": torch.zeros(1, 3, 1, 16, 16),
|
|
"prompt_image": Image.new("RGB", (16, 16)),
|
|
"height": 16,
|
|
"width": 16,
|
|
},
|
|
},
|
|
params=_make_params(**params_kwargs),
|
|
)
|
|
|
|
output = JoyImageEditPipeline.forward(pipeline, request)
|
|
|
|
assert output.output.shape == (1, 3, 2, 2)
|
|
expected_cfg = expected_scale > 1.0
|
|
assert encode_calls == (["make it brighter", ""] if expected_cfg else ["make it brighter"])
|
|
assert diffuse_calls[0]["do_true_cfg"] is expected_cfg
|
|
assert diffuse_calls[0]["true_cfg_scale"] == expected_scale
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"schedule_kwargs, expected_timesteps",
|
|
[
|
|
({"timesteps": torch.tensor([9, 4])}, torch.tensor([9.0, 4.0])),
|
|
({"sigmas": [1.0, 0.5]}, torch.tensor([1000.0, 500.0])),
|
|
],
|
|
)
|
|
def test_forward_uses_request_sampling_parameters(schedule_kwargs, expected_timesteps):
|
|
class FakeScheduler:
|
|
def set_timesteps(self, num_inference_steps=None, device=None, timesteps=None, sigmas=None):
|
|
if timesteps is not None:
|
|
self.timesteps = torch.as_tensor(timesteps, device=device, dtype=torch.float32)
|
|
elif sigmas is not None:
|
|
self.timesteps = torch.as_tensor(sigmas, device=device) * 1000
|
|
else:
|
|
self.timesteps = torch.arange(num_inference_steps, device=device, dtype=torch.float32)
|
|
|
|
pipeline = object.__new__(JoyImageEditPipeline)
|
|
torch.nn.Module.__init__(pipeline)
|
|
pipeline.device = torch.device("cpu")
|
|
pipeline.latent_channels = 4
|
|
pipeline.tokenizer_max_length = JOY_MAX_IMAGE_SEQ_LEN
|
|
pipeline.scheduler = FakeScheduler()
|
|
encode_calls = []
|
|
prepare_calls = []
|
|
diffuse_calls = []
|
|
|
|
def encode_prompt(prompt, image, **kwargs):
|
|
encode_calls.append(kwargs)
|
|
return torch.zeros(1, 3, 4), torch.ones(1, 3, dtype=torch.long)
|
|
|
|
def prepare_latents(**kwargs):
|
|
prepare_calls.append(kwargs)
|
|
reference = torch.ones(1, 1, 4, 1, 2, 2)
|
|
return torch.cat([reference, kwargs["latents"]], dim=1), reference
|
|
|
|
def diffuse(**kwargs):
|
|
diffuse_calls.append(kwargs)
|
|
return kwargs["latents"]
|
|
|
|
pipeline.encode_prompt = encode_prompt
|
|
pipeline._prepare_latents = prepare_latents
|
|
pipeline.diffuse = diffuse
|
|
pipeline._decode_latents = lambda _latents: pytest.fail("latent output must not be decoded")
|
|
|
|
request_latents = torch.full((1, 1, 4, 1, 2, 2), 3.0)
|
|
params = _make_params(
|
|
guidance_scale=1.0,
|
|
latents=request_latents,
|
|
output_type="latent",
|
|
max_sequence_length=123,
|
|
**schedule_kwargs,
|
|
)
|
|
request = _make_request(
|
|
prompt={
|
|
"prompt": "make it brighter",
|
|
"additional_information": {
|
|
"image_tensor": torch.zeros(1, 3, 1, 16, 16),
|
|
"prompt_image": Image.new("RGB", (16, 16)),
|
|
"height": 16,
|
|
"width": 16,
|
|
},
|
|
},
|
|
params=params,
|
|
)
|
|
|
|
output = JoyImageEditPipeline.forward(pipeline, request)
|
|
|
|
assert encode_calls[0]["max_sequence_length"] == 123
|
|
assert prepare_calls[0]["latents"] is request_latents
|
|
torch.testing.assert_close(diffuse_calls[0]["timesteps"], expected_timesteps)
|
|
assert diffuse_calls[0]["cfg_normalize"] is True
|
|
torch.testing.assert_close(output.output, request_latents[:, 0])
|
|
|
|
|
|
def test_retrieve_timesteps_rejects_conflicting_custom_schedules():
|
|
scheduler = SimpleNamespace(set_timesteps=lambda **_kwargs: None)
|
|
|
|
with pytest.raises(ValueError, match="Only one"):
|
|
retrieve_timesteps(scheduler, timesteps=[9], sigmas=[1.0])
|
|
|
|
|
|
def test_forward_rejects_unsupported_output_type():
|
|
pipeline = object.__new__(JoyImageEditPipeline)
|
|
torch.nn.Module.__init__(pipeline)
|
|
request = _make_request(
|
|
prompt={
|
|
"prompt": "make it brighter",
|
|
"additional_information": {
|
|
"image_tensor": torch.zeros(1, 3, 1, 16, 16),
|
|
"prompt_image": Image.new("RGB", (16, 16)),
|
|
"height": 16,
|
|
"width": 16,
|
|
},
|
|
},
|
|
params=_make_params(output_type="np"),
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="only `pil` and `latent`"):
|
|
JoyImageEditPipeline.forward(pipeline, request)
|
|
|
|
|
|
def test_forward_skips_decode_for_dummy_warmup_request(monkeypatch):
|
|
class FakeScheduler:
|
|
def set_timesteps(self, num_inference_steps, device):
|
|
self.timesteps = torch.tensor([1.0], device=device)
|
|
|
|
pipeline = object.__new__(JoyImageEditPipeline)
|
|
torch.nn.Module.__init__(pipeline)
|
|
pipeline.device = torch.device("cpu")
|
|
pipeline.od_config = SimpleNamespace(
|
|
enable_cpu_offload=True,
|
|
enable_layerwise_offload=False,
|
|
pin_cpu_memory=False,
|
|
parallel_config=SimpleNamespace(use_hsdp=False),
|
|
)
|
|
pipeline.transformer = torch.nn.Linear(1, 1)
|
|
pipeline.latent_channels = 4
|
|
pipeline.scheduler = FakeScheduler()
|
|
pipeline.encode_prompt = lambda *args, **kwargs: (
|
|
torch.zeros(1, 3, 4),
|
|
torch.ones(1, 3, dtype=torch.long),
|
|
)
|
|
pipeline._prepare_latents = lambda **kwargs: (
|
|
torch.zeros(1, 2, 4, 1, 2, 2),
|
|
torch.ones(1, 1, 4, 1, 2, 2),
|
|
)
|
|
pipeline.diffuse = lambda **kwargs: kwargs["latents"] + 1.0
|
|
pipeline.check_cfg_parallel_validity = lambda scale, has_neg_prompt: True
|
|
|
|
def decode_should_not_run(latents):
|
|
raise AssertionError("dummy warmup should not decode latents")
|
|
|
|
pipeline._decode_latents = decode_should_not_run
|
|
move_calls = []
|
|
|
|
def fake_move_params(module, target_device, **kwargs):
|
|
move_calls.append((module, target_device, kwargs))
|
|
|
|
monkeypatch.setattr(
|
|
joy_pipeline_module.SequentialOffloadHook,
|
|
"_move_params",
|
|
fake_move_params,
|
|
)
|
|
monkeypatch.setattr(joy_pipeline_module.current_omni_platform, "empty_cache", lambda: None)
|
|
|
|
request = _make_request(
|
|
prompt={
|
|
"prompt": "dummy run",
|
|
"additional_information": {
|
|
"image_tensor": torch.zeros(1, 3, 1, 16, 16),
|
|
"prompt_image": Image.new("RGB", (16, 16)),
|
|
"height": 16,
|
|
"width": 16,
|
|
},
|
|
},
|
|
params=_make_params(guidance_scale=1.0),
|
|
)
|
|
request.request_id = DUMMY_DIFFUSION_REQUEST_ID
|
|
request.is_dummy_run = lambda: True
|
|
|
|
output = JoyImageEditPipeline.forward(pipeline, request)
|
|
|
|
assert output.output.shape == (1, 4, 1, 2, 2)
|
|
assert move_calls == [
|
|
(
|
|
pipeline.transformer,
|
|
torch.device("cpu"),
|
|
{"non_blocking": False, "pin_memory": False},
|
|
)
|
|
]
|