Merge remote-tracking branch 'origin/main' into qwen-image-2.1
# Conflicts: # vllm_omni/diffusion/executor/multiproc_executor.py # vllm_omni/diffusion/offloader/sequential_backend.py
This commit is contained in:
commit
9174554e40
1332 files changed
+160051
-60504
No files matched your search
@@ -0,0 +1,2 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
@@ -0,0 +1,454 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from shutil import rmtree
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from safetensors.torch import save_file
|
||||
|
||||
from tests.model_tests.diffusion.anima_builder import CHECKPOINT_FILENAME, tiny_anima_builder
|
||||
from vllm_omni.diffusion.attention import selector
|
||||
from vllm_omni.diffusion.attention.backends.sdpa import SDPABackend
|
||||
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_qwenimage import DistributedAutoencoderKLQwenImage
|
||||
from vllm_omni.diffusion.models.anima import pipeline_anima
|
||||
from vllm_omni.diffusion.models.anima.anima_text_conditioner import AnimaTextConditioner
|
||||
from vllm_omni.diffusion.models.anima.anima_transformer import AnimaTransformer3DModel
|
||||
from vllm_omni.diffusion.models.anima.pipeline_anima import AnimaPipeline
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"offload_args",
|
||||
[
|
||||
{"enable_cpu_offload": True},
|
||||
{"enable_layerwise_offload": True},
|
||||
{"enable_distributed_layerwise_offload": True},
|
||||
{"diffusion_offload_config": {"mode": "module", "components": ["dit"]}},
|
||||
{
|
||||
"diffusion_offload_config": {
|
||||
"mode": "layer",
|
||||
"components": ["dit"],
|
||||
"layer_options": {"dit": {"weight_transfer": "rank-local"}},
|
||||
}
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_native_anima_rejects_offload(offload_args):
|
||||
config = OmniDiffusionConfig(model="anima.safetensors", **offload_args)
|
||||
with pytest.raises(NotImplementedError, match="offload"):
|
||||
AnimaPipeline(od_config=config, device=torch.device("cpu"))
|
||||
|
||||
|
||||
def test_native_anima_initializes_without_offload():
|
||||
config = OmniDiffusionConfig(model="anima.safetensors")
|
||||
AnimaPipeline(od_config=config, device=torch.device("cpu"))
|
||||
|
||||
|
||||
def test_anima_registration() -> None:
|
||||
"""The native Anima pipeline is registered and importable."""
|
||||
from vllm_omni.diffusion.registry import DiffusionModelRegistry
|
||||
|
||||
assert DiffusionModelRegistry._try_load_model_cls("AnimaPipeline") is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_class_name", ["AnimaPipeline", "AnimaModularPipeline"])
|
||||
def test_enrich_config_native_anima_checkpoint(tmp_path: Path, model_class_name: str) -> None:
|
||||
"""Native and reference Anima class names resolve to AnimaPipeline."""
|
||||
dummy_checkpoint = tmp_path / "model.safetensors"
|
||||
dummy_checkpoint.write_text("dummy")
|
||||
|
||||
config = OmniDiffusionConfig(
|
||||
model=str(dummy_checkpoint),
|
||||
model_class_name=model_class_name,
|
||||
)
|
||||
config.enrich_config()
|
||||
|
||||
assert config.diffusion_load_format == "default"
|
||||
assert config.model_class_name == "AnimaPipeline"
|
||||
assert config.diffusers_pipeline_cls is None
|
||||
|
||||
|
||||
def test_native_anima_component_paths_use_custom_pipeline_args() -> None:
|
||||
"""Anima resolves shared and component-specific paths from native args."""
|
||||
pipeline = AnimaPipeline.__new__(AnimaPipeline)
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
custom_pipeline_args={
|
||||
"components_path": "/tmp/anima-components",
|
||||
"vae_path": "/tmp/anima-vae",
|
||||
"text_encoder_model": "/tmp/anima-text-encoder",
|
||||
}
|
||||
)
|
||||
|
||||
assert pipeline._component_path("vae", "/tmp/default") == "/tmp/anima-vae"
|
||||
assert pipeline._component_path("text_encoder", "/tmp/default") == "/tmp/anima-text-encoder"
|
||||
assert pipeline._component_path("tokenizer", "/tmp/default") == "/tmp/default"
|
||||
|
||||
|
||||
def test_native_anima_converts_original_cosmos_transformer_keys() -> None:
|
||||
"""Original Cosmos checkpoint names map to native module names."""
|
||||
converted = AnimaPipeline._convert_original_transformer_state_dict(
|
||||
{
|
||||
"net.x_embedder.proj.1.weight": "patch",
|
||||
"net.blocks.0.self_attn.q_proj.weight": "q",
|
||||
"net.blocks.0.self_attn.q_norm.weight": "q_norm",
|
||||
"net.blocks.0.mlp.layer1.weight": "mlp",
|
||||
"net.final_layer.linear.weight": "out",
|
||||
"net.accum_iteration": "drop",
|
||||
}
|
||||
)
|
||||
|
||||
assert converted == {
|
||||
"patch_embed.proj.weight": "patch",
|
||||
"transformer_blocks.0.attn1.to_q.weight": "q",
|
||||
"transformer_blocks.0.attn1.norm_q.weight": "q_norm",
|
||||
"transformer_blocks.0.ff.net.0.proj.weight": "mlp",
|
||||
"proj_out.weight": "out",
|
||||
}
|
||||
|
||||
|
||||
def test_native_anima_resolves_vae_scale_factor_from_loaded_vae() -> None:
|
||||
"""The loaded VAE determines the spatial scale factor."""
|
||||
from vllm_omni.diffusion.models.anima.pipeline_anima import _anima_vae_scale_factor_from_vae
|
||||
|
||||
vae = SimpleNamespace(config=SimpleNamespace(spatial_compression_ratio=16))
|
||||
|
||||
assert _anima_vae_scale_factor_from_vae(vae) == 16
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_config_file", [False, True])
|
||||
def test_native_anima_loads_synthetic_checkpoint(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, use_config_file: bool
|
||||
) -> None:
|
||||
"""Native modules load exact weights from an Anima safetensors checkpoint."""
|
||||
tiny_transformer_config = {
|
||||
"in_channels": 1,
|
||||
"out_channels": 1,
|
||||
"num_attention_heads": 1,
|
||||
"attention_head_dim": 12,
|
||||
"num_layers": 1,
|
||||
"mlp_ratio": 1.0,
|
||||
"text_embed_dim": 4,
|
||||
"adaln_lora_dim": 3,
|
||||
"max_size": (1, 2, 2),
|
||||
"patch_size": (1, 1, 1),
|
||||
"rope_scale": (1.0, 1.0, 1.0),
|
||||
"concat_padding_mask": True,
|
||||
"extra_pos_embed_type": None,
|
||||
}
|
||||
tiny_text_conditioner_config = {
|
||||
"source_dim": 4,
|
||||
"target_dim": 4,
|
||||
"model_dim": 4,
|
||||
"num_layers": 1,
|
||||
"num_attention_heads": 1,
|
||||
"target_vocab_size": 8,
|
||||
"min_sequence_length": 4,
|
||||
}
|
||||
if use_config_file:
|
||||
(tmp_path / "anima.json").write_text(
|
||||
json.dumps({"transformer": tiny_transformer_config, "text_conditioner": tiny_text_conditioner_config})
|
||||
)
|
||||
else:
|
||||
monkeypatch.setattr(pipeline_anima, "ANIMA_TRANSFORMER_CONFIG", tiny_transformer_config)
|
||||
monkeypatch.setattr(pipeline_anima, "ANIMA_TEXT_CONDITIONER_CONFIG", tiny_text_conditioner_config)
|
||||
|
||||
monkeypatch.setattr(selector, "_cached_get_backend_cls", lambda *_args, **_kwargs: SDPABackend)
|
||||
transformer = AnimaTransformer3DModel(**tiny_transformer_config)
|
||||
text_conditioner = AnimaTextConditioner(**tiny_text_conditioner_config)
|
||||
transformer_state = {name: tensor.detach().clone() for name, tensor in transformer.state_dict().items()}
|
||||
text_conditioner_state = {name: tensor.detach().clone() for name, tensor in text_conditioner.state_dict().items()}
|
||||
checkpoint_state = {
|
||||
**{f"transformer.{name}": tensor for name, tensor in transformer_state.items()},
|
||||
**{f"text_conditioner.{name}": tensor for name, tensor in text_conditioner_state.items()},
|
||||
}
|
||||
|
||||
checkpoint_path = tmp_path / "anima.safetensors"
|
||||
save_file(checkpoint_state, str(checkpoint_path))
|
||||
|
||||
pipeline = AnimaPipeline.__new__(AnimaPipeline)
|
||||
pipeline.od_config = SimpleNamespace(model=str(checkpoint_path), dtype=torch.float32)
|
||||
pipeline.device = torch.device("cpu")
|
||||
|
||||
def assert_loaded(loaded_transformer, loaded_text_conditioner):
|
||||
for name, tensor in transformer_state.items():
|
||||
assert torch.equal(loaded_transformer.state_dict()[name], tensor)
|
||||
for name, tensor in text_conditioner_state.items():
|
||||
assert torch.equal(loaded_text_conditioner.state_dict()[name], tensor)
|
||||
|
||||
loaded_transformer, loaded_text_conditioner = pipeline._load_native_denoiser_components(dict(checkpoint_state))
|
||||
assert_loaded(loaded_transformer, loaded_text_conditioner)
|
||||
|
||||
loaded_transformer, loaded_text_conditioner = pipeline._load_native_denoiser_components()
|
||||
assert_loaded(loaded_transformer, loaded_text_conditioner)
|
||||
|
||||
if use_config_file:
|
||||
tiny_transformer_config["num_layers"] = 2
|
||||
(tmp_path / "anima.json").write_text(
|
||||
json.dumps({"transformer": tiny_transformer_config, "text_conditioner": tiny_text_conditioner_config})
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="Missing key"):
|
||||
pipeline._load_native_denoiser_components()
|
||||
|
||||
|
||||
def test_tiny_anima_generates_images(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Load every saved component and exercise CFG, decoding, and multiple outputs on CPU."""
|
||||
monkeypatch.setattr(selector, "_cached_get_backend_cls", lambda *_args, **_kwargs: SDPABackend)
|
||||
monkeypatch.setattr(current_omni_platform, "is_available", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
DistributedAutoencoderKLQwenImage,
|
||||
"init_distributed",
|
||||
lambda self: setattr(self, "distributed_executor", SimpleNamespace(parallel_size=1)),
|
||||
)
|
||||
model_dir = tiny_anima_builder()
|
||||
try:
|
||||
config = OmniDiffusionConfig(
|
||||
model=str(Path(model_dir) / CHECKPOINT_FILENAME), model_class_name="AnimaPipeline", dtype=torch.float32
|
||||
)
|
||||
pipeline = AnimaPipeline(od_config=config, device=torch.device("cpu"))
|
||||
pipeline.load_weights()
|
||||
pipeline.eval()
|
||||
outputs = []
|
||||
with torch.inference_mode():
|
||||
for _ in range(2):
|
||||
request = OmniDiffusionRequest(
|
||||
prompt="Dummy prompt",
|
||||
request_id="tiny-anima",
|
||||
sampling_params=OmniDiffusionSamplingParams(
|
||||
height=32,
|
||||
width=32,
|
||||
num_inference_steps=2,
|
||||
guidance_scale=4.0,
|
||||
num_outputs_per_prompt=2,
|
||||
generator=torch.Generator(device="cpu").manual_seed(42),
|
||||
),
|
||||
)
|
||||
outputs.append(pipeline(DiffusionRequestBatch([request]))[0].output)
|
||||
assert outputs[0].shape == (2, 3, 32, 32)
|
||||
assert torch.isfinite(outputs[0]).all()
|
||||
assert torch.equal(outputs[0], outputs[1])
|
||||
assert not torch.equal(outputs[0][0], outputs[0][1])
|
||||
finally:
|
||||
rmtree(model_dir)
|
||||
|
||||
|
||||
def test_native_anima_profiler_setup_is_deferred_and_idempotent(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Profiler targets are wrapped once, after deferred components are loaded."""
|
||||
pipeline = AnimaPipeline.__new__(AnimaPipeline)
|
||||
pipeline.od_config = SimpleNamespace(enable_diffusion_pipeline_profiler=True)
|
||||
pipeline._profiler_initialized = False
|
||||
pipeline.vae = SimpleNamespace(decode=lambda: "decoded")
|
||||
pipeline.text_encoder = SimpleNamespace(forward=lambda: "encoded")
|
||||
|
||||
monkeypatch.setattr(current_omni_platform, "is_available", lambda: False)
|
||||
pipeline._setup_profiler()
|
||||
wrapped_decode = pipeline.vae.decode
|
||||
wrapped_text_encoder = pipeline.text_encoder.forward
|
||||
pipeline._setup_profiler()
|
||||
|
||||
assert pipeline.vae.decode is wrapped_decode
|
||||
assert pipeline.text_encoder.forward is wrapped_text_encoder
|
||||
assert pipeline.vae.decode() == "decoded"
|
||||
assert pipeline.text_encoder.forward() == "encoded"
|
||||
assert "AnimaPipeline.vae.decode" in pipeline.stage_durations
|
||||
assert "AnimaPipeline.text_encoder.forward" in pipeline.stage_durations
|
||||
|
||||
|
||||
def _make_anima_forward_probe():
|
||||
pipeline = AnimaPipeline.__new__(AnimaPipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline.vae_scale_factor = 8
|
||||
pipeline.transformer = SimpleNamespace(dtype=torch.float32)
|
||||
pipeline.text_encoder = SimpleNamespace(dtype=torch.float32)
|
||||
pipeline._current_timestep = None
|
||||
pipeline._num_timesteps = 0
|
||||
pipeline._guidance_scale = 0.0
|
||||
captured = {}
|
||||
|
||||
def encode_prompt(**_kwargs):
|
||||
return {
|
||||
"qwen_prompt_embeds": torch.zeros(1, 2, 4),
|
||||
"qwen_attention_mask": torch.ones(1, 2),
|
||||
"t5_input_ids": torch.ones(1, 2, dtype=torch.long),
|
||||
"t5_attention_mask": torch.ones(1, 2),
|
||||
"negative_qwen_prompt_embeds": torch.zeros(1, 2, 4),
|
||||
"negative_qwen_attention_mask": torch.ones(1, 2),
|
||||
"negative_t5_input_ids": torch.ones(1, 2, dtype=torch.long),
|
||||
"negative_t5_attention_mask": torch.ones(1, 2),
|
||||
}
|
||||
|
||||
def condition_prompt_embeds(**_kwargs):
|
||||
return torch.zeros(1, 2, 4)
|
||||
|
||||
def prepare_latents(**kwargs):
|
||||
captured["prepare_latents"] = kwargs
|
||||
return torch.zeros(1, 16, 1, kwargs["height"] // 8, kwargs["width"] // 8)
|
||||
|
||||
def diffuse(**kwargs):
|
||||
captured["diffuse"] = kwargs
|
||||
return kwargs["latents"]
|
||||
|
||||
pipeline.encode_prompt = encode_prompt
|
||||
pipeline.condition_prompt_embeds = condition_prompt_embeds
|
||||
pipeline.prepare_latents = prepare_latents
|
||||
pipeline.prepare_timesteps = lambda **_kwargs: (torch.ones(1), 1)
|
||||
pipeline.diffuse = diffuse
|
||||
|
||||
def decode_latents(latents, output_type="pil"):
|
||||
captured["output_type"] = output_type
|
||||
return DiffusionOutput(output=latents)
|
||||
|
||||
pipeline.decode_latents = decode_latents
|
||||
return pipeline, captured
|
||||
|
||||
|
||||
def test_native_anima_forward_uses_official_default_resolution() -> None:
|
||||
"""Forward uses Anima's reference resolution when none is requested."""
|
||||
pipeline, captured = _make_anima_forward_probe()
|
||||
req = OmniDiffusionRequest(
|
||||
prompt="a red cube",
|
||||
sampling_params=OmniDiffusionSamplingParams(),
|
||||
request_id="anima-defaults",
|
||||
)
|
||||
|
||||
pipeline.forward(DiffusionRequestBatch([req]))
|
||||
|
||||
assert captured["prepare_latents"]["height"] == 1024
|
||||
assert captured["prepare_latents"]["width"] == 1024
|
||||
|
||||
|
||||
def test_native_anima_forward_honors_sampling_output_type() -> None:
|
||||
"""Request-level output type controls Anima decoding."""
|
||||
pipeline, captured = _make_anima_forward_probe()
|
||||
req = OmniDiffusionRequest(
|
||||
prompt="a red cube",
|
||||
sampling_params=OmniDiffusionSamplingParams(output_type="latent"),
|
||||
request_id="anima-output-type",
|
||||
)
|
||||
|
||||
outputs = pipeline.forward(DiffusionRequestBatch([req]))
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert isinstance(outputs[0], DiffusionOutput)
|
||||
assert outputs[0].output.shape == (1, 16, 1, 128, 128)
|
||||
assert captured["output_type"] == "latent"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("request_count", [0, 2])
|
||||
def test_native_anima_forward_rejects_non_single_request_batches(request_count: int) -> None:
|
||||
pipeline, captured = _make_anima_forward_probe()
|
||||
requests = [
|
||||
OmniDiffusionRequest(
|
||||
prompt="a red cube",
|
||||
sampling_params=OmniDiffusionSamplingParams(),
|
||||
request_id=f"anima-{index}",
|
||||
)
|
||||
for index in range(request_count)
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError, match="supports one request per forward"):
|
||||
pipeline.forward(DiffusionRequestBatch(requests))
|
||||
|
||||
assert captured == {}
|
||||
|
||||
|
||||
def test_native_anima_explicit_guidance_scale_drives_cfg_multiplier() -> None:
|
||||
"""An explicit guidance scale controls native classifier-free guidance."""
|
||||
pipeline, captured = _make_anima_forward_probe()
|
||||
req = OmniDiffusionRequest(
|
||||
prompt="a red cube",
|
||||
sampling_params=OmniDiffusionSamplingParams(guidance_scale=5.0),
|
||||
request_id="anima-guidance",
|
||||
)
|
||||
|
||||
pipeline.forward(DiffusionRequestBatch([req]))
|
||||
|
||||
assert captured["diffuse"]["do_true_cfg"] is True
|
||||
assert captured["diffuse"]["true_cfg_scale"] == 5.0
|
||||
|
||||
|
||||
def test_native_anima_true_cfg_scale_overrides_guidance_multiplier() -> None:
|
||||
"""An explicit true-CFG scale overrides the guidance-scale multiplier."""
|
||||
pipeline, captured = _make_anima_forward_probe()
|
||||
req = OmniDiffusionRequest(
|
||||
prompt="a red cube",
|
||||
sampling_params=OmniDiffusionSamplingParams(guidance_scale=5.0, true_cfg_scale=3.0),
|
||||
request_id="anima-true-cfg",
|
||||
)
|
||||
|
||||
pipeline.forward(DiffusionRequestBatch([req]))
|
||||
|
||||
assert captured["diffuse"]["do_true_cfg"] is True
|
||||
assert captured["diffuse"]["true_cfg_scale"] == 3.0
|
||||
|
||||
|
||||
def test_native_anima_cfg_equation() -> None:
|
||||
"""The denoising loop applies the standard true-CFG equation."""
|
||||
pipeline = AnimaPipeline.__new__(AnimaPipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline._interrupt = False
|
||||
|
||||
class MockProgressBar:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
pass
|
||||
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
pipeline.progress_bar = lambda **_kwargs: MockProgressBar()
|
||||
|
||||
class MockTransformer:
|
||||
dtype = torch.float32
|
||||
|
||||
def __call__(self, hidden_states, timestep, encoder_hidden_states, padding_mask, return_dict=False):
|
||||
# Return double the encoder_hidden_states to simulate transformer output
|
||||
return (encoder_hidden_states * 2.0,)
|
||||
|
||||
pipeline.transformer = MockTransformer()
|
||||
|
||||
class MockScheduler:
|
||||
def set_begin_index(self, index):
|
||||
pass
|
||||
|
||||
def step(self, noise_pred, t, latents, return_dict=False):
|
||||
# Return the noise_pred itself to inspect it
|
||||
return (noise_pred,)
|
||||
|
||||
pipeline.scheduler = MockScheduler()
|
||||
|
||||
prompt_embeds = torch.tensor([[[2.0, 3.0]]])
|
||||
negative_prompt_embeds = torch.tensor([[[1.0, 2.0]]])
|
||||
latents = torch.zeros(1, 16, 1, 16, 16)
|
||||
padding_mask = torch.zeros(1, 1, 16, 16)
|
||||
timesteps = torch.tensor([500.0])
|
||||
|
||||
# Cond = prompt_embeds * 2.0 = [4.0, 6.0]
|
||||
# Uncond = negative_prompt_embeds * 2.0 = [2.0, 4.0]
|
||||
# Expected noise_pred = uncond + true_cfg_scale * (cond - uncond)
|
||||
# = [2.0, 4.0] + 4.0 * ([4.0, 6.0] - [2.0, 4.0])
|
||||
# = [2.0, 4.0] + 4.0 * [2.0, 2.0]
|
||||
# = [2.0, 4.0] + [8.0, 8.0] = [10.0, 12.0]
|
||||
out_latents = pipeline.diffuse(
|
||||
prompt_embeds=prompt_embeds,
|
||||
negative_prompt_embeds=negative_prompt_embeds,
|
||||
latents=latents,
|
||||
padding_mask=padding_mask,
|
||||
timesteps=timesteps,
|
||||
do_true_cfg=True,
|
||||
true_cfg_scale=4.0,
|
||||
)
|
||||
|
||||
expected = torch.tensor([[[10.0, 12.0]]])
|
||||
assert torch.allclose(out_latents, expected)
|
||||
@@ -0,0 +1,439 @@
|
||||
#!/usr/bin/env python
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""AuK transformer parity: the correctness oracle for the in-tree port.
|
||||
|
||||
Checks that :class:`AuKTransformer` reproduces the upstream ``auk`` reference backbone
|
||||
on the released ``auk_base`` weights. Both models are built from the same
|
||||
state dict and fed the same inputs, under the reference's own inference
|
||||
configuration: fp32 weights with ``torch.autocast(bfloat16)``. That
|
||||
configuration matters. The reference keeps its rotary frequencies in a buffer,
|
||||
so casting its weights to bf16 would round both the frequencies and the
|
||||
integer positions and move the numbers for reasons unrelated to the port.
|
||||
|
||||
Cases:
|
||||
|
||||
* ``single`` - one conditional forward
|
||||
* ``cfg`` - the doubled ``cfg_infer`` forward, both branches
|
||||
* ``masked`` - batch of 2 with padded targets, references and text
|
||||
* ``rope`` - the local rotary implementation against ``x_transformers``, and
|
||||
its survival of a cast to bf16
|
||||
* ``sample`` - an 8-step Euler trajectory against the reference backbone
|
||||
driven by a transcription of upstream's ODE loop
|
||||
* ``generator`` - the sampler's two noise paths agreeing
|
||||
* ``cache`` - the text projection cache and ``clear_cache()``
|
||||
* ``mask flag`` - ``attn_mask_enabled=False`` reproducing the reference with
|
||||
its own flag off
|
||||
|
||||
This directory deliberately has no ``__init__.py``, unlike its siblings.
|
||||
pytest's prepend import mode puts the nearest non-package directory on
|
||||
``sys.path``, and an ``auk`` package here would shadow the upstream ``auk``
|
||||
distribution this test compares against.
|
||||
|
||||
Needs the released checkpoint and a GPU, so it runs locally rather than in
|
||||
CI. Point ``AUK_CKPT_DIR`` at a directory holding ``auk_base.safetensors`` and
|
||||
``config.yaml``; the test skips when that is unset or when the upstream
|
||||
``auk`` package is not importable::
|
||||
|
||||
AUK_CKPT_DIR=/path/to/ckpts/AuK CUDA_VISIBLE_DEVICES=6 \
|
||||
python -m pytest tests/diffusion/models/auk/test_auk_transformer_parity.py -v -s
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import yaml
|
||||
|
||||
from vllm_omni.diffusion.models.auk.auk_transformer import (
|
||||
AuKTransformer,
|
||||
Rotary,
|
||||
_apply_rope,
|
||||
dit_state_dict,
|
||||
sample_latents,
|
||||
)
|
||||
|
||||
CKPT_DIR = os.environ.get("AUK_CKPT_DIR")
|
||||
|
||||
# Tolerances. The port evaluates rotary embeddings in fp32 and skips the
|
||||
# reference's redundant uncond text projection, so the two graphs are not
|
||||
# bit-identical under autocast; they agree well inside bf16 noise.
|
||||
FORWARD_ATOL = 3e-2
|
||||
FORWARD_COS = 0.999
|
||||
SAMPLE_ATOL = 5e-2
|
||||
SAMPLE_COS = 0.999
|
||||
ROPE_ATOL = 1e-5
|
||||
|
||||
TARGET_FRAMES = 300
|
||||
REF_FRAMES = 150
|
||||
TEXT_TOKENS = 40
|
||||
SHORT_LENS = (220, 90, 25) # target, reference, text for the padded batch row
|
||||
TIME = 0.37
|
||||
SAMPLE_STEPS = 8
|
||||
SWAY_COEF = -1.0
|
||||
SEED = 1234
|
||||
DEVICE = "cuda:0"
|
||||
AUTOCAST_DTYPE = torch.bfloat16
|
||||
|
||||
|
||||
def _skip_reason() -> str | None:
|
||||
if CKPT_DIR is None:
|
||||
return "AUK_CKPT_DIR is not set"
|
||||
if not Path(CKPT_DIR, "auk_base.safetensors").is_file():
|
||||
return f"no auk_base.safetensors under {CKPT_DIR}"
|
||||
try:
|
||||
# Resolving the submodule, not just "auk", also catches the shadow an
|
||||
# __init__.py in this directory would create.
|
||||
upstream = importlib.util.find_spec("auk.model")
|
||||
except ModuleNotFoundError:
|
||||
upstream = None
|
||||
if upstream is None:
|
||||
return "the upstream 'auk' package is not importable or is shadowed by this directory"
|
||||
if not torch.cuda.is_available():
|
||||
return "no CUDA device"
|
||||
return None
|
||||
|
||||
|
||||
SKIP_REASON = _skip_reason()
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.local_model,
|
||||
pytest.mark.diffusion,
|
||||
pytest.mark.gpu,
|
||||
pytest.mark.cuda,
|
||||
pytest.mark.skipif(SKIP_REASON is not None, reason=str(SKIP_REASON)),
|
||||
]
|
||||
|
||||
|
||||
def _arch() -> dict:
|
||||
"""Read the backbone geometry out of the checkpoint's config."""
|
||||
config = yaml.safe_load(Path(CKPT_DIR, "config.yaml").read_text())["model"]
|
||||
return {
|
||||
"dim": config["arch"]["dim"],
|
||||
"heads": config["arch"]["heads"],
|
||||
"dim_head": config["arch"].get("dim_head", 64),
|
||||
"ff_mult": config["arch"]["ff_mult"],
|
||||
"text_hidden_dim": config["arch"]["text_hidden_dim"],
|
||||
"num_layers": config["arch"]["num_layers"],
|
||||
"num_single_layers": config["arch"]["num_single_layers"],
|
||||
"latent_dim": config["vae"]["latent_dim"],
|
||||
}
|
||||
|
||||
|
||||
def _stats(port: torch.Tensor, ref: torch.Tensor) -> tuple[float, float, float]:
|
||||
"""Return max abs diff, cosine similarity and the reference's RMS."""
|
||||
a = port.flatten().float()
|
||||
b = ref.flatten().float()
|
||||
return (
|
||||
(a - b).abs().max().item(),
|
||||
torch.nn.functional.cosine_similarity(a, b, dim=0).item(),
|
||||
b.pow(2).mean().sqrt().item(),
|
||||
)
|
||||
|
||||
|
||||
def _assert_close(name: str, port: torch.Tensor, ref: torch.Tensor, atol: float, min_cos: float) -> None:
|
||||
max_abs, cos, rms = _stats(port, ref)
|
||||
print(f"[{name}] max|Δ|={max_abs:.4e} cos={cos:.8f} ref_rms={rms:.4f} rel={max_abs / rms:.3e}")
|
||||
assert max_abs < atol, f"{name}: max|Δ|={max_abs:.4e} >= {atol:.1e} (reference rms {rms:.4f})"
|
||||
assert cos > min_cos, f"{name}: cos={cos:.8f} <= {min_cos}"
|
||||
|
||||
|
||||
def _autocast():
|
||||
return torch.autocast("cuda", dtype=AUTOCAST_DTYPE)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def models() -> tuple[torch.nn.Module, AuKTransformer]:
|
||||
"""The reference and the port, same weights, fp32 on the accelerator."""
|
||||
from auk.model.flux2_edit import Flux2Edit
|
||||
from safetensors.torch import load_file
|
||||
|
||||
arch = _arch()
|
||||
device = torch.device(DEVICE)
|
||||
state = dit_state_dict(load_file(str(Path(CKPT_DIR, "auk_base.safetensors")), device="cpu"))
|
||||
|
||||
reference = Flux2Edit(dropout=0.0, attn_backend="torch", attn_mask_enabled=True, **arch)
|
||||
reference.load_state_dict(state, strict=True)
|
||||
reference = reference.to(device).eval().requires_grad_(False)
|
||||
|
||||
port = AuKTransformer(**arch)
|
||||
# strict=True is the point of the port: every released tensor lands.
|
||||
port.load_state_dict(state, strict=True)
|
||||
port = port.to(device).eval().requires_grad_(False)
|
||||
|
||||
del state
|
||||
return reference, port
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def inputs() -> dict[str, torch.Tensor]:
|
||||
"""Fixed random inputs, as a batch of 2 whose second row is padded."""
|
||||
device = torch.device(DEVICE)
|
||||
generator = torch.Generator(device="cpu").manual_seed(SEED)
|
||||
arch = _arch()
|
||||
|
||||
def randn(*shape: int) -> torch.Tensor:
|
||||
return torch.randn(*shape, generator=generator, dtype=torch.float32).to(device)
|
||||
|
||||
def pad_mask(width: int, short: int) -> torch.Tensor:
|
||||
mask = torch.zeros(2, width, dtype=torch.bool, device=device)
|
||||
mask[0, :] = True
|
||||
mask[1, :short] = True
|
||||
return mask
|
||||
|
||||
mask2 = pad_mask(TARGET_FRAMES, SHORT_LENS[0])
|
||||
ref_mask2 = pad_mask(REF_FRAMES, SHORT_LENS[1])
|
||||
c_mask2 = pad_mask(TEXT_TOKENS, SHORT_LENS[2])
|
||||
|
||||
# Padding rows are all-zero in the text encoder's output, so keep the
|
||||
# inputs consistent with the masks.
|
||||
x2 = randn(2, TARGET_FRAMES, arch["latent_dim"]) * mask2.unsqueeze(-1)
|
||||
ref2 = randn(2, REF_FRAMES, arch["latent_dim"]) * ref_mask2.unsqueeze(-1)
|
||||
text2 = randn(2, TEXT_TOKENS, arch["text_hidden_dim"]) * c_mask2.unsqueeze(-1)
|
||||
|
||||
return {
|
||||
"x": x2[:1].contiguous(),
|
||||
"ref": ref2[:1].contiguous(),
|
||||
"text": text2[:1].contiguous(),
|
||||
"c_mask": c_mask2[:1].contiguous(),
|
||||
"x2": x2,
|
||||
"ref2": ref2,
|
||||
"text2": text2,
|
||||
"mask2": mask2,
|
||||
"ref_mask2": ref_mask2,
|
||||
"c_mask2": c_mask2,
|
||||
"time": torch.tensor(TIME, dtype=torch.float32, device=device),
|
||||
}
|
||||
|
||||
|
||||
def test_single_forward(models, inputs) -> None:
|
||||
"""One conditional forward: reference against port."""
|
||||
reference, port = models
|
||||
kwargs = dict(text=inputs["text"], time=inputs["time"], c_mask=inputs["c_mask"], ref=inputs["ref"])
|
||||
with torch.no_grad(), _autocast():
|
||||
want = reference(x=inputs["x"], **kwargs)
|
||||
got = port(inputs["x"], **kwargs)
|
||||
|
||||
assert got.shape == want.shape == (1, TARGET_FRAMES, port.latent_dim)
|
||||
_assert_close("single", got, want, FORWARD_ATOL, FORWARD_COS)
|
||||
|
||||
|
||||
def test_cfg_forward(models, inputs) -> None:
|
||||
"""The doubled cfg_infer forward, cond branch first."""
|
||||
reference, port = models
|
||||
kwargs = dict(
|
||||
text=inputs["text"],
|
||||
time=inputs["time"],
|
||||
c_mask=inputs["c_mask"],
|
||||
ref=inputs["ref"],
|
||||
cfg_infer=True,
|
||||
)
|
||||
with torch.no_grad(), _autocast():
|
||||
want = reference(x=inputs["x"], **kwargs)
|
||||
got = port(inputs["x"], **kwargs)
|
||||
|
||||
assert got.shape == want.shape == (2, TARGET_FRAMES, port.latent_dim)
|
||||
_assert_close("cfg-cond", got[:1], want[:1], FORWARD_ATOL, FORWARD_COS)
|
||||
_assert_close("cfg-uncond", got[1:], want[1:], FORWARD_ATOL, FORWARD_COS)
|
||||
|
||||
|
||||
def test_masked_batch_forward(models, inputs) -> None:
|
||||
"""Batch of 2 with padded targets, references and text.
|
||||
|
||||
Only valid target frames are compared. Padded frames are unconstrained:
|
||||
the reference leaves them to whatever masked attention emits there.
|
||||
"""
|
||||
reference, port = models
|
||||
kwargs = dict(
|
||||
text=inputs["text2"],
|
||||
time=inputs["time"],
|
||||
mask=inputs["mask2"],
|
||||
c_mask=inputs["c_mask2"],
|
||||
ref=inputs["ref2"],
|
||||
ref_mask=inputs["ref_mask2"],
|
||||
)
|
||||
with torch.no_grad(), _autocast():
|
||||
want = reference(x=inputs["x2"], **kwargs)
|
||||
got = port(inputs["x2"], **kwargs)
|
||||
|
||||
assert got.shape == want.shape == (2, TARGET_FRAMES, port.latent_dim)
|
||||
valid = inputs["mask2"].unsqueeze(-1).expand_as(got)
|
||||
_assert_close("masked", got[valid], want[valid], FORWARD_ATOL, FORWARD_COS)
|
||||
|
||||
|
||||
def _set_mask_flag(reference, port, enabled: bool) -> None:
|
||||
"""Flip attn_mask_enabled on both models in place, so no second copy is loaded."""
|
||||
for module in port.modules():
|
||||
if hasattr(module, "attn_mask_enabled"):
|
||||
module.attn_mask_enabled = enabled
|
||||
for blocks in (reference.transformer_blocks, reference.single_transformer_blocks):
|
||||
for block in blocks:
|
||||
block.attn.processor.attn_mask_enabled = enabled
|
||||
|
||||
|
||||
def test_mask_flag_disabled(models, inputs) -> None:
|
||||
"""``attn_mask_enabled=False`` must track the reference with its own flag off.
|
||||
|
||||
With the flag off both models run attention unmasked and still zero the
|
||||
padded outputs, so the padded batch is compared in full.
|
||||
"""
|
||||
reference, port = models
|
||||
kwargs = dict(
|
||||
text=inputs["text2"],
|
||||
time=inputs["time"],
|
||||
mask=inputs["mask2"],
|
||||
c_mask=inputs["c_mask2"],
|
||||
ref=inputs["ref2"],
|
||||
ref_mask=inputs["ref_mask2"],
|
||||
)
|
||||
with torch.no_grad(), _autocast():
|
||||
masked = port(inputs["x2"], **kwargs)
|
||||
_set_mask_flag(reference, port, False)
|
||||
try:
|
||||
want = reference(x=inputs["x2"], **kwargs)
|
||||
got = port(inputs["x2"], **kwargs)
|
||||
finally:
|
||||
_set_mask_flag(reference, port, True)
|
||||
|
||||
_assert_close("mask-flag-off", got, want, FORWARD_ATOL, FORWARD_COS)
|
||||
# Guard against a vacuous pass: the flag has to actually change something.
|
||||
assert (got.float() - masked.float()).abs().max().item() > 1e-3
|
||||
|
||||
|
||||
def test_sampler_generator_matches_seed(models, inputs) -> None:
|
||||
"""A generator seeded with 0 must draw exactly what ``seed=0`` draws."""
|
||||
_, port = models
|
||||
common = dict(
|
||||
gen_frames=TARGET_FRAMES,
|
||||
nfe=2,
|
||||
cfg_strength=0.0,
|
||||
sway_sampling_coef=SWAY_COEF,
|
||||
text=inputs["text"],
|
||||
c_mask=inputs["c_mask"],
|
||||
ref=inputs["ref"],
|
||||
ref_mask=None,
|
||||
)
|
||||
with torch.no_grad(), _autocast():
|
||||
by_seed = sample_latents(port, seed=0, **common)
|
||||
by_generator = sample_latents(port, generator=torch.Generator(device=DEVICE).manual_seed(0), **common)
|
||||
|
||||
print(f"[generator] max|Δ|={(by_generator.float() - by_seed.float()).abs().max().item():.4e}")
|
||||
torch.testing.assert_close(by_generator, by_seed, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_cache_needs_clearing(models, inputs) -> None:
|
||||
"""The cached text projection is reused until ``clear_cache()``.
|
||||
|
||||
``sample_latents`` clears on the way out, so a pipeline that reuses the
|
||||
module across requests is safe without doing anything. A caller driving
|
||||
``forward(cache=True)`` itself has to clear when the text changes.
|
||||
"""
|
||||
_, port = models
|
||||
other_text = inputs["text"] * 0.5
|
||||
kwargs = dict(time=inputs["time"], c_mask=inputs["c_mask"], ref=inputs["ref"], cfg_infer=True)
|
||||
|
||||
port.clear_cache()
|
||||
with torch.no_grad(), _autocast():
|
||||
want = port(inputs["x"], inputs["text"], **kwargs)
|
||||
port(inputs["x"], other_text, cache=True, **kwargs)
|
||||
stale = port(inputs["x"], inputs["text"], cache=True, **kwargs)
|
||||
port.clear_cache()
|
||||
fresh = port(inputs["x"], inputs["text"], cache=True, **kwargs)
|
||||
port.clear_cache()
|
||||
|
||||
# The cache is real: the second call reused the first call's projection.
|
||||
assert (stale.float() - want.float()).abs().max().item() > 1e-3
|
||||
torch.testing.assert_close(fresh, want, rtol=0, atol=0)
|
||||
assert port.text_cond is None and port.text_uncond is None
|
||||
|
||||
|
||||
def test_rope_matches_x_transformers() -> None:
|
||||
"""The local rotary implementation against x_transformers, in fp32."""
|
||||
from x_transformers.x_transformers import RotaryEmbedding, apply_rotary_pos_emb
|
||||
|
||||
device = torch.device(DEVICE)
|
||||
dim_head, seq_len = 64, TEXT_TOKENS + REF_FRAMES + TARGET_FRAMES
|
||||
q = torch.randn(2, 24, seq_len, dim_head, dtype=torch.float32, device=device)
|
||||
|
||||
freqs = RotaryEmbedding(dim_head).to(device).forward_from_seq_len(seq_len)[0]
|
||||
want = apply_rotary_pos_emb(q, freqs)
|
||||
got = _apply_rope(q, Rotary(dim_head).to(device)(seq_len))
|
||||
|
||||
max_abs, cos, _ = _stats(got, want)
|
||||
print(f"[rope] max|Δ|={max_abs:.4e} cos={cos:.10f}")
|
||||
assert max_abs < ROPE_ATOL, f"rope: max|Δ|={max_abs:.4e} >= {ROPE_ATOL:.1e}"
|
||||
|
||||
|
||||
def test_rope_survives_half_precision_cast() -> None:
|
||||
"""Casting the module to bf16 must not round the rotary frequencies.
|
||||
|
||||
The reference stores them in a buffer, so ``.to(bfloat16)`` rounds both the
|
||||
frequencies and the integer positions and silently changes every rotation.
|
||||
The port recomputes them instead.
|
||||
"""
|
||||
dim_head, seq_len = 64, TEXT_TOKENS + REF_FRAMES + TARGET_FRAMES
|
||||
exact = Rotary(dim_head)(seq_len)
|
||||
got = Rotary(dim_head).to(torch.bfloat16)(seq_len)
|
||||
|
||||
assert got.dtype == torch.float32
|
||||
torch.testing.assert_close(got, exact, rtol=0, atol=0)
|
||||
|
||||
|
||||
def _reference_euler(reference, *, x, text, c_mask, ref, steps, cfg_strength, sway) -> torch.Tensor:
|
||||
"""Upstream's ODE loop, transcribed.
|
||||
|
||||
``CFMEdit.sample`` hands the backbone to ``torchdiffeq.odeint(method=
|
||||
"euler")``, which on a fixed grid is ``y += (t1 - t0) * f(t0, y)``.
|
||||
Constructing a real ``CFMEdit`` would drag in the Qwen-Omni text encoder,
|
||||
so only the backbone here comes from upstream.
|
||||
"""
|
||||
t = torch.linspace(0, 1, steps + 1, device=x.device, dtype=torch.float32)
|
||||
if sway is not None:
|
||||
t = t + sway * (torch.cos(math.pi / 2 * t) - 1 + t)
|
||||
|
||||
shared = dict(text=text, c_mask=c_mask, ref=ref, mask=None, ref_mask=None, cache=True)
|
||||
for i in range(steps):
|
||||
if cfg_strength < 1e-5:
|
||||
v = reference(x=x, time=t[i], **shared)
|
||||
else:
|
||||
pred = reference(x=x, time=t[i], cfg_infer=True, **shared)
|
||||
v_cond, v_uncond = torch.chunk(pred, 2, dim=0)
|
||||
v = v_cond + (v_cond - v_uncond) * cfg_strength
|
||||
x = x + (t[i + 1] - t[i]) * v
|
||||
reference.clear_cache()
|
||||
return x
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cfg_strength", [0.0, 2.0])
|
||||
def test_euler_sample(models, inputs, cfg_strength: float) -> None:
|
||||
"""An 8-step Euler trajectory, with and without guidance."""
|
||||
reference, port = models
|
||||
shared = dict(text=inputs["text"], c_mask=inputs["c_mask"], ref=inputs["ref"])
|
||||
|
||||
with torch.no_grad(), _autocast():
|
||||
got = sample_latents(
|
||||
port,
|
||||
gen_frames=TARGET_FRAMES,
|
||||
ref_mask=None,
|
||||
nfe=SAMPLE_STEPS,
|
||||
cfg_strength=cfg_strength,
|
||||
sway_sampling_coef=SWAY_COEF,
|
||||
seed=SEED,
|
||||
**shared,
|
||||
)
|
||||
# Re-seeding reproduces the noise sample_latents drew for itself.
|
||||
torch.manual_seed(SEED)
|
||||
noise = torch.randn(
|
||||
TARGET_FRAMES, port.latent_dim, device=inputs["ref"].device, dtype=inputs["ref"].dtype
|
||||
).unsqueeze(0)
|
||||
want = _reference_euler(
|
||||
reference, x=noise, steps=SAMPLE_STEPS, cfg_strength=cfg_strength, sway=SWAY_COEF, **shared
|
||||
)
|
||||
|
||||
assert got.shape == want.shape == (1, TARGET_FRAMES, port.latent_dim)
|
||||
_assert_close(f"sample-cfg{cfg_strength:g}", got, want, SAMPLE_ATOL, SAMPLE_COS)
|
||||
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env python
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""AuK VAE parity: the numeric oracle for the vendored BigVGAN-flow codec.
|
||||
|
||||
Checks ``AuKVAE`` against the AuK reference implementation on real speech, with
|
||||
both models loaded from the same released ``vae.safetensors``:
|
||||
|
||||
* encoder statistics (mean / log-std) agree,
|
||||
* ``encode(sample=False)`` reproduces the reference mean path,
|
||||
* ``encode(sample=True)`` reproduces ``encoding_and_normalization`` for a
|
||||
matching RNG seed,
|
||||
* ``decode`` reproduces ``denormalize`` + ``inference_from_latents``, in fp32
|
||||
and (reported, not gated tightly) under bf16 autocast,
|
||||
* the waveform survives a mean-path round trip.
|
||||
|
||||
Runs on GPU in fp32 and needs both the released checkpoint and the reference
|
||||
``auk`` package importable, so it is a local_model test rather than CI. Point
|
||||
``AUK_CKPT_DIR`` at the directory holding ``vae.safetensors`` and ``AUK_REF_WAV``
|
||||
at a speech clip (default ``$AUK_CKPT_DIR/ref.wav``)::
|
||||
|
||||
AUK_CKPT_DIR=... AUK_REF_WAV=... python -m pytest \
|
||||
tests/diffusion/models/auk/test_auk_vae_parity.py -v -s
|
||||
|
||||
Unlike its sibling directories this one carries no ``__init__.py`` on purpose: it would put
|
||||
``tests/diffusion/models`` on ``sys.path`` ahead of site-packages, and this directory's own name
|
||||
would then shadow the reference ``auk`` package the parity check imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import soundfile as sf
|
||||
import torch
|
||||
import torchaudio.functional as AF
|
||||
|
||||
from vllm_omni.diffusion.models.auk.auk_vae import AuKVAE
|
||||
|
||||
pytestmark = [pytest.mark.local_model, pytest.mark.diffusion, pytest.mark.gpu, pytest.mark.cuda]
|
||||
|
||||
# The released model.vae.model_init_kwargs, shared by both models under test.
|
||||
VAE_CONFIG = {
|
||||
"upsample_rates": [5, 4, 3, 2, 2, 2],
|
||||
"upsample_kernel_sizes": [10, 8, 6, 4, 4, 4],
|
||||
"upsample_initial_channel": 1536,
|
||||
"resblock_kernel_sizes": [3, 7, 11],
|
||||
"resblock_dilation_sizes": [[1, 3, 5], [1, 3, 5], [1, 3, 5]],
|
||||
"downsample_rates": [2, 2, 2, 3, 4, 5],
|
||||
"downsample_channels": [12, 24, 48, 96, 192, 384, 768],
|
||||
"snake_logscale": True,
|
||||
"latent_dim": 64,
|
||||
"use_vae": True,
|
||||
"causal": True,
|
||||
"flow_hidden_channels": 256,
|
||||
"act_causal": True,
|
||||
}
|
||||
|
||||
SAMPLE_RATE = 24000
|
||||
HOP_SIZE = 480
|
||||
LATENT_DIM = 64
|
||||
SEED = 1234
|
||||
# Keep the clip short: the parity run shares its GPU with other jobs.
|
||||
MAX_SECONDS = 4.0
|
||||
TOLERANCE = 1e-5
|
||||
DEVICE = "cuda"
|
||||
|
||||
_CKPT_DIR = os.environ.get("AUK_CKPT_DIR")
|
||||
_VAE_PATH = Path(_CKPT_DIR) / "vae.safetensors" if _CKPT_DIR else None
|
||||
_REF_WAV = Path(os.environ.get("AUK_REF_WAV") or (Path(_CKPT_DIR) / "ref.wav" if _CKPT_DIR else "unset"))
|
||||
|
||||
|
||||
def _reference_importable() -> bool:
|
||||
"""Resolve ``auk.model``, not ``auk``.
|
||||
|
||||
An ``__init__.py`` in this directory would make its own name resolve as ``auk`` and satisfy a
|
||||
top-level probe, leaving the fixtures to fail at setup instead of skipping. The submodule
|
||||
probe fails in that case, which is the honest answer.
|
||||
"""
|
||||
try:
|
||||
return importlib.util.find_spec("auk.model") is not None
|
||||
except (ImportError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _skip_reason() -> str | None:
|
||||
if not _CKPT_DIR:
|
||||
return "set AUK_CKPT_DIR to the directory holding vae.safetensors"
|
||||
if _VAE_PATH is None or not _VAE_PATH.is_file():
|
||||
return f"no vae.safetensors under {_CKPT_DIR}"
|
||||
if not _REF_WAV.is_file():
|
||||
return f"no reference clip at {_REF_WAV} (set AUK_REF_WAV)"
|
||||
if not _reference_importable():
|
||||
return "cannot import the reference auk.model package (an __init__.py here would shadow it)"
|
||||
if not torch.cuda.is_available():
|
||||
return "parity runs in fp32 on GPU"
|
||||
return None
|
||||
|
||||
|
||||
pytestmark.append(pytest.mark.skipif(_skip_reason() is not None, reason=_skip_reason() or ""))
|
||||
|
||||
|
||||
def _max_abs(lhs: torch.Tensor, rhs: torch.Tensor) -> float:
|
||||
return (lhs.float() - rhs.float()).abs().max().item()
|
||||
|
||||
|
||||
def _snr_db(signal: torch.Tensor, estimate: torch.Tensor) -> float:
|
||||
noise = (signal.float() - estimate.float()).pow(2).sum()
|
||||
return (10.0 * torch.log10(signal.float().pow(2).sum() / noise)).item()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def clip() -> torch.Tensor:
|
||||
"""Mono 24 kHz speech, trimmed to a whole number of latent frames."""
|
||||
audio, sample_rate = sf.read(str(_REF_WAV), dtype="float32", always_2d=True) # (T, C)
|
||||
wav = torch.from_numpy(audio).mean(dim=1, keepdim=True).transpose(0, 1)
|
||||
if sample_rate != SAMPLE_RATE:
|
||||
wav = AF.resample(wav, sample_rate, SAMPLE_RATE)
|
||||
samples = min(int(MAX_SECONDS * SAMPLE_RATE), wav.size(-1)) // HOP_SIZE * HOP_SIZE
|
||||
wav = wav[:, :samples].contiguous().to(DEVICE)
|
||||
print(f"\n[clip] {_REF_WAV.name}: {samples} samples, {samples / SAMPLE_RATE:.2f} s, {samples // HOP_SIZE} frames")
|
||||
return wav
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def reference(clip: torch.Tensor) -> dict[str, torch.Tensor]:
|
||||
"""Reference tensors from the upstream model, which is then released so one model fits at a time."""
|
||||
from auk.model.vae import load_vae_model
|
||||
from auk.model.vae.bigvgan_flow_vae import BigVGANFlowVAEConfig
|
||||
|
||||
model = load_vae_model(
|
||||
vae_name="BigVGANFlowVAE",
|
||||
vae_cfg=BigVGANFlowVAEConfig.from_dict(VAE_CONFIG),
|
||||
vae_ckpt=str(_VAE_PATH),
|
||||
map_location="cpu",
|
||||
)
|
||||
model = model.to(DEVICE).eval()
|
||||
model.requires_grad_(False)
|
||||
|
||||
with torch.no_grad():
|
||||
mean, log_std = model.audio_encoder(clip.unsqueeze(1)).chunk(2, 1)
|
||||
scale = torch.sqrt(model.global_log_std.float())
|
||||
mean_latents = (mean.transpose(1, 2).float() - model.global_mean.float()) / scale
|
||||
torch.manual_seed(SEED)
|
||||
sampled_latents, _ = model.encoding_and_normalization(clip.unsqueeze(1))
|
||||
decoded = model.inference_from_latents(model.denormalize(mean_latents).permute(0, 2, 1))
|
||||
|
||||
tensors = {
|
||||
"mean": mean.clone(),
|
||||
"log_std": log_std.clone(),
|
||||
"mean_latents": mean_latents.clone(),
|
||||
"sampled_latents": sampled_latents.clone(),
|
||||
"decoded": decoded.squeeze(1).clone(),
|
||||
}
|
||||
del model
|
||||
torch.accelerator.empty_cache()
|
||||
return tensors
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def port(reference: dict[str, torch.Tensor]) -> tuple[AuKVAE, list[str], list[str]]:
|
||||
"""The vendored module, loaded from the same checkpoint. Depends on ``reference`` for load ordering.
|
||||
|
||||
Placed on the accelerator before loading so weight norm is folded there; see ``load_weights``.
|
||||
"""
|
||||
model = AuKVAE.from_config(VAE_CONFIG).to(DEVICE)
|
||||
missing, unexpected = model.load_weights(_VAE_PATH)
|
||||
model = model.eval()
|
||||
model.requires_grad_(False)
|
||||
return model, missing, unexpected
|
||||
|
||||
|
||||
def test_checkpoint_covers_every_inference_tensor(port: tuple[AuKVAE, list[str], list[str]]) -> None:
|
||||
model, missing, unexpected = port
|
||||
print(f"[keys] missing={len(missing)} unexpected={len(unexpected)}")
|
||||
print(f"[keys] unexpected prefixes={sorted({key.split('.')[0] for key in unexpected})}")
|
||||
assert missing == []
|
||||
assert unexpected and all(key.startswith("flow.") for key in unexpected)
|
||||
assert (model.hop_size, model.sample_rate, model.latent_dim) == (HOP_SIZE, SAMPLE_RATE, LATENT_DIM)
|
||||
|
||||
|
||||
def test_encoder_statistics_match(
|
||||
clip: torch.Tensor, reference: dict[str, torch.Tensor], port: tuple[AuKVAE, list[str], list[str]]
|
||||
) -> None:
|
||||
model = port[0]
|
||||
with torch.no_grad():
|
||||
mean, log_std = model.audio_encoder(clip.unsqueeze(1)).chunk(2, 1)
|
||||
mean_diff = _max_abs(mean, reference["mean"])
|
||||
log_std_diff = _max_abs(log_std, reference["log_std"])
|
||||
print(f"[encoder] max|dmean|={mean_diff:.3e} max|dlog_std|={log_std_diff:.3e}")
|
||||
assert mean_diff < TOLERANCE
|
||||
assert log_std_diff < TOLERANCE
|
||||
# Folded and hook-computed weights are bit-identical on one device, so the whole encoder is.
|
||||
assert (mean_diff, log_std_diff) == (0.0, 0.0)
|
||||
|
||||
|
||||
def test_encode_mean_path_matches(
|
||||
clip: torch.Tensor, reference: dict[str, torch.Tensor], port: tuple[AuKVAE, list[str], list[str]]
|
||||
) -> None:
|
||||
model = port[0]
|
||||
with torch.no_grad():
|
||||
latents = model.encode(clip)
|
||||
assert latents.shape == (1, clip.size(-1) // HOP_SIZE, LATENT_DIM)
|
||||
diff = _max_abs(latents, reference["mean_latents"])
|
||||
print(f"[encode mean] shape={tuple(latents.shape)} max|d|={diff:.3e}")
|
||||
assert diff == 0.0
|
||||
|
||||
|
||||
def test_encode_sampled_matches_seeded_reference(
|
||||
clip: torch.Tensor, reference: dict[str, torch.Tensor], port: tuple[AuKVAE, list[str], list[str]]
|
||||
) -> None:
|
||||
model = port[0]
|
||||
generator = torch.Generator(device=DEVICE)
|
||||
generator.manual_seed(SEED)
|
||||
with torch.no_grad():
|
||||
latents = model.encode(clip, sample=True, generator=generator)
|
||||
diff = _max_abs(latents, reference["sampled_latents"])
|
||||
spread = _max_abs(latents, reference["mean_latents"])
|
||||
print(f"[encode sample] max|d vs seeded reference|={diff:.3e} max|d vs mean path|={spread:.3e}")
|
||||
assert diff < TOLERANCE
|
||||
assert diff == 0.0 # a fresh device Generator replays the default one after torch.manual_seed
|
||||
assert spread > TOLERANCE # the draw actually moved the latents
|
||||
|
||||
|
||||
def test_decode_matches(reference: dict[str, torch.Tensor], port: tuple[AuKVAE, list[str], list[str]]) -> None:
|
||||
model = port[0]
|
||||
latents = reference["mean_latents"]
|
||||
with torch.no_grad():
|
||||
wav = model.decode(latents)
|
||||
assert wav.shape == (1, latents.size(1) * HOP_SIZE)
|
||||
diff = _max_abs(wav, reference["decoded"])
|
||||
print(f"[decode fp32] shape={tuple(wav.shape)} max|d|={diff:.3e}")
|
||||
assert diff < TOLERANCE
|
||||
assert diff == 0.0
|
||||
|
||||
with torch.no_grad(), torch.autocast(device_type="cuda", dtype=torch.bfloat16):
|
||||
wav_bf16 = model.decode(latents)
|
||||
bf16_diff = _max_abs(wav_bf16, reference["decoded"])
|
||||
print(f"[decode bf16 autocast] max|d| vs fp32 reference={bf16_diff:.3e} snr={_snr_db(wav, wav_bf16):.2f} dB")
|
||||
assert torch.isfinite(wav_bf16).all()
|
||||
assert bf16_diff < 0.5 # sanity only: bf16 is reported, not held to fp32 parity
|
||||
|
||||
|
||||
def test_round_trip_snr(clip: torch.Tensor, port: tuple[AuKVAE, list[str], list[str]]) -> None:
|
||||
model = port[0]
|
||||
with torch.no_grad():
|
||||
wav = model.decode(model.encode(clip))
|
||||
assert wav.shape == clip.shape
|
||||
snr = _snr_db(clip, wav)
|
||||
print(f"[round trip] snr={snr:.2f} dB peak={wav.abs().max().item():.4f}")
|
||||
assert snr > 10.0
|
||||
@@ -0,0 +1,514 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""AuK stage-1 pipeline: request parsing on CPU, generation parity on a GPU.
|
||||
|
||||
The CPU tests stub the transformer, the codec and the ODE so they exercise only
|
||||
what the pipeline itself owns: how a request turns into a target length, a
|
||||
sampling schedule and a noise generator.
|
||||
|
||||
The parity test replays the saved upstream reference for the zero-shot TTS case
|
||||
and reports latent, per-frame and waveform distances rather than asserting bit
|
||||
equality, because two differences live in the conditioning rather than in the
|
||||
port: the saved text condition is an fp32 thinker forward while the reference
|
||||
generated under bf16 autocast, and the reference's stochastic VAE draw came from
|
||||
the process-global RNG seeded at 1234.
|
||||
|
||||
It replays with ``vae_sample=False``. That is the deliberate choice: the
|
||||
reference re-seeds the global RNG immediately before drawing the ODE noise, so a
|
||||
per-request generator reproduces that noise exactly (verified bit-identical) as
|
||||
long as nothing else has drawn from it. Sampling the VAE posterior consumes the
|
||||
generator first, which moves the noise and produces a different, equally valid
|
||||
realization; comparing latents then measures the noise, not the port. The
|
||||
posterior mean costs a reference latent that is 0.045 MSE (0.969 cosine) away
|
||||
from the draw the reference used, which is the smaller of the two errors.
|
||||
Generate the reference artifacts once with the upstream package installed
|
||||
(``tools/auk_parity_reference.py --auk-repo ... --ckpt-dir ... --qwen-dir ...
|
||||
--out /path/to/auk-parity``; it writes ``parity_ref/<variant>`` and
|
||||
``fusion_ref``), then run::
|
||||
|
||||
AUK_OMNI_CKPT_DIR=/path/to/auk-omni-base \
|
||||
AUK_PARITY_REF=/path/to/auk-parity/parity_ref/base \
|
||||
python -m pytest -s tests/diffusion/models/auk/test_pipeline_auk.py
|
||||
|
||||
``AUK_OMNI_CKPT_DIR`` is the assembled directory this pipeline loads, which is
|
||||
not the same thing as the released AuK snapshot that the transformer and codec
|
||||
parity tests in this directory read from ``AUK_CKPT_DIR``. ``AUK_CKPT_DIR`` is
|
||||
accepted as a fallback, and the test skips rather than fails when the directory
|
||||
it resolves to is not an assembled one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import soundfile as sf
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
import torchaudio
|
||||
from torch import nn
|
||||
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.models.auk import pipeline_auk
|
||||
from vllm_omni.diffusion.models.auk.pipeline_auk import AuKPipeline
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
LATENT_DIM = 64
|
||||
HOP = 480
|
||||
SAMPLE_RATE = 24000
|
||||
TEXT_HIDDEN_DIM = 2048
|
||||
|
||||
# The distilled student's grid, exact values from the released checkpoint.
|
||||
FLASH_T_GRID = [0.0, 0.07612049579620361, 0.2928932309150696, 0.6173166036605835, 1.0]
|
||||
|
||||
CKPT_DIR = os.environ.get("AUK_OMNI_CKPT_DIR") or os.environ.get("AUK_CKPT_DIR")
|
||||
PARITY_REF = os.environ.get("AUK_PARITY_REF")
|
||||
# An assembled directory is the only thing this pipeline can load.
|
||||
IS_ASSEMBLED = bool(CKPT_DIR) and (Path(CKPT_DIR).expanduser() / "config.json").is_file()
|
||||
|
||||
# Measured on the released base checkpoint: mean frame cosine 0.961 to 0.968,
|
||||
# relative latent MSE 0.051 to 0.063, log-mel distance 0.34. That spread is
|
||||
# bf16 against fp32 DiT weights plus where the codec folds its weight norm,
|
||||
# neither of which is worth gating on. A port that has lost the conditioning
|
||||
# sits at 0.110 cosine, 1.82 relative MSE and 2.88 log-mel, so these gates
|
||||
# still separate the two by an order of magnitude.
|
||||
PARITY_MIN_FRAME_COSINE = 0.93
|
||||
PARITY_MAX_RELATIVE_MSE = 0.15
|
||||
PARITY_MAX_MEL_DISTANCE = 0.6
|
||||
|
||||
|
||||
class _StubTransformer(nn.Module):
|
||||
"""Records its construction arguments; owns one weight so strict loading runs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
dim: int,
|
||||
heads: int,
|
||||
dim_head: int,
|
||||
ff_mult: float,
|
||||
latent_dim: int,
|
||||
text_hidden_dim: int,
|
||||
num_layers: int,
|
||||
num_single_layers: int,
|
||||
attn_mask_enabled: bool = True,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.init_kwargs = {
|
||||
"dim": dim,
|
||||
"heads": heads,
|
||||
"dim_head": dim_head,
|
||||
"ff_mult": ff_mult,
|
||||
"latent_dim": latent_dim,
|
||||
"text_hidden_dim": text_hidden_dim,
|
||||
"num_layers": num_layers,
|
||||
"num_single_layers": num_single_layers,
|
||||
"attn_mask_enabled": attn_mask_enabled,
|
||||
}
|
||||
self.latent_dim = latent_dim
|
||||
self.proj = nn.Linear(latent_dim, latent_dim, bias=False)
|
||||
|
||||
|
||||
class _StubVAE(nn.Module):
|
||||
"""Deterministic stand-in for the codec, one latent frame per hop."""
|
||||
|
||||
hop_size = HOP
|
||||
sample_rate = SAMPLE_RATE
|
||||
latent_dim = LATENT_DIM
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.encode_calls: list[dict[str, Any]] = []
|
||||
self.decode_calls = 0
|
||||
self.weights_path: str | None = None
|
||||
self.config: dict[str, Any] = {}
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, cfg: dict[str, Any]) -> _StubVAE:
|
||||
vae = cls()
|
||||
vae.config = dict(cfg)
|
||||
return vae
|
||||
|
||||
def load_weights(self, path: str, **_: Any) -> tuple[list[str], list[str]]:
|
||||
self.weights_path = str(path)
|
||||
return [], []
|
||||
|
||||
def encode(
|
||||
self,
|
||||
wav: torch.Tensor,
|
||||
*,
|
||||
sample: bool = False,
|
||||
generator: torch.Generator | None = None,
|
||||
) -> torch.Tensor:
|
||||
frames = wav.shape[-1] // HOP
|
||||
self.encode_calls.append({"samples": int(wav.shape[-1]), "sample": sample, "generator": generator})
|
||||
if sample:
|
||||
return torch.randn(1, frames, LATENT_DIM, generator=generator, device=wav.device)
|
||||
return torch.zeros(1, frames, LATENT_DIM, device=wav.device)
|
||||
|
||||
def decode(self, latents: torch.Tensor) -> torch.Tensor:
|
||||
self.decode_calls += 1
|
||||
return torch.zeros(1, latents.shape[1] * HOP, device=latents.device)
|
||||
|
||||
|
||||
def _stub_sampler(calls: list[dict[str, Any]]):
|
||||
"""Record the ODE arguments and return noise drawn from the request generator."""
|
||||
|
||||
def sample_latents(dit: nn.Module, **kwargs: Any) -> torch.Tensor:
|
||||
calls.append(kwargs)
|
||||
shape = (1, kwargs["gen_frames"], kwargs["latent_dim"])
|
||||
generator = kwargs.get("generator")
|
||||
if generator is None:
|
||||
return torch.zeros(*shape, device=kwargs["device"], dtype=kwargs["dtype"])
|
||||
return torch.randn(*shape, generator=generator, device=kwargs["device"], dtype=kwargs["dtype"])
|
||||
|
||||
return sample_latents
|
||||
|
||||
|
||||
def _write_checkpoint(root: Path, variant: str) -> Path:
|
||||
"""Write a tiny assembled checkpoint the stubs can load."""
|
||||
|
||||
from safetensors.torch import save_file
|
||||
|
||||
model_dir = root / f"auk-{variant}"
|
||||
model_dir.mkdir(parents=True, exist_ok=True)
|
||||
config = {
|
||||
"model_type": "auk",
|
||||
"architectures": ["AuKForConditionalGeneration"],
|
||||
"dit": {
|
||||
"dim": 16,
|
||||
"heads": 2,
|
||||
"dim_head": 8,
|
||||
"ff_mult": 2,
|
||||
"text_hidden_dim": TEXT_HIDDEN_DIM,
|
||||
"num_layers": 1,
|
||||
"num_single_layers": 1,
|
||||
"attn_mask_enabled": True,
|
||||
},
|
||||
"vae": {
|
||||
"latent_dim": LATENT_DIM,
|
||||
"downsample_rate": HOP,
|
||||
"target_sample_rate": SAMPLE_RATE,
|
||||
"model_init_kwargs": {"latent_dim": LATENT_DIM},
|
||||
},
|
||||
"variant": variant,
|
||||
"flash_t_grid": FLASH_T_GRID,
|
||||
"defaults": {"nfe": 32, "cfg": 2.0, "sway": -1.0},
|
||||
}
|
||||
(model_dir / "config.json").write_text(json.dumps(config))
|
||||
save_file(
|
||||
{
|
||||
"transformer.proj.weight": torch.zeros(LATENT_DIM, LATENT_DIM),
|
||||
"layer_weights": torch.zeros(36),
|
||||
"layer_scale": torch.ones(1),
|
||||
},
|
||||
str(model_dir / "auk.safetensors"),
|
||||
)
|
||||
save_file({"global_mean": torch.zeros(LATENT_DIM)}, str(model_dir / "vae.safetensors"))
|
||||
return model_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def build_pipeline(tmp_path, monkeypatch):
|
||||
"""Build an AuKPipeline whose transformer, codec and ODE are stubs."""
|
||||
|
||||
monkeypatch.setattr(pipeline_auk, "get_local_device", lambda: torch.device("cpu"))
|
||||
monkeypatch.setattr(pipeline_auk, "AuKTransformer", _StubTransformer)
|
||||
monkeypatch.setattr(pipeline_auk, "AuKVAE", _StubVAE)
|
||||
|
||||
def _build(variant: str = "base") -> tuple[AuKPipeline, list[dict[str, Any]]]:
|
||||
calls: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(pipeline_auk, "sample_latents", _stub_sampler(calls))
|
||||
od_config = OmniDiffusionConfig(
|
||||
model=str(_write_checkpoint(tmp_path, variant)),
|
||||
dtype=torch.float32,
|
||||
model_class_name="AuKPipeline",
|
||||
)
|
||||
return AuKPipeline(od_config=od_config), calls
|
||||
|
||||
return _build
|
||||
|
||||
|
||||
def _prompt(
|
||||
*,
|
||||
tokens: int = 8,
|
||||
audio: Any = None,
|
||||
knobs: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the diffusion prompt the stage input processor would emit."""
|
||||
|
||||
prompt: dict[str, Any] = {
|
||||
"prompt": "",
|
||||
"prompt_embeds": torch.zeros(tokens, TEXT_HIDDEN_DIM),
|
||||
"additional_information": {"auk": dict(knobs or {})},
|
||||
}
|
||||
if audio is not None:
|
||||
prompt["multi_modal_data"] = {"audio": audio}
|
||||
return prompt
|
||||
|
||||
|
||||
def _batch(prompt: dict[str, Any], **sampling: Any) -> DiffusionRequestBatch:
|
||||
request = OmniDiffusionRequest(
|
||||
prompt=prompt,
|
||||
sampling_params=OmniDiffusionSamplingParams(**sampling),
|
||||
request_id="auk-test-0",
|
||||
)
|
||||
return DiffusionRequestBatch(requests=[request])
|
||||
|
||||
|
||||
def _silence(seconds: float, sample_rate: int = SAMPLE_RATE) -> tuple[np.ndarray, int]:
|
||||
return np.zeros(int(seconds * sample_rate), dtype=np.float32), sample_rate
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
class TestRequestParsing:
|
||||
def test_pipeline_declares_audio_output(self, build_pipeline):
|
||||
pipeline, _ = build_pipeline()
|
||||
|
||||
assert pipeline.support_audio_output is True
|
||||
assert pipeline.audio_sample_rate == SAMPLE_RATE
|
||||
assert pipeline.supports_request_batch is False
|
||||
# The warmup request cannot carry an encoder-stage text condition.
|
||||
assert pipeline.dummy_run_num_frames == 0
|
||||
|
||||
def test_config_reaches_the_transformer(self, build_pipeline):
|
||||
pipeline, _ = build_pipeline()
|
||||
|
||||
assert pipeline.dit.init_kwargs["latent_dim"] == LATENT_DIM
|
||||
assert pipeline.dit.init_kwargs["text_hidden_dim"] == TEXT_HIDDEN_DIM
|
||||
assert pipeline.dit.init_kwargs["attn_mask_enabled"] is True
|
||||
|
||||
def test_gen_seconds_sets_the_target_length(self, build_pipeline):
|
||||
pipeline, calls = build_pipeline()
|
||||
|
||||
outputs = pipeline.forward(_batch(_prompt(audio=_silence(2.0), knobs={"gen_seconds": 6.0}), seed=1))
|
||||
|
||||
assert calls[0]["gen_frames"] == math.ceil(6.0 * SAMPLE_RATE / HOP)
|
||||
assert calls[0]["ref"].shape == (1, 100, LATENT_DIM)
|
||||
assert calls[0]["ref_mask"].shape == (1, 100)
|
||||
assert calls[0]["c_mask"].shape == (1, 8)
|
||||
assert outputs[0].output.shape == (300 * HOP,)
|
||||
assert outputs[0].output.dtype is torch.float32
|
||||
|
||||
def test_target_length_falls_back_to_the_source_clip(self, build_pipeline):
|
||||
pipeline, calls = build_pipeline()
|
||||
|
||||
pipeline.forward(_batch(_prompt(audio=_silence(2.0), knobs={"gen_seconds": None}), seed=1))
|
||||
|
||||
assert calls[0]["gen_frames"] == 100
|
||||
|
||||
def test_text_only_request_without_a_duration_is_rejected(self, build_pipeline):
|
||||
pipeline, calls = build_pipeline()
|
||||
|
||||
with pytest.raises(ValueError, match="gen_seconds"):
|
||||
pipeline.forward(_batch(_prompt(knobs={"gen_seconds": None}), seed=1))
|
||||
assert calls == []
|
||||
|
||||
def test_text_only_request_gets_an_empty_reference(self, build_pipeline):
|
||||
pipeline, calls = build_pipeline()
|
||||
|
||||
pipeline.forward(_batch(_prompt(knobs={"gen_seconds": 3.5}), seed=1))
|
||||
|
||||
assert calls[0]["ref"].shape == (1, 0, LATENT_DIM)
|
||||
assert calls[0]["gen_frames"] == 175
|
||||
|
||||
def test_source_clip_is_resampled_to_the_codec_rate(self, build_pipeline):
|
||||
pipeline, _ = build_pipeline()
|
||||
|
||||
pipeline.forward(_batch(_prompt(audio=_silence(1.0, 16000), knobs={"gen_seconds": 1.0}), seed=1))
|
||||
|
||||
assert pipeline.vae.encode_calls[0]["samples"] == SAMPLE_RATE
|
||||
|
||||
def test_stereo_source_is_mixed_to_mono(self, build_pipeline):
|
||||
pipeline, _ = build_pipeline()
|
||||
stereo = np.zeros((2, SAMPLE_RATE), dtype=np.float32)
|
||||
|
||||
pipeline.forward(_batch(_prompt(audio=(stereo, SAMPLE_RATE), knobs={"gen_seconds": 1.0}), seed=1))
|
||||
|
||||
assert pipeline.vae.encode_calls[0]["samples"] == SAMPLE_RATE
|
||||
|
||||
def test_missing_text_condition_is_rejected(self, build_pipeline):
|
||||
pipeline, _ = build_pipeline()
|
||||
prompt = _prompt(knobs={"gen_seconds": 1.0})
|
||||
del prompt["prompt_embeds"]
|
||||
|
||||
with pytest.raises(ValueError, match="prompt_embeds"):
|
||||
pipeline.forward(_batch(prompt, seed=1))
|
||||
|
||||
def test_base_variant_uses_the_requested_schedule(self, build_pipeline):
|
||||
pipeline, calls = build_pipeline()
|
||||
|
||||
pipeline.forward(
|
||||
_batch(
|
||||
_prompt(audio=_silence(1.0), knobs={"gen_seconds": 1.0, "sway": -1.0}),
|
||||
seed=1,
|
||||
num_inference_steps=16,
|
||||
guidance_scale=3.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert calls[0]["nfe"] == 16
|
||||
assert calls[0]["cfg_strength"] == 3.0
|
||||
assert calls[0]["sway_sampling_coef"] == -1.0
|
||||
assert calls[0]["t_grid"] is None
|
||||
|
||||
def test_schedule_defaults_come_from_the_checkpoint(self, build_pipeline):
|
||||
pipeline, calls = build_pipeline()
|
||||
|
||||
pipeline.forward(_batch(_prompt(audio=_silence(1.0), knobs={"gen_seconds": 1.0, "sway": None}), seed=1))
|
||||
|
||||
assert calls[0]["nfe"] == 32
|
||||
assert calls[0]["cfg_strength"] == 2.0
|
||||
assert calls[0]["sway_sampling_coef"] == -1.0
|
||||
|
||||
def test_flash_variant_locks_the_distilled_recipe(self, build_pipeline):
|
||||
pipeline, calls = build_pipeline("flash")
|
||||
|
||||
pipeline.forward(
|
||||
_batch(
|
||||
_prompt(audio=_silence(1.0), knobs={"gen_seconds": 1.0, "sway": -1.0}),
|
||||
seed=1,
|
||||
num_inference_steps=32,
|
||||
guidance_scale=2.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert calls[0]["nfe"] == 4
|
||||
assert calls[0]["cfg_strength"] == 0.0
|
||||
assert calls[0]["sway_sampling_coef"] is None
|
||||
assert calls[0]["t_grid"] == FLASH_T_GRID
|
||||
|
||||
def test_noise_comes_from_a_seeded_request_generator(self, build_pipeline):
|
||||
pipeline, calls = build_pipeline()
|
||||
|
||||
outputs = pipeline.forward(
|
||||
_batch(_prompt(knobs={"gen_seconds": 1.0}), seed=0, output_type="latent"),
|
||||
)
|
||||
|
||||
expected = torch.randn(1, 50, LATENT_DIM, generator=torch.Generator().manual_seed(0))
|
||||
assert torch.equal(outputs[0].output, expected)
|
||||
# The global RNG is never seeded on the pipeline's behalf.
|
||||
assert calls[0]["seed"] is None
|
||||
assert isinstance(calls[0]["generator"], torch.Generator)
|
||||
|
||||
def test_latent_output_type_skips_the_decoder(self, build_pipeline):
|
||||
pipeline, _ = build_pipeline()
|
||||
|
||||
outputs = pipeline.forward(_batch(_prompt(knobs={"gen_seconds": 1.0}), seed=1, output_type="latent"))
|
||||
|
||||
assert outputs[0].output.shape == (1, 50, LATENT_DIM)
|
||||
assert pipeline.vae.decode_calls == 0
|
||||
|
||||
def test_vae_sample_draws_from_the_request_generator(self, build_pipeline):
|
||||
pipeline, _ = build_pipeline()
|
||||
|
||||
pipeline.forward(_batch(_prompt(audio=_silence(1.0), knobs={"gen_seconds": 1.0, "vae_sample": True}), seed=1))
|
||||
|
||||
call = pipeline.vae.encode_calls[0]
|
||||
assert call["sample"] is True
|
||||
assert isinstance(call["generator"], torch.Generator)
|
||||
|
||||
def test_one_request_per_forward(self, build_pipeline):
|
||||
pipeline, _ = build_pipeline()
|
||||
batch = _batch(_prompt(knobs={"gen_seconds": 1.0}), seed=1)
|
||||
batch.requests.append(batch.requests[0])
|
||||
|
||||
with pytest.raises(AssertionError, match="one request per forward"):
|
||||
pipeline.forward(batch)
|
||||
|
||||
|
||||
def _reference_audio_path(messages: list[dict[str, Any]]) -> str:
|
||||
for message in messages:
|
||||
for item in message.get("content") or ():
|
||||
if isinstance(item, dict) and item.get("type") == "audio":
|
||||
path = item.get("audio") or item.get("audio_url")
|
||||
if path:
|
||||
return str(path)
|
||||
raise AssertionError("the saved reference case carries no source clip")
|
||||
|
||||
|
||||
def _log_mel_distance(left: torch.Tensor, right: torch.Tensor, sample_rate: int) -> float:
|
||||
"""Mean absolute log-mel difference between two mono waveforms."""
|
||||
|
||||
mel = torchaudio.transforms.MelSpectrogram(sample_rate=sample_rate, n_fft=1024, hop_length=256, n_mels=80)
|
||||
length = min(left.shape[-1], right.shape[-1])
|
||||
spectra = [torch.log(mel(wav[..., :length].float()) + 1e-5) for wav in (left, right)]
|
||||
return float((spectra[0] - spectra[1]).abs().mean())
|
||||
|
||||
|
||||
@pytest.mark.local_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.skipif(
|
||||
not (IS_ASSEMBLED and PARITY_REF),
|
||||
reason="needs AUK_PARITY_REF and AUK_OMNI_CKPT_DIR pointing at an assembled checkpoint directory",
|
||||
)
|
||||
def test_zero_shot_tts_tracks_the_upstream_reference():
|
||||
"""Replay the saved zero-shot TTS case and report the distances."""
|
||||
|
||||
parity_dir = Path(PARITY_REF)
|
||||
fusion_dir = Path(os.environ.get("AUK_FUSION_REF") or parity_dir.parent.parent / "fusion_ref")
|
||||
reference = torch.load(parity_dir / "zs_tts_en.pt", map_location="cpu", weights_only=False)
|
||||
fusion = torch.load(fusion_dir / "zs_tts_audio.pt", map_location="cpu", weights_only=False)
|
||||
|
||||
# soundfile rather than torchaudio.load: the repo's test convention, and
|
||||
# torchaudio's decoder needs an ffmpeg the port environment does not have.
|
||||
samples, source_rate = sf.read(_reference_audio_path(reference["messages"]), dtype="float32", always_2d=True)
|
||||
source = torch.from_numpy(samples).transpose(0, 1)
|
||||
knobs = {"gen_seconds": reference["gen_seconds"], "sway": -1.0, "t_grid": None, "vae_sample": False}
|
||||
prompt = {
|
||||
"prompt": "",
|
||||
# The encoder stage emits bf16; the saved fusion is an fp32 forward.
|
||||
"prompt_embeds": fusion["fused"].to(torch.bfloat16),
|
||||
"multi_modal_data": {"audio": (source, int(source_rate))},
|
||||
"additional_information": {"auk": knobs},
|
||||
}
|
||||
od_config = OmniDiffusionConfig(model=CKPT_DIR, dtype=torch.bfloat16, model_class_name="AuKPipeline")
|
||||
pipeline = AuKPipeline(od_config=od_config)
|
||||
|
||||
def run(output_type: str) -> torch.Tensor:
|
||||
batch = _batch(
|
||||
dict(prompt),
|
||||
seed=reference["seed"],
|
||||
num_inference_steps=32,
|
||||
guidance_scale=2.0,
|
||||
output_type=output_type,
|
||||
)
|
||||
return pipeline.forward(batch)[0].output
|
||||
|
||||
# Two deterministic runs: the ODE is seeded, so the latents belong to the
|
||||
# waveform even though the decode happens in the second call.
|
||||
latents = run("latent")
|
||||
waveform = run("pt")
|
||||
|
||||
ref_frames = int(reference["ref_latent_lens"][0])
|
||||
expected = reference["generated"][:, ref_frames:, :].float()
|
||||
assert latents.shape == expected.shape
|
||||
assert torch.isfinite(latents).all()
|
||||
assert torch.isfinite(waveform).all()
|
||||
assert waveform.shape[-1] == expected.shape[1] * HOP
|
||||
|
||||
cosine = F.cosine_similarity(latents[0], expected[0], dim=-1)
|
||||
metrics = {
|
||||
"gen_frames": int(expected.shape[1]),
|
||||
"ref_frames": ref_frames,
|
||||
"latent_mse": float(torch.mean((latents - expected) ** 2)),
|
||||
"latent_relative_mse": float(torch.mean((latents - expected) ** 2) / expected.var()),
|
||||
"frame_cosine_mean": float(cosine.mean()),
|
||||
"frame_cosine_min": float(cosine.min()),
|
||||
"log_mel_distance": _log_mel_distance(waveform, reference["audio"].reshape(-1), int(reference["sr"])),
|
||||
"rms_port": float(waveform.pow(2).mean().sqrt()),
|
||||
"rms_reference": float(reference["audio"].reshape(-1).pow(2).mean().sqrt()),
|
||||
}
|
||||
print("\nAuK zero-shot TTS parity:", json.dumps(metrics, indent=1))
|
||||
|
||||
assert metrics["frame_cosine_mean"] > PARITY_MIN_FRAME_COSINE
|
||||
assert metrics["latent_relative_mse"] < PARITY_MAX_RELATIVE_MSE
|
||||
assert metrics["log_mel_distance"] < PARITY_MAX_MEL_DISTANCE
|
||||
@@ -122,8 +122,10 @@ class TestStage1DiTLoRA:
|
||||
lambda root, name, sub: fake_replace_submodule(root, name, sub, replace_calls),
|
||||
)
|
||||
|
||||
# Build pipeline with bagel component
|
||||
# Build pipeline with bagel component declared via _lora_components,
|
||||
# matching how BagelPipeline opts in.
|
||||
pipeline = torch.nn.Module()
|
||||
pipeline._lora_components = ["bagel"]
|
||||
pipeline.bagel = torch.nn.Module()
|
||||
lm = torch.nn.Module()
|
||||
lm.stacked_params_mapping = [
|
||||
@@ -194,8 +196,11 @@ class TestBagelLoRARoundTrip:
|
||||
|
||||
monkeypatch.setattr(manager_mod, "BaseLayerWithLoRA", DummyBaseLayerWithLoRA)
|
||||
|
||||
# Build pipeline with bagel.language_model.foo (simple non-packed layer)
|
||||
# Build pipeline with bagel.language_model.foo (simple non-packed layer);
|
||||
# the bagel component is declared via _lora_components, matching how
|
||||
# BagelPipeline opts in.
|
||||
pipeline = torch.nn.Module()
|
||||
pipeline._lora_components = ["bagel"]
|
||||
pipeline.bagel = torch.nn.Module()
|
||||
lm = torch.nn.Module()
|
||||
lm.foo = _FakeLinearBase()
|
||||
|
||||
@@ -26,7 +26,9 @@ NORM_EPS = 1e-5
|
||||
def _init_distributed(request):
|
||||
"""Initialize the minimal single-rank distributed environment required by
|
||||
the vLLM parallel linear layers (tensor-parallel group must exist)."""
|
||||
if request.node.name.startswith("test_real_rotary_"):
|
||||
if request.node.name.startswith(
|
||||
("test_real_rotary_", "test_packed_rope_table", "test_fused_qk_norm_rope", "test_fallback_with_no")
|
||||
):
|
||||
yield
|
||||
return
|
||||
|
||||
@@ -438,29 +440,86 @@ def test_transformer_rejects_prompt_tuning():
|
||||
BooguImageTransformer2DModel(od_config=_tiny_od_config(prompt_tuning_configs={"use_prompt_tuning": True}))
|
||||
|
||||
|
||||
def _native_to_checkpoint_name(name: str) -> str:
|
||||
"""Inverse of ``load_weights`` remapping: native param name -> diffusers name.
|
||||
_QKV_FANOUT = {
|
||||
"to_qkv": ("to_q", "to_k", "to_v"),
|
||||
"img_to_qkv": ("img_to_q", "img_to_k", "img_to_v"),
|
||||
"instruct_to_qkv": ("instruct_to_q", "instruct_to_k", "instruct_to_v"),
|
||||
}
|
||||
|
||||
|
||||
def _native_to_checkpoint_weights(name: str, param: torch.Tensor) -> list[tuple[str, torch.Tensor]]:
|
||||
"""Inverse of ``load_weights`` remapping: native param -> diffusers weights.
|
||||
|
||||
The diffusers checkpoint stores fused projections as separate matrices, so a
|
||||
single merged native param (QKV or FFN gate/up) fans out into several
|
||||
checkpoint weights. Returns ``(diffusers_name, value)`` pairs whose values,
|
||||
when fed through ``load_weights``, reassemble into the original ``param``.
|
||||
|
||||
- ``.to_out.<suffix>`` -> ``.to_out.0.<suffix>`` (diffusers ModuleList wrap).
|
||||
- promoted joint-attention projections move back under ``.processor.``.
|
||||
"""
|
||||
q_size = NUM_HEADS * HEAD_DIM
|
||||
kv_size = NUM_KV_HEADS * HEAD_DIM
|
||||
|
||||
# Fused QKV projections split back into per-matrix q/k/v weights.
|
||||
for token, (q_name, k_name, v_name) in _QKV_FANOUT.items():
|
||||
marker = f".{token}."
|
||||
if marker in name:
|
||||
q, k, v = param[:q_size], param[q_size : q_size + kv_size], param[q_size + kv_size :]
|
||||
results = []
|
||||
for sub_name, value in ((q_name, q), (k_name, k), (v_name, v)):
|
||||
ckpt_name = name.replace(marker, f".{sub_name}.")
|
||||
if token != "to_qkv":
|
||||
# Joint-attention projections live under `.processor.` upstream.
|
||||
ckpt_name = ckpt_name.replace(".img_instruct_attn.", ".img_instruct_attn.processor.")
|
||||
results.append((ckpt_name, value))
|
||||
return results
|
||||
|
||||
# Fused FFN gate/up splits into linear_1 (gate) / linear_3 (input).
|
||||
if ".gate_up_proj." in name:
|
||||
inner = param.shape[0] // 2
|
||||
return [
|
||||
(name.replace(".gate_up_proj.", ".linear_1."), param[:inner]),
|
||||
(name.replace(".gate_up_proj.", ".linear_3."), param[inner:]),
|
||||
]
|
||||
|
||||
# Default: 1:1 with the existing diffusers name promotions.
|
||||
if ".to_out." in name:
|
||||
name = name.replace(".to_out.", ".to_out.0.")
|
||||
for proj in (
|
||||
"img_to_q",
|
||||
"img_to_k",
|
||||
"img_to_v",
|
||||
"instruct_to_q",
|
||||
"instruct_to_k",
|
||||
"instruct_to_v",
|
||||
"instruct_out",
|
||||
"img_out",
|
||||
):
|
||||
for proj in ("instruct_out", "img_out"):
|
||||
token = f".img_instruct_attn.{proj}."
|
||||
if token in name:
|
||||
name = name.replace(token, f".img_instruct_attn.processor.{proj}.")
|
||||
break
|
||||
return name
|
||||
return [(name, param)]
|
||||
|
||||
|
||||
def test_transformer_exposes_stacked_params_mapping():
|
||||
"""The packed -> sub-layer mapping must be discoverable from the module tree.
|
||||
|
||||
``diffusion/lora/loader.py`` and the quantized weight loaders read
|
||||
``stacked_params_mapping`` off the model, so it has to exist before (and
|
||||
independently of) ``load_weights``.
|
||||
"""
|
||||
from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import (
|
||||
_BOOGU_STACKED_PARAMS_MAPPING,
|
||||
BooguImageTransformer2DModel,
|
||||
)
|
||||
|
||||
model = BooguImageTransformer2DModel(od_config=_tiny_od_config())
|
||||
|
||||
assert tuple(model.stacked_params_mapping) == _BOOGU_STACKED_PARAMS_MAPPING
|
||||
# A per-instance copy, so consumers cannot mutate the module constant.
|
||||
assert model.stacked_params_mapping is not _BOOGU_STACKED_PARAMS_MAPPING
|
||||
|
||||
# Every entry is a (param, shard, shard_id) triple.
|
||||
for param_name, shard_name, shard_id in model.stacked_params_mapping:
|
||||
assert param_name.startswith(".") and shard_name.startswith(".")
|
||||
assert shard_id in {"q", "k", "v", 0, 1}
|
||||
|
||||
# The mapping targets exactly the fused projections this port creates.
|
||||
mapped_leaves = {param.strip(".").split(".")[-1] for param, _, _ in model.stacked_params_mapping}
|
||||
assert mapped_leaves == {"to_qkv", "img_to_qkv", "instruct_to_qkv", "gate_up_proj"}
|
||||
|
||||
|
||||
def test_transformer_load_weights_round_trip():
|
||||
@@ -471,24 +530,59 @@ def test_transformer_load_weights_round_trip():
|
||||
model = BooguImageTransformer2DModel(od_config=_tiny_od_config())
|
||||
native_params = dict(model.named_parameters())
|
||||
|
||||
# Build synthetic diffusers-named weights (one per native parameter).
|
||||
checkpoint_weights = {}
|
||||
# Build synthetic diffusers-named weights. Fused native projections fan out
|
||||
# into the separate matrices the diffusers checkpoint stores.
|
||||
expected: dict[str, torch.Tensor] = {}
|
||||
checkpoint_weights: dict[str, torch.Tensor] = {}
|
||||
for native_name, param in native_params.items():
|
||||
checkpoint_weights[_native_to_checkpoint_name(native_name)] = torch.randn_like(param)
|
||||
full = torch.randn_like(param)
|
||||
expected[native_name] = full
|
||||
for ckpt_name, value in _native_to_checkpoint_weights(native_name, full):
|
||||
checkpoint_weights[ckpt_name] = value
|
||||
|
||||
# The remapping must be a bijection over the parameter set.
|
||||
assert len(checkpoint_weights) == len(native_params)
|
||||
# Every checkpoint name is unique; merged params fan out to >=1 weight.
|
||||
assert len(checkpoint_weights) >= len(native_params)
|
||||
|
||||
loaded = model.load_weights(list(checkpoint_weights.items()))
|
||||
|
||||
# No missing / unexpected parameters.
|
||||
assert loaded == set(native_params.keys())
|
||||
|
||||
# Values landed on the right parameters (TP=1: weight_loader copies verbatim).
|
||||
# Values landed on the right parameters (TP=1: weight_loader copies verbatim;
|
||||
# fused shards are placed at their q/k/v / gate/up offsets).
|
||||
reloaded = dict(model.named_parameters())
|
||||
for native_name in native_params:
|
||||
expected = checkpoint_weights[_native_to_checkpoint_name(native_name)]
|
||||
assert torch.allclose(reloaded[native_name], expected)
|
||||
assert torch.allclose(reloaded[native_name], expected[native_name])
|
||||
|
||||
|
||||
def test_transformer_load_weights_rejects_partial_fused_params():
|
||||
"""A fused parameter must not count as loaded until all its shards arrive.
|
||||
|
||||
The loader compares parameter *names*, so reporting ``to_qkv`` complete
|
||||
after a single ``to_q`` would let a checkpoint carrying only ``to_q`` start
|
||||
up with the ``k``/``v`` slices left uninitialized.
|
||||
"""
|
||||
from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import (
|
||||
BooguImageTransformer2DModel,
|
||||
)
|
||||
|
||||
model = BooguImageTransformer2DModel(od_config=_tiny_od_config())
|
||||
native_params = dict(model.named_parameters())
|
||||
|
||||
complete_name = "noise_refiner.0.attn.to_qkv.weight"
|
||||
partial_name = "noise_refiner.0.feed_forward.gate_up_proj.weight"
|
||||
assert complete_name in native_params
|
||||
assert partial_name in native_params
|
||||
|
||||
weights = list(_native_to_checkpoint_weights(complete_name, native_params[complete_name]))
|
||||
# Only the gate half of the FFN, so its fused parameter stays incomplete.
|
||||
gate_ckpt, gate_value = _native_to_checkpoint_weights(partial_name, native_params[partial_name])[0]
|
||||
weights.append((gate_ckpt, gate_value))
|
||||
|
||||
loaded = model.load_weights(weights)
|
||||
|
||||
assert complete_name in loaded
|
||||
assert partial_name not in loaded
|
||||
|
||||
|
||||
def test_transformer_load_weights_warns_for_unexpected_and_unloaded():
|
||||
@@ -511,12 +605,15 @@ def test_transformer_load_weights_warns_for_unexpected_and_unloaded():
|
||||
try:
|
||||
model = BooguImageTransformer2DModel(od_config=_tiny_od_config())
|
||||
native_params = dict(model.named_parameters())
|
||||
loaded_name = next(iter(native_params))
|
||||
checkpoint_name = _native_to_checkpoint_name(loaded_name)
|
||||
# A non-fused parameter, so one checkpoint entry loads it completely.
|
||||
loaded_name = "x_embedder.weight"
|
||||
assert loaded_name in native_params
|
||||
(checkpoint_name, checkpoint_value), *_ = _native_to_checkpoint_weights(loaded_name, native_params[loaded_name])
|
||||
checkpoint_value = torch.randn_like(checkpoint_value)
|
||||
|
||||
loaded = model.load_weights(
|
||||
[
|
||||
(checkpoint_name, torch.randn_like(native_params[loaded_name])),
|
||||
(checkpoint_name, checkpoint_value),
|
||||
("unexpected.weight", torch.ones(1)),
|
||||
]
|
||||
)
|
||||
@@ -600,3 +697,152 @@ def test_transformer_forward_ti2i_shape():
|
||||
|
||||
assert out.shape == (batch_size, model.out_channels, latent_h, latent_w)
|
||||
assert torch.isfinite(out).all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The three tests below are for the fused qk-norm+RoPE kernel's wiring into
|
||||
# this transformer: the packed-table layout conversion, the fused path against
|
||||
# the previous eager chain, and the fallback when no packed table is present.
|
||||
# They need CUDA (the module-level marks are CPU; these add the cuda mark and
|
||||
# skip on CPU-only runners) and real Boogu geometry rather than the mock dims
|
||||
# above.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FUSED_HEAD_DIM = 120
|
||||
_FUSED_Q_HEADS = 28
|
||||
_FUSED_KV_HEADS = 7
|
||||
_FUSED_EPS = 1e-5
|
||||
|
||||
|
||||
def _fused_rotary_pair(tokens: int):
|
||||
theta = torch.randn(1, tokens, _FUSED_HEAD_DIM // 2, device="cuda", dtype=torch.float32)
|
||||
return (
|
||||
torch.cos(theta).repeat_interleave(2, dim=-1),
|
||||
torch.sin(theta).repeat_interleave(2, dim=-1),
|
||||
)
|
||||
|
||||
|
||||
def _fused_norms():
|
||||
from vllm.config import VllmConfig, set_current_vllm_config
|
||||
from vllm.model_executor.layers.layernorm import RMSNorm
|
||||
|
||||
with set_current_vllm_config(VllmConfig()):
|
||||
norm_q = RMSNorm(_FUSED_HEAD_DIM, eps=_FUSED_EPS).cuda().to(torch.bfloat16)
|
||||
norm_k = RMSNorm(_FUSED_HEAD_DIM, eps=_FUSED_EPS).cuda().to(torch.bfloat16)
|
||||
with torch.no_grad():
|
||||
norm_q.weight.copy_(torch.randn(_FUSED_HEAD_DIM))
|
||||
norm_k.weight.copy_(torch.randn(_FUSED_HEAD_DIM))
|
||||
return norm_q, norm_k
|
||||
|
||||
|
||||
def _operand_ulp_bound(x, weight, cos, sin):
|
||||
"""One bf16 rounding of a normalized operand, propagated through RoPE."""
|
||||
import torch.nn.functional as F
|
||||
|
||||
n = F.rms_norm(x, (_FUSED_HEAD_DIM,), weight, _FUSED_EPS).float().abs()
|
||||
c = cos[..., ::2].float().abs().unsqueeze(2)
|
||||
s = sin[..., ::2].float().abs().unsqueeze(2)
|
||||
even_mag = n[..., ::2] * c + n[..., 1::2] * s
|
||||
odd_mag = n[..., ::2] * s + n[..., 1::2] * c
|
||||
return 2.0**-6 * torch.stack((even_mag, odd_mag), dim=-1).flatten(-2) + 1e-6
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
def test_packed_rope_table_layout(monkeypatch):
|
||||
from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import (
|
||||
_with_packed_rope_table,
|
||||
)
|
||||
|
||||
monkeypatch.delenv("VLLM_OMNI_FUSED_QK_NORM_ROPE_MIN_TOKENS", raising=False)
|
||||
torch.manual_seed(0)
|
||||
tokens = 2311 # above _FUSED_MIN_TOKENS
|
||||
cos, sin = _fused_rotary_pair(tokens)
|
||||
out = _with_packed_rope_table((cos, sin))
|
||||
assert len(out) == 3
|
||||
assert out[0] is cos and out[1] is sin
|
||||
packed = out[2]
|
||||
assert packed.shape == (tokens, _FUSED_HEAD_DIM)
|
||||
assert packed.dtype == torch.float32 and packed.is_contiguous()
|
||||
half = _FUSED_HEAD_DIM // 2
|
||||
assert torch.equal(packed[:, :half], cos[0, :, ::2])
|
||||
assert torch.equal(packed[:, half:], sin[0, :, ::2])
|
||||
# Below _FUSED_MIN_TOKENS the tuple passes through unchanged (host-bound
|
||||
# regime: the fused path would cost more than it saves).
|
||||
short = _with_packed_rope_table(_fused_rotary_pair(17))
|
||||
assert len(short) == 2
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
def test_fused_qk_norm_rope_matches_eager_chain():
|
||||
"""The fused path vs the previous eager chain at real Boogu shapes.
|
||||
|
||||
Every element must sit within one bf16 ulp of a normalized operand
|
||||
propagated through the rotation (the two paths legitimately differ by
|
||||
single- vs double-rounding of the norm's weight multiply).
|
||||
"""
|
||||
from vllm.triton_utils import HAS_TRITON
|
||||
|
||||
if not HAS_TRITON:
|
||||
pytest.skip("Triton required")
|
||||
from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import (
|
||||
_qk_norm_rope,
|
||||
_with_packed_rope_table,
|
||||
)
|
||||
|
||||
torch.manual_seed(0)
|
||||
norm_q, norm_k = _fused_norms()
|
||||
q = torch.randn(1, 4139, _FUSED_Q_HEADS, _FUSED_HEAD_DIM, device="cuda", dtype=torch.bfloat16)
|
||||
k = torch.randn(1, 4139, _FUSED_KV_HEADS, _FUSED_HEAD_DIM, device="cuda", dtype=torch.bfloat16)
|
||||
cos, sin = _fused_rotary_pair(4139)
|
||||
with_table = _with_packed_rope_table((cos, sin))
|
||||
|
||||
fused = _qk_norm_rope(q, k, norm_q, norm_k, with_table, torch.bfloat16)
|
||||
eager = _qk_norm_rope(q, k, norm_q, norm_k, (cos, sin), torch.bfloat16)
|
||||
for fused_t, eager_t, x, w in zip(fused, eager, (q, k), (norm_q.weight, norm_k.weight)):
|
||||
bound = _operand_ulp_bound(x, w, cos, sin)
|
||||
diff = (fused_t.float() - eager_t.float()).abs()
|
||||
assert (diff <= bound).all(), "fused path beyond one operand ulp of the eager chain"
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
def test_fallback_with_no_packed_table():
|
||||
"""Without a packed table the helper is bit-exact to the old chain."""
|
||||
from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import (
|
||||
_qk_norm_rope,
|
||||
apply_rotary_emb,
|
||||
)
|
||||
|
||||
torch.manual_seed(0)
|
||||
norm_q, norm_k = _fused_norms()
|
||||
q = torch.randn(1, 64, _FUSED_Q_HEADS, _FUSED_HEAD_DIM, device="cuda", dtype=torch.bfloat16)
|
||||
k = torch.randn(1, 64, _FUSED_KV_HEADS, _FUSED_HEAD_DIM, device="cuda", dtype=torch.bfloat16)
|
||||
cos, sin = _fused_rotary_pair(64)
|
||||
|
||||
out_q, out_k = _qk_norm_rope(q, k, norm_q, norm_k, (cos, sin), torch.bfloat16)
|
||||
ref_q = apply_rotary_emb(norm_q(q), (cos, sin)).to(torch.bfloat16)
|
||||
ref_k = apply_rotary_emb(norm_k(k), (cos, sin)).to(torch.bfloat16)
|
||||
assert torch.equal(out_q, ref_q)
|
||||
assert torch.equal(out_k, ref_k)
|
||||
|
||||
|
||||
def test_packed_rope_table_env_override(monkeypatch):
|
||||
"""VLLM_OMNI_FUSED_QK_NORM_ROPE_MIN_TOKENS steers the packing gate: 0 packs
|
||||
even a 43-token table, a huge value leaves a 4139-token table untouched,
|
||||
unset keeps the 2048 default routing."""
|
||||
from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import (
|
||||
_with_packed_rope_table,
|
||||
)
|
||||
|
||||
env = "VLLM_OMNI_FUSED_QK_NORM_ROPE_MIN_TOKENS"
|
||||
short, long = _identity_rotary_emb(1, 43), _identity_rotary_emb(1, 4139)
|
||||
|
||||
monkeypatch.setenv(env, "0")
|
||||
assert len(_with_packed_rope_table(short)) == 3
|
||||
monkeypatch.setenv(env, str(10**9))
|
||||
assert _with_packed_rope_table(long) is long
|
||||
monkeypatch.delenv(env)
|
||||
assert len(_with_packed_rope_table(long)) == 3
|
||||
assert _with_packed_rope_table(short) is short
|
||||
@@ -13,11 +13,13 @@ Two groups:
|
||||
CFG handling, and reshape logic can be verified numerically on CPU.
|
||||
"""
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch import nn
|
||||
from transformers import Qwen3VLConfig
|
||||
|
||||
from vllm_omni.diffusion.data import DiffusionParallelConfig, OmniDiffusionConfig, TransformerConfig
|
||||
|
||||
@@ -56,9 +58,17 @@ def mock_dependencies(mocker, monkeypatch):
|
||||
f"{_MODULE}.FlowMatchEulerDiscreteScheduler.from_pretrained",
|
||||
lambda *a, **k: mock_scheduler,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mllm_loader = mocker.patch(
|
||||
f"{_MODULE}.Qwen3VLForConditionalGeneration.from_pretrained",
|
||||
lambda *a, **k: mllm_wrapper,
|
||||
return_value=mllm_wrapper,
|
||||
)
|
||||
mllm_config_loader = mocker.patch(
|
||||
f"{_MODULE}.Qwen3VLConfig.from_pretrained",
|
||||
return_value=Qwen3VLConfig(),
|
||||
)
|
||||
mllm_builder = mocker.patch(
|
||||
f"{_MODULE}.create_transformers_model_with_vllm_linears",
|
||||
return_value=nn.Linear(1, 1),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{_MODULE}.Qwen3VLProcessor.from_pretrained",
|
||||
@@ -74,12 +84,17 @@ def mock_dependencies(mocker, monkeypatch):
|
||||
mock_transformer_cls.return_value = mock_transformer_instance
|
||||
monkeypatch.setattr(f"{_MODULE}.BooguImageTransformer2DModel", mock_transformer_cls)
|
||||
|
||||
# Treat the dummy model id as a local path: skips hub prefetch.
|
||||
mocker.patch("os.path.exists", return_value=True)
|
||||
# Treat only the dummy model id as local. Other filesystem checks (for
|
||||
# example lazy imports in the quantization registry) must remain real.
|
||||
path_exists = os.path.exists
|
||||
mocker.patch("os.path.exists", side_effect=lambda path: str(path).startswith("dummy-boogu") or path_exists(path))
|
||||
|
||||
return {
|
||||
"inner_encoder": inner_encoder,
|
||||
"mllm_wrapper": mllm_wrapper,
|
||||
"mllm_loader": mllm_loader,
|
||||
"mllm_config_loader": mllm_config_loader,
|
||||
"mllm_builder": mllm_builder,
|
||||
"processor": mock_processor,
|
||||
"vae": mock_vae,
|
||||
"scheduler": mock_scheduler,
|
||||
@@ -128,6 +143,9 @@ def test_constructor_wires_components(boogu_pipeline, mock_dependencies):
|
||||
assert boogu_pipeline.vae_scale_factor == 8
|
||||
assert boogu_pipeline.default_sample_size == 128
|
||||
assert hasattr(boogu_pipeline, "load_weights")
|
||||
assert "quantization_config" not in mock_dependencies["mllm_loader"].call_args.kwargs
|
||||
mock_dependencies["mllm_config_loader"].assert_not_called()
|
||||
mock_dependencies["mllm_builder"].assert_not_called()
|
||||
|
||||
|
||||
def test_constructor_strips_mllm_lm_head(boogu_pipeline, mock_dependencies):
|
||||
@@ -188,7 +206,7 @@ def test_constructor_weights_sources(boogu_pipeline):
|
||||
assert source.fall_back_to_pt is True
|
||||
|
||||
|
||||
def test_constructor_routes_only_transformer_config(mock_dependencies, mocker):
|
||||
def test_constructor_rejects_unsupported_mllm_quantization(mock_dependencies, mocker):
|
||||
from vllm.model_executor.layers.quantization.base_config import QuantizationConfig
|
||||
|
||||
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import BooguImagePipeline
|
||||
@@ -204,10 +222,143 @@ def test_constructor_routes_only_transformer_config(mock_dependencies, mocker):
|
||||
{"transformer": transformer_config, "mllm": encoder_config, "vae": None}
|
||||
),
|
||||
)
|
||||
with pytest.raises(ValueError, match="Boogu MLLM only supports FP8 quantization"):
|
||||
BooguImagePipeline(od_config=od_config)
|
||||
mock_dependencies["mllm_loader"].assert_not_called()
|
||||
mock_dependencies["mllm_config_loader"].assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("quantization_config", "quantize_mllm"),
|
||||
[
|
||||
pytest.param("fp8", True, id="global-fp8"),
|
||||
pytest.param({"mllm": {"method": "fp8"}, "transformer": {"method": "fp8"}}, True, id="mllm-and-dit-fp8"),
|
||||
pytest.param({"mllm": None, "transformer": "fp8"}, False, id="dit-only-fp8"),
|
||||
],
|
||||
)
|
||||
def test_constructor_routes_mllm_quantization(mock_dependencies, quantization_config, quantize_mllm):
|
||||
from transformers import AutoModel
|
||||
from vllm.model_executor.layers.quantization.fp8 import Fp8Config
|
||||
from vllm.model_executor.layers.quantization.online.base import OnlineQuantizationConfig
|
||||
from vllm.model_executor.layers.quantization.utils.quant_utils import kFp8Static128BlockSym
|
||||
|
||||
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import BooguImagePipeline
|
||||
|
||||
od_config = OmniDiffusionConfig(
|
||||
model="dummy-boogu",
|
||||
revision="test-revision",
|
||||
tf_model_config=TransformerConfig(params={}),
|
||||
dtype=torch.bfloat16,
|
||||
quantization_config=quantization_config,
|
||||
)
|
||||
pipeline = BooguImagePipeline(od_config=od_config)
|
||||
|
||||
if quantize_mllm:
|
||||
mock_dependencies["mllm_loader"].assert_not_called()
|
||||
mock_dependencies["mllm_config_loader"].assert_called_once_with(
|
||||
"dummy-boogu", subfolder="mllm", local_files_only=True, revision="test-revision"
|
||||
)
|
||||
builder = mock_dependencies["mllm_builder"]
|
||||
builder.assert_called_once()
|
||||
auto_cls, hf_config, online_quant_config = builder.call_args.args
|
||||
assert auto_cls is AutoModel
|
||||
assert hf_config is mock_dependencies["mllm_config_loader"].return_value
|
||||
assert isinstance(online_quant_config, OnlineQuantizationConfig)
|
||||
assert online_quant_config.args.linear.weight == kFp8Static128BlockSym
|
||||
assert builder.call_args.kwargs == {
|
||||
"dtype": torch.bfloat16,
|
||||
"device": pipeline._execution_device,
|
||||
"prefix": "mllm",
|
||||
"skip_modules": ("mllm.visual",),
|
||||
}
|
||||
assert pipeline.mllm is builder.return_value
|
||||
assert not pipeline.mllm.training
|
||||
assert all(not parameter.requires_grad for parameter in pipeline.mllm.parameters())
|
||||
transformer_source, mllm_source = pipeline.weights_sources
|
||||
assert transformer_source.subfolder == "transformer"
|
||||
assert mllm_source.model_or_path == "dummy-boogu"
|
||||
assert mllm_source.subfolder == "mllm"
|
||||
assert mllm_source.prefix == "mllm."
|
||||
assert mllm_source.revision == "test-revision"
|
||||
else:
|
||||
assert "quantization_config" not in mock_dependencies["mllm_loader"].call_args.kwargs
|
||||
assert pipeline.mllm is mock_dependencies["inner_encoder"]
|
||||
mock_dependencies["mllm_config_loader"].assert_not_called()
|
||||
mock_dependencies["mllm_builder"].assert_not_called()
|
||||
assert [source.subfolder for source in pipeline.weights_sources] == ["transformer"]
|
||||
assert isinstance(mock_dependencies["transformer_cls"].call_args.kwargs["quant_config"], Fp8Config)
|
||||
|
||||
|
||||
def test_constructor_preserves_mllm_ignored_layers(mock_dependencies):
|
||||
from vllm.model_executor.layers.quantization.fp8 import Fp8Config
|
||||
|
||||
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import BooguImagePipeline
|
||||
|
||||
quant_config = Fp8Config(
|
||||
ignored_layers=["mllm.language_model.layers.0.self_attn.q_proj", "transformer.blocks.0.attn.to_q"]
|
||||
)
|
||||
od_config = OmniDiffusionConfig(
|
||||
model="dummy-boogu",
|
||||
tf_model_config=TransformerConfig(params={}),
|
||||
dtype=torch.bfloat16,
|
||||
quantization_config=quant_config,
|
||||
)
|
||||
BooguImagePipeline(od_config=od_config)
|
||||
kwargs = mock_dependencies["transformer_cls"].call_args.kwargs
|
||||
assert kwargs["quant_config"] is transformer_config
|
||||
assert kwargs["prefix"] == "transformer"
|
||||
|
||||
online_quant_config = mock_dependencies["mllm_builder"].call_args.args[2]
|
||||
assert online_quant_config.ignored_layers == quant_config.ignored_layers
|
||||
|
||||
|
||||
def test_constructor_preserves_serialized_mllm_quantization(mock_dependencies):
|
||||
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import BooguImagePipeline
|
||||
|
||||
mock_dependencies["mllm_config_loader"].return_value = Qwen3VLConfig(
|
||||
quantization_config={"quant_method": "fp8", "modules_to_not_convert": ["model.visual"]}
|
||||
)
|
||||
od_config = OmniDiffusionConfig(
|
||||
model="dummy-boogu-fp8",
|
||||
tf_model_config=TransformerConfig(params={}),
|
||||
dtype=torch.bfloat16,
|
||||
quantization_config="fp8",
|
||||
)
|
||||
pipeline = BooguImagePipeline(od_config=od_config)
|
||||
|
||||
# No override: HF reads the serialized checkpoint's scales and skip list.
|
||||
assert "quantization_config" not in mock_dependencies["mllm_loader"].call_args.kwargs
|
||||
assert pipeline.mllm is mock_dependencies["inner_encoder"]
|
||||
mock_dependencies["mllm_builder"].assert_not_called()
|
||||
assert [source.subfolder for source in pipeline.weights_sources] == ["transformer"]
|
||||
|
||||
|
||||
def test_load_weights_maps_mllm_and_preserves_transformer():
|
||||
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import BooguImagePipeline
|
||||
|
||||
pipeline = object.__new__(BooguImagePipeline)
|
||||
nn.Module.__init__(pipeline)
|
||||
pipeline.mllm = nn.ModuleDict(
|
||||
{
|
||||
"language_model": nn.Linear(2, 2, bias=False),
|
||||
"visual": nn.Linear(2, 2, bias=False),
|
||||
}
|
||||
)
|
||||
pipeline.transformer = nn.Linear(2, 2, bias=False)
|
||||
language_weight = torch.full((2, 2), 1.0)
|
||||
visual_weight = torch.full((2, 2), 2.0)
|
||||
transformer_weight = torch.full((2, 2), 3.0)
|
||||
|
||||
loaded = pipeline.load_weights(
|
||||
[
|
||||
("mllm.lm_head.weight", torch.full((2, 2), 9.0)),
|
||||
("mllm.model.language_model.weight", language_weight),
|
||||
("mllm.model.visual.weight", visual_weight),
|
||||
("transformer.weight", transformer_weight),
|
||||
]
|
||||
)
|
||||
|
||||
assert loaded == {"mllm.language_model.weight", "mllm.visual.weight", "transformer.weight"}
|
||||
torch.testing.assert_close(pipeline.mllm["language_model"].weight, language_weight)
|
||||
torch.testing.assert_close(pipeline.mllm["visual"].weight, visual_weight)
|
||||
torch.testing.assert_close(pipeline.transformer.weight, transformer_weight)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.util import find_spec
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
_FRAMEWORK_AVAILABLE = find_spec("cosmos_framework") is not None
|
||||
requires_cosmos_framework = pytest.mark.skipif(
|
||||
not _FRAMEWORK_AVAILABLE,
|
||||
reason="cosmos_framework source is not present on PYTHONPATH",
|
||||
)
|
||||
|
||||
|
||||
def test_framework_preflight_checks_every_policy_symbol(monkeypatch) -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3 import utils as cosmos3_utils
|
||||
|
||||
imported = []
|
||||
|
||||
def record_import(module_name: str, symbol_name: str, error_message: str):
|
||||
imported.append((module_name, symbol_name, error_message))
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(cosmos3_utils, "lazy_import", record_import)
|
||||
|
||||
cosmos3_utils.preflight_cosmos3_action_framework_imports()
|
||||
|
||||
expected_symbols = {
|
||||
"ActionTransformPipeline",
|
||||
"FlowUniPCMultistepScheduler",
|
||||
"build_abs_pose_from_components",
|
||||
"convert_rotation",
|
||||
"get_domain_id",
|
||||
"pose_abs_to_rel",
|
||||
"pose_rel_to_abs",
|
||||
}
|
||||
assert {symbol_name for _, symbol_name, _ in imported} == expected_symbols
|
||||
assert [(module_name, symbol_name) for module_name, symbol_name, _ in imported] == list(
|
||||
cosmos3_utils._ROBOLAB_FRAMEWORK_IMPORTS.values()
|
||||
)
|
||||
|
||||
|
||||
def test_lazy_import_reports_framework_symbol_skew(monkeypatch) -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3 import utils as cosmos3_utils
|
||||
|
||||
monkeypatch.setattr(cosmos3_utils, "import_module", lambda _module_name: object())
|
||||
|
||||
with pytest.raises(ImportError, match="MissingSymbol") as exc_info:
|
||||
cosmos3_utils.lazy_import(
|
||||
"cosmos_framework.fake_module",
|
||||
"MissingSymbol",
|
||||
"Cosmos Framework compatibility error.",
|
||||
)
|
||||
|
||||
assert isinstance(exc_info.value.__cause__, AttributeError)
|
||||
|
||||
|
||||
@requires_cosmos_framework
|
||||
def test_action_domain_table_matches_current_framework_and_legacy_aliases() -> None:
|
||||
from cosmos_framework.data.generator.action.utils.domain_utils import (
|
||||
EMBODIMENT_TO_DOMAIN_ID as FRAMEWORK_DOMAIN_IDS,
|
||||
)
|
||||
|
||||
from vllm_omni.diffusion.models.cosmos3.action import EMBODIMENT_TO_DOMAIN_ID as VLLM_DOMAIN_IDS
|
||||
from vllm_omni.diffusion.models.cosmos3.utils import _LEGACY_EMBODIMENT_ALIASES
|
||||
|
||||
canonical_vllm_domain_ids = {
|
||||
name: domain_id for name, domain_id in VLLM_DOMAIN_IDS.items() if name not in _LEGACY_EMBODIMENT_ALIASES
|
||||
}
|
||||
|
||||
assert canonical_vllm_domain_ids == FRAMEWORK_DOMAIN_IDS
|
||||
assert {alias: VLLM_DOMAIN_IDS[alias] for alias in _LEGACY_EMBODIMENT_ALIASES} == {
|
||||
alias: FRAMEWORK_DOMAIN_IDS[canonical_name] for alias, canonical_name in _LEGACY_EMBODIMENT_ALIASES.items()
|
||||
}
|
||||
|
||||
|
||||
@requires_cosmos_framework
|
||||
def test_current_cosmos_framework_policy_modules_import_and_instantiate() -> None:
|
||||
from cosmos_framework.data.generator.action.utils.domain_utils import get_domain_id
|
||||
from cosmos_framework.data.generator.action.utils.pose_utils import (
|
||||
build_abs_pose_from_components,
|
||||
convert_rotation,
|
||||
pose_abs_to_rel,
|
||||
pose_rel_to_abs,
|
||||
)
|
||||
from cosmos_framework.data.generator.action.utils.transforms import ActionTransformPipeline
|
||||
from cosmos_framework.model.generator.diffusion.samplers.fm_solvers_unipc import (
|
||||
FlowUniPCMultistepScheduler,
|
||||
)
|
||||
|
||||
transform = ActionTransformPipeline(
|
||||
max_action_dim=64,
|
||||
cfg_dropout_rate=0.0,
|
||||
format_prompt_as_json=True,
|
||||
)
|
||||
scheduler = FlowUniPCMultistepScheduler(
|
||||
num_train_timesteps=1000,
|
||||
shift=1.0,
|
||||
use_dynamic_shifting=False,
|
||||
)
|
||||
scheduler.set_timesteps(4, device=torch.device("cpu"), shift=5.0)
|
||||
|
||||
assert transform.prompt_json_formatter is not None
|
||||
assert len(scheduler.timesteps) == 4
|
||||
assert get_domain_id("droid_lerobot") == 8
|
||||
assert convert_rotation([0.0, 0.0, 0.0, 1.0], "quat_xyzw", "rot6d").shape == (6,)
|
||||
assert callable(pose_abs_to_rel)
|
||||
assert callable(pose_rel_to_abs)
|
||||
assert callable(build_abs_pose_from_components)
|
||||
|
||||
|
||||
@requires_cosmos_framework
|
||||
def test_real_action_transform_pipeline_matches_droid_policy_contract() -> None:
|
||||
from cosmos_framework.data.generator.action.utils.transforms import ActionTransformPipeline
|
||||
|
||||
transform = ActionTransformPipeline(
|
||||
max_action_dim=64,
|
||||
cfg_dropout_rate=0.0,
|
||||
format_prompt_as_json=True,
|
||||
)
|
||||
sample = {
|
||||
"ai_caption": "Pick up the cube.",
|
||||
"video": torch.zeros((3, 3, 192, 320), dtype=torch.uint8),
|
||||
"action": torch.zeros((3, 8), dtype=torch.float32),
|
||||
"conditioning_fps": torch.tensor(15, dtype=torch.long),
|
||||
"mode": "wam",
|
||||
"domain_id": torch.tensor(8, dtype=torch.long),
|
||||
"viewpoint": "concat_view",
|
||||
"additional_view_description": "Wrist view above two exterior views.",
|
||||
}
|
||||
|
||||
transformed = transform(sample, "256")
|
||||
|
||||
assert isinstance(transformed["ai_caption"], dict)
|
||||
assert transformed["action"].shape == (3, 64)
|
||||
assert int(transformed["raw_action_dim"].item()) == 8
|
||||
assert transformed["sequence_plan"].has_action is True
|
||||
assert transformed["mode"] == "wam"
|
||||
assert int(transformed["domain_id"].item()) == 8
|
||||
@@ -1,13 +1,16 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
@@ -21,6 +24,20 @@ from vllm_omni.experimental.world_models.session_state import SessionStateManage
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("alias", "canonical_name"),
|
||||
[
|
||||
("galbot", "embodiment_b"),
|
||||
("agibot_gear_gripper", "embodiment_c_gripper"),
|
||||
("agibot_gear_gripper_ext", "embodiment_c_gripper_ext"),
|
||||
],
|
||||
)
|
||||
def test_action_domain_table_preserves_legacy_aliases(alias: str, canonical_name: str) -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3.action import resolve_domain_id
|
||||
|
||||
assert resolve_domain_id(domain_name=alias) == resolve_domain_id(domain_name=canonical_name)
|
||||
|
||||
|
||||
def test_pipeline_declares_layerwise_offload_components() -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline
|
||||
|
||||
@@ -69,6 +86,7 @@ class StubScheduler:
|
||||
flow_shift: float = 1.0,
|
||||
) -> None:
|
||||
self.timesteps = torch.tensor(timesteps or [9, 3], dtype=torch.int64)
|
||||
self.sigmas = self.timesteps.float() / 10
|
||||
self.config = SimpleNamespace(
|
||||
num_train_timesteps=1000,
|
||||
flow_shift=flow_shift,
|
||||
@@ -97,6 +115,7 @@ class StubScheduler:
|
||||
else:
|
||||
assert num_inference_steps is not None
|
||||
self.timesteps = torch.arange(num_inference_steps, 0, -1, dtype=torch.int64, device=device)
|
||||
self.sigmas = self.timesteps.float()
|
||||
|
||||
def step(self, noise_pred: torch.Tensor, timestep: torch.Tensor, latents: torch.Tensor, **kwargs):
|
||||
del kwargs
|
||||
@@ -234,7 +253,7 @@ def passthrough_progress_bar(iterable):
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_cosmos3_guardrails(monkeypatch: pytest.MonkeyPatch):
|
||||
module = types.ModuleType("vllm_omni.diffusion.models.cosmos3.guardrails")
|
||||
module: Any = types.ModuleType("vllm_omni.diffusion.models.cosmos3.guardrails")
|
||||
module.is_guardrails_enabled = lambda od_config, sampling_params=None: False
|
||||
module.ensure_initialized = lambda od_config: None
|
||||
module.check_text_safety = lambda text: None
|
||||
@@ -267,6 +286,8 @@ def make_cosmos3_pipeline():
|
||||
pipeline.is_edge_model = False
|
||||
pipeline._guidance_scale = None
|
||||
pipeline._num_timesteps = None
|
||||
pipeline._current_step_index = None
|
||||
pipeline._current_sigma = None
|
||||
pipeline._cosmos3_branch_caches = None
|
||||
pipeline._cache_dit_requires_paired_cfg = False
|
||||
pipeline._sound_tokenizer = None
|
||||
@@ -284,7 +305,7 @@ def sequential_cfg_parallel(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
|
||||
def make_sampling_params(**overrides: Any) -> SimpleNamespace:
|
||||
values = {
|
||||
values: dict[str, Any] = {
|
||||
"height": None,
|
||||
"width": None,
|
||||
"num_frames": None,
|
||||
@@ -361,23 +382,31 @@ def _capture_tokenize_calls(pipeline: Any) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provided", "value", "default", "is_distilled", "expected"),
|
||||
("provided", "value", "default", "is_distilled", "expected", "expected_warning"),
|
||||
[
|
||||
(False, 1.0, 7.0, False, 7.0),
|
||||
(True, 1.0, 7.0, False, 1.0),
|
||||
(True, 4.5, 7.0, False, 4.5),
|
||||
(False, 1.0, 7.0, True, 1.0),
|
||||
(True, 4.5, 7.0, True, 1.0),
|
||||
(False, 1.0, 7.0, False, 7.0, False),
|
||||
(True, 1.0, 7.0, False, 1.0, False),
|
||||
(True, 4.5, 7.0, False, 4.5, False),
|
||||
(False, 1.0, 7.0, True, 1.0, False),
|
||||
(True, 1.0, 7.0, True, 1.0, False),
|
||||
(True, 4.5, 7.0, True, 1.0, True),
|
||||
(True, 0.0, 7.0, True, 1.0, True),
|
||||
],
|
||||
)
|
||||
def test_resolve_guidance_scale(
|
||||
make_cosmos3_pipeline,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
provided: bool,
|
||||
value: float,
|
||||
default: float,
|
||||
is_distilled: bool,
|
||||
expected: float,
|
||||
expected_warning: bool,
|
||||
) -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3 import pipeline_cosmos3
|
||||
|
||||
warning_once = Mock()
|
||||
monkeypatch.setattr(pipeline_cosmos3.logger, "warning_once", warning_once)
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
pipeline.is_distilled_model = is_distilled
|
||||
sp = make_sampling_params(
|
||||
@@ -386,6 +415,12 @@ def test_resolve_guidance_scale(
|
||||
)
|
||||
|
||||
assert pipeline._resolve_guidance_scale(sp, default) == expected
|
||||
if expected_warning:
|
||||
warning_once.assert_called_once()
|
||||
assert "overridden to 1.0" in warning_once.call_args.args[0]
|
||||
assert "negative_prompt does not affect generation" in warning_once.call_args.args[0]
|
||||
else:
|
||||
warning_once.assert_not_called()
|
||||
|
||||
|
||||
def test_distilled_generation_accepts_t2i_and_i2v(make_cosmos3_pipeline) -> None:
|
||||
@@ -479,6 +514,69 @@ def test_forward_threads_request_id_to_robolab(make_cosmos3_pipeline) -> None:
|
||||
assert captured["session_id"] == "robolab-request-7"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("format_prompt_as_json", [False, True])
|
||||
def test_robolab_input_builder_threads_prompt_format_and_uses_wam(
|
||||
make_cosmos3_pipeline,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
format_prompt_as_json: bool,
|
||||
) -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3 import pipeline_cosmos3
|
||||
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
pipeline.transformer = StubCosmos3Transformer(action_gen=True, action_dim=64)
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_transform(sample, resolution):
|
||||
captured["sample_mode"] = sample["mode"]
|
||||
captured["resolution"] = resolution
|
||||
sample["sequence_plan"] = SimpleNamespace(
|
||||
condition_frame_indexes_action=[0],
|
||||
action_start_frame_offset=1,
|
||||
)
|
||||
sample["raw_action_dim"] = torch.tensor(8)
|
||||
sample["image_size"] = torch.tensor([16, 16, 16, 16])
|
||||
if format_prompt_as_json:
|
||||
sample["ai_caption"] = {"actions": {"instruction": sample["ai_caption"]}}
|
||||
return sample
|
||||
|
||||
def fake_get_transform(*, format_prompt_as_json: bool):
|
||||
captured["format_prompt_as_json"] = format_prompt_as_json
|
||||
return fake_transform
|
||||
|
||||
pipeline._get_robolab_transform = fake_get_transform
|
||||
monkeypatch.setattr(pipeline_cosmos3, "get_robolab_domain_id", lambda name: 8)
|
||||
obs = {
|
||||
"prompt": "Pick up the cube.",
|
||||
"observation/image": np.zeros((16, 16, 3), dtype=np.uint8),
|
||||
"observation/joint_position": np.zeros(7, dtype=np.float32),
|
||||
"observation/gripper_position": np.zeros(1, dtype=np.float32),
|
||||
}
|
||||
sampling_params = make_sampling_params(
|
||||
extra_args={
|
||||
"robot_obs": obs,
|
||||
"action_chunk_size": 2,
|
||||
"image_height": 16,
|
||||
"image_width": 16,
|
||||
"format_prompt_as_json": format_prompt_as_json,
|
||||
}
|
||||
)
|
||||
|
||||
inputs = pipeline._build_robolab_policy_inputs(sampling_params, request_id="request-1")
|
||||
|
||||
assert inputs is not None
|
||||
assert captured == {
|
||||
"sample_mode": "wam",
|
||||
"resolution": "480",
|
||||
"format_prompt_as_json": format_prompt_as_json,
|
||||
}
|
||||
assert inputs.domain_id == 8
|
||||
assert inputs.raw_action_dim == 8
|
||||
if format_prompt_as_json:
|
||||
assert json.loads(inputs.prompt) == {"actions": {"instruction": "Pick up the cube."}}
|
||||
else:
|
||||
assert inputs.prompt == "Pick up the cube."
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("prompt", "sampling_params", "message"),
|
||||
[
|
||||
@@ -657,6 +755,7 @@ def _make_od_config(
|
||||
custom_pipeline_args={},
|
||||
model_config=model_config or {},
|
||||
tf_model_config=tf_model_config,
|
||||
parallel_config=SimpleNamespace(cfg_parallel_size=1, ulysses_degree=1),
|
||||
)
|
||||
|
||||
|
||||
@@ -692,6 +791,7 @@ def test_pipeline_init_uses_flow_unipc_with_cosmos3_defaults(stub_real_pipeline_
|
||||
assert pipeline._engine_init_flow_shift == 2.5
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cfg_parallel_size,ulysses_degree", [(1, 1), (1, 2), (2, 1), (2, 2)])
|
||||
@pytest.mark.parametrize(
|
||||
("scheduler_class_name", "expected_distilled"),
|
||||
[
|
||||
@@ -705,6 +805,8 @@ def test_pipeline_resolves_scheduler_class_from_checkpoint_file(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
scheduler_class_name: str,
|
||||
expected_distilled: bool,
|
||||
cfg_parallel_size: int,
|
||||
ulysses_degree: int,
|
||||
) -> None:
|
||||
import json
|
||||
|
||||
@@ -717,7 +819,7 @@ def test_pipeline_resolves_scheduler_class_from_checkpoint_file(
|
||||
t_list = [1.0, 0.75, 0.5, 0.25]
|
||||
scheduler_dir = tmp_path / "scheduler"
|
||||
scheduler_dir.mkdir()
|
||||
scheduler_config = {"_class_name": scheduler_class_name}
|
||||
scheduler_config: dict[str, Any] = {"_class_name": scheduler_class_name}
|
||||
if expected_distilled:
|
||||
scheduler_config["fixed_step_sampler_config"] = {"sample_type": "sde", "t_list": t_list}
|
||||
(scheduler_dir / "scheduler_config.json").write_text(json.dumps(scheduler_config))
|
||||
@@ -753,6 +855,20 @@ def test_pipeline_resolves_scheduler_class_from_checkpoint_file(
|
||||
|
||||
od_config = _make_od_config(sound_gen=False)
|
||||
od_config.model = str(tmp_path)
|
||||
od_config.parallel_config.cfg_parallel_size = cfg_parallel_size
|
||||
od_config.parallel_config.ulysses_degree = ulysses_degree
|
||||
if expected_distilled and cfg_parallel_size > 1:
|
||||
monkeypatch.setattr(
|
||||
pipeline_cosmos3.AutoTokenizer,
|
||||
"from_pretrained",
|
||||
lambda *args, **kwargs: pytest.fail("component loading must not start for distilled CFG parallelism"),
|
||||
)
|
||||
with pytest.raises(ValueError, match="Set --cfg-parallel-size 1 and use --ulysses-degree"):
|
||||
Cosmos3OmniDiffusersPipeline(od_config=od_config)
|
||||
assert StubFlowMatchScheduler.from_config_calls == []
|
||||
assert StubFlowUniPCScheduler.from_config_calls == []
|
||||
return
|
||||
|
||||
pipeline = Cosmos3OmniDiffusersPipeline(od_config=od_config)
|
||||
|
||||
assert pipeline.is_distilled_model is expected_distilled
|
||||
@@ -1420,7 +1536,7 @@ def test_ir_op_priority_hook_preserves_platform_fields(monkeypatch: pytest.Monke
|
||||
fused_add_rms_norm: list[str]
|
||||
custom_op: list[str]
|
||||
|
||||
fake_kernel = types.ModuleType("vllm.config.kernel")
|
||||
fake_kernel: Any = types.ModuleType("vllm.config.kernel")
|
||||
fake_kernel.IrOpPriorityConfig = FakeIrOpPriorityConfig
|
||||
monkeypatch.setitem(sys.modules, fake_kernel.__name__, fake_kernel)
|
||||
|
||||
@@ -2043,6 +2159,47 @@ def test_diffuse_covers_cfg_i2v_and_multimodal_steps(make_cosmos3_pipeline) -> N
|
||||
torch.testing.assert_close(action_result, torch.full((), 44.0).expand_as(action_result))
|
||||
|
||||
|
||||
def test_diffuse_publishes_exact_seacache_metadata_and_cfg_contexts(make_cosmos3_pipeline) -> None:
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
pipeline.scheduler.sigmas = torch.tensor([0.91, 0.17])
|
||||
observations: list[tuple[str, int | None, float | None, int | None]] = []
|
||||
|
||||
class RecordingHook:
|
||||
@contextmanager
|
||||
def cache_context(self, name: str):
|
||||
sigma = pipeline.current_sigma
|
||||
observations.append(
|
||||
(
|
||||
name,
|
||||
pipeline.current_step_index,
|
||||
None if sigma is None else float(sigma),
|
||||
pipeline.num_timesteps,
|
||||
)
|
||||
)
|
||||
yield
|
||||
|
||||
pipeline._cache_context_factory = RecordingHook().cache_context
|
||||
pipeline.diffuse(
|
||||
latents=torch.zeros(1, 2, 1, 1, 1),
|
||||
timesteps=torch.tensor([900, 100]),
|
||||
cond_ids=_ids(2),
|
||||
cond_mask=_mask(),
|
||||
uncond_ids=_ids(1),
|
||||
uncond_mask=_mask(),
|
||||
guidance_scale=3.0,
|
||||
shared_kwargs={"video_shape": (1, 1, 1), "fps": 24.0},
|
||||
)
|
||||
|
||||
assert observations == [
|
||||
("cond", 0, pytest.approx(0.91), 2),
|
||||
("uncond", 0, pytest.approx(0.91), 2),
|
||||
("cond", 1, pytest.approx(0.17), 2),
|
||||
("uncond", 1, pytest.approx(0.17), 2),
|
||||
]
|
||||
assert pipeline.current_step_index is None
|
||||
assert pipeline.current_sigma is None
|
||||
|
||||
|
||||
def test_diffuse_drops_session_when_progress_iteration_fails(make_cosmos3_pipeline) -> None:
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
pipeline._use_session_state = True
|
||||
@@ -2102,6 +2259,53 @@ def test_diffuse_transfer_applies_control_cfg(make_cosmos3_pipeline, sequential_
|
||||
torch.testing.assert_close(result, torch.full_like(latents, 254.0))
|
||||
|
||||
|
||||
def test_diffuse_transfer_uses_named_seacache_contexts(make_cosmos3_pipeline, sequential_cfg_parallel) -> None:
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
pipeline.scheduler.sigmas = torch.tensor([0.42])
|
||||
contexts: list[tuple[str, int | None, float | None, int | None]] = []
|
||||
|
||||
class RecordingHook:
|
||||
@contextmanager
|
||||
def cache_context(self, name: str):
|
||||
sigma = pipeline.current_sigma
|
||||
contexts.append(
|
||||
(
|
||||
name,
|
||||
pipeline.current_step_index,
|
||||
None if sigma is None else float(sigma),
|
||||
pipeline.num_timesteps,
|
||||
)
|
||||
)
|
||||
yield
|
||||
|
||||
pipeline._cache_context_factory = RecordingHook().cache_context
|
||||
latents = torch.zeros(1, 2, 1, 1, 1)
|
||||
velocity_mask = torch.ones(1, 1, 1, 1, 1)
|
||||
pipeline.diffuse_transfer(
|
||||
latents=latents,
|
||||
timesteps=torch.tensor([7]),
|
||||
cond_ids=_ids(2),
|
||||
cond_mask=_mask(),
|
||||
uncond_ids=_ids(1),
|
||||
uncond_mask=_mask(),
|
||||
guidance_scale=3.0,
|
||||
control_guidance=1.5,
|
||||
control_guidance_interval=None,
|
||||
control_latents=[torch.zeros_like(latents)],
|
||||
shared_kwargs={"video_shape": (1, 1, 1), "fps": 24.0, "noisy_frame_mask": velocity_mask},
|
||||
velocity_mask=velocity_mask,
|
||||
condition_latents=torch.zeros_like(latents),
|
||||
)
|
||||
|
||||
assert contexts == [
|
||||
("cond", 0, pytest.approx(0.42), 1),
|
||||
("cond_no_control", 0, pytest.approx(0.42), 1),
|
||||
("uncond", 0, pytest.approx(0.42), 1),
|
||||
]
|
||||
assert pipeline.current_step_index is None
|
||||
assert pipeline.current_sigma is None
|
||||
|
||||
|
||||
def test_diffuse_transfer_rejects_session_state_manager(make_cosmos3_pipeline) -> None:
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
pipeline._use_session_state = True
|
||||
@@ -2472,7 +2676,7 @@ def test_diffuse_keeps_paired_cfg_when_cache_dit_active(make_cosmos3_pipeline) -
|
||||
|
||||
class TestForwardRouting:
|
||||
def _install_forward_stubs(self, pipeline):
|
||||
captured: dict[str, object] = {"diffuse_calls": [], "prepare_calls": []}
|
||||
captured: dict[str, Any] = {"diffuse_calls": [], "prepare_calls": []}
|
||||
|
||||
def fake_format(
|
||||
prompt,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -513,6 +513,108 @@ def test_forward_returns_video_prediction(monkeypatch: pytest.MonkeyPatch) -> No
|
||||
assert tuple(output.shape) == (1, 2, 1, 2, 2)
|
||||
|
||||
|
||||
def test_cache_execution_residual_spans_final_gen_norm(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from vllm_omni.diffusion.cache.teacache.extractors import extract_cosmos3_context
|
||||
from vllm_omni.diffusion.models.cosmos3 import transformer_cosmos3
|
||||
|
||||
class TrackingNorm(nn.Module):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.calls = 0
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
self.calls += 1
|
||||
return hidden_states + 5.0
|
||||
|
||||
monkeypatch.setattr(transformer_cosmos3, "_get_ulysses_state", lambda: (1, 0, None))
|
||||
model = transformer_cosmos3.Cosmos3VFMTransformer(
|
||||
SimpleNamespace(tf_model_config=_tiny_cosmos3_config(), dtype=torch.float32)
|
||||
)
|
||||
norm = TrackingNorm()
|
||||
model.norm_moe_gen = norm
|
||||
captured: dict[str, torch.Tensor] = {}
|
||||
|
||||
def run_gen_layers(hidden_gen: torch.Tensor, **kwargs) -> torch.Tensor:
|
||||
del kwargs
|
||||
captured["input"] = hidden_gen.detach().clone()
|
||||
return hidden_gen + 2.0
|
||||
|
||||
monkeypatch.setattr(model, "_run_gen_layers", run_gen_layers)
|
||||
forward_kwargs = {
|
||||
"hidden_states": torch.zeros(1, 2, 1, 2, 2),
|
||||
"timestep": torch.tensor([1.0]),
|
||||
"text_ids": torch.tensor([[1, 2]], dtype=torch.long),
|
||||
"text_mask": torch.ones(1, 2, dtype=torch.long),
|
||||
"video_shape": (1, 2, 2),
|
||||
"fps": 24.0,
|
||||
}
|
||||
|
||||
full_output = model(**forward_kwargs)
|
||||
|
||||
assert norm.calls == 1
|
||||
norm.calls = 0
|
||||
|
||||
ctx = extract_cosmos3_context(model, **forward_kwargs)
|
||||
execution_input = ctx.hidden_states.detach().clone()
|
||||
execution_output = ctx.run_transformer_blocks()[0]
|
||||
residual = execution_output - execution_input
|
||||
extracted_output = ctx.postprocess(execution_output)
|
||||
|
||||
assert norm.calls == 1
|
||||
expected_residual = ((captured["input"] + 2.0) + 5.0) - captured["input"]
|
||||
assert torch.equal(residual, expected_residual)
|
||||
assert torch.equal(extracted_output, full_output)
|
||||
|
||||
norm.calls = 0
|
||||
|
||||
def fail_if_gen_layers_run(*args, **kwargs):
|
||||
del args, kwargs
|
||||
raise AssertionError("SeaCache hit unexpectedly executed GEN layers")
|
||||
|
||||
monkeypatch.setattr(model, "_run_gen_layers", fail_if_gen_layers_run)
|
||||
|
||||
cached_ctx = extract_cosmos3_context(model, **forward_kwargs)
|
||||
cached_output = cached_ctx.postprocess(cached_ctx.hidden_states + residual)
|
||||
|
||||
assert norm.calls == 0
|
||||
assert torch.equal(cached_output, full_output)
|
||||
for name in ("_seacache_skip", "_seacache_record", "_seacache_residual", "_seacache_last_residual"):
|
||||
assert not hasattr(model, name)
|
||||
|
||||
|
||||
def test_no_cache_still_runs_final_gen_norm_once(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3 import transformer_cosmos3
|
||||
|
||||
class CountingNorm(nn.Module):
|
||||
def __init__(self, original: nn.Module) -> None:
|
||||
super().__init__()
|
||||
self.original = original
|
||||
self.calls = 0
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
self.calls += 1
|
||||
return self.original(hidden_states)
|
||||
|
||||
monkeypatch.setattr(transformer_cosmos3, "_get_ulysses_state", lambda: (1, 0, None))
|
||||
model = transformer_cosmos3.Cosmos3VFMTransformer(
|
||||
SimpleNamespace(tf_model_config=_tiny_cosmos3_config(), dtype=torch.float32)
|
||||
)
|
||||
norm = CountingNorm(model.norm_moe_gen)
|
||||
model.norm_moe_gen = norm
|
||||
|
||||
output = model(
|
||||
hidden_states=torch.zeros(1, 2, 1, 2, 2),
|
||||
timestep=torch.tensor([1.0]),
|
||||
text_ids=torch.tensor([[1, 2]], dtype=torch.long),
|
||||
text_mask=torch.ones(1, 2, dtype=torch.long),
|
||||
video_shape=(1, 2, 2),
|
||||
fps=24.0,
|
||||
)
|
||||
|
||||
assert norm.calls == 1
|
||||
assert tuple(output.shape) == (1, 2, 1, 2, 2)
|
||||
|
||||
|
||||
def test_qwen_gen_mlp_remains_gated() -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3GatedMLP, Cosmos3VFMTransformer
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.triton_utils import HAS_TRITON
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from vllm_omni.diffusion.models.ernie_image.ernie_image_transformer import _apply_rotary_emb
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_fused_rope_state():
|
||||
from vllm_omni.diffusion.models.ernie_image import fused_rope
|
||||
|
||||
fused_rope._FAILED_KEYS.clear()
|
||||
yield
|
||||
fused_rope._FAILED_KEYS.clear()
|
||||
|
||||
|
||||
def _inputs(shape: tuple[int, int, int, int]):
|
||||
batch, sequence, _, head_dim = shape
|
||||
torch.manual_seed(17)
|
||||
query = torch.randn(shape, device="cuda", dtype=torch.bfloat16)
|
||||
key = torch.randn_like(query)
|
||||
freqs_cos = torch.randn(
|
||||
batch,
|
||||
sequence,
|
||||
head_dim // 2,
|
||||
device="cuda",
|
||||
dtype=torch.float32,
|
||||
)
|
||||
freqs_sin = torch.randn_like(freqs_cos)
|
||||
return query, key, freqs_cos, freqs_sin
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.skipif(not current_omni_platform.is_cuda(), reason="NVIDIA CUDA required")
|
||||
@pytest.mark.skipif(not HAS_TRITON, reason="Triton required")
|
||||
@pytest.mark.parametrize(
|
||||
"shape",
|
||||
[
|
||||
(1, 1, 1, 128),
|
||||
(2, 257, 7, 128),
|
||||
(2, 4608, 32, 128),
|
||||
],
|
||||
)
|
||||
def test_fused_qk_rope_is_bit_exact(shape):
|
||||
from vllm_omni.diffusion.models.ernie_image import fused_rope
|
||||
|
||||
query, key, freqs_cos, freqs_sin = _inputs(shape)
|
||||
with torch.inference_mode():
|
||||
expected_query = _apply_rotary_emb(query, freqs_cos, freqs_sin)
|
||||
expected_key = _apply_rotary_emb(key, freqs_cos, freqs_sin)
|
||||
actual = fused_rope.try_fused_qk_rotary_emb(query, key, freqs_cos, freqs_sin)
|
||||
|
||||
assert actual is not None
|
||||
actual_query, actual_key = actual
|
||||
assert torch.equal(actual_query, expected_query)
|
||||
assert torch.equal(actual_key, expected_key)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.skipif(not current_omni_platform.is_cuda(), reason="NVIDIA CUDA required")
|
||||
@pytest.mark.skipif(not HAS_TRITON, reason="Triton required")
|
||||
def test_launch_failure_disables_runtime_key(monkeypatch):
|
||||
from vllm_omni.diffusion.models.ernie_image import fused_rope
|
||||
|
||||
query, key, freqs_cos, freqs_sin = _inputs((1, 17, 3, 128))
|
||||
launch_count = 0
|
||||
|
||||
def failing_launch(*args):
|
||||
nonlocal launch_count
|
||||
launch_count += 1
|
||||
raise RuntimeError("injected launch failure")
|
||||
|
||||
monkeypatch.setattr(
|
||||
fused_rope,
|
||||
"_launch_fused_qk_rotary_emb",
|
||||
failing_launch,
|
||||
)
|
||||
with torch.inference_mode():
|
||||
for _ in range(2):
|
||||
assert fused_rope.try_fused_qk_rotary_emb(query, key, freqs_cos, freqs_sin) is None
|
||||
assert launch_count == 1
|
||||
assert len(fused_rope._FAILED_KEYS) == 1
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
|
||||
def test_failed_runtime_key_cache_is_bounded():
|
||||
from vllm_omni.diffusion.models.ernie_image import fused_rope
|
||||
|
||||
for index in range(fused_rope._FAILED_KEYS_MAX_SIZE + 1):
|
||||
fused_rope._record_failed_runtime_key((index,))
|
||||
|
||||
assert len(fused_rope._FAILED_KEYS) == fused_rope._FAILED_KEYS_MAX_SIZE
|
||||
assert (0,) not in fused_rope._FAILED_KEYS
|
||||
assert (fused_rope._FAILED_KEYS_MAX_SIZE,) in fused_rope._FAILED_KEYS
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.skipif(not current_omni_platform.is_cuda(), reason="NVIDIA CUDA required")
|
||||
@pytest.mark.skipif(not HAS_TRITON, reason="Triton required")
|
||||
def test_compile_path_does_not_launch_fused_kernel(monkeypatch):
|
||||
from vllm_omni.diffusion.models.ernie_image import fused_rope
|
||||
|
||||
query, key, freqs_cos, freqs_sin = _inputs((1, 17, 3, 128))
|
||||
monkeypatch.setattr(torch.compiler, "is_compiling", lambda: True)
|
||||
|
||||
def unexpected_launch(*args):
|
||||
raise AssertionError("compile path must remain native")
|
||||
|
||||
monkeypatch.setattr(
|
||||
fused_rope,
|
||||
"_launch_fused_qk_rotary_emb",
|
||||
unexpected_launch,
|
||||
)
|
||||
with torch.inference_mode():
|
||||
assert fused_rope.try_fused_qk_rotary_emb(query, key, freqs_cos, freqs_sin) is None
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.skipif(not current_omni_platform.is_cuda(), reason="NVIDIA CUDA required")
|
||||
@pytest.mark.skipif(not HAS_TRITON, reason="Triton required")
|
||||
@pytest.mark.parametrize("requires_grad_input", ["query", "key", "cos", "sin"])
|
||||
def test_gradient_inputs_do_not_launch_fused_kernel(monkeypatch, requires_grad_input):
|
||||
from vllm_omni.diffusion.models.ernie_image import fused_rope
|
||||
|
||||
values = list(_inputs((1, 17, 3, 128)))
|
||||
input_index = {"query": 0, "key": 1, "cos": 2, "sin": 3}[requires_grad_input]
|
||||
values[input_index].requires_grad_(True)
|
||||
|
||||
def unexpected_launch(*args):
|
||||
raise AssertionError("autograd inputs must remain on the native path")
|
||||
|
||||
monkeypatch.setattr(
|
||||
fused_rope,
|
||||
"_launch_fused_qk_rotary_emb",
|
||||
unexpected_launch,
|
||||
)
|
||||
assert (
|
||||
fused_rope.try_fused_qk_rotary_emb(
|
||||
*values,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.skipif(not current_omni_platform.is_cuda(), reason="NVIDIA CUDA required")
|
||||
@pytest.mark.skipif(not HAS_TRITON, reason="Triton required")
|
||||
def test_mismatched_key_dtype_or_device_does_not_launch(monkeypatch):
|
||||
from vllm_omni.diffusion.models.ernie_image import fused_rope
|
||||
|
||||
query, key, freqs_cos, freqs_sin = _inputs((1, 17, 3, 128))
|
||||
|
||||
def unexpected_launch(*args):
|
||||
raise AssertionError("mismatched Q/K inputs must remain native")
|
||||
|
||||
monkeypatch.setattr(
|
||||
fused_rope,
|
||||
"_launch_fused_qk_rotary_emb",
|
||||
unexpected_launch,
|
||||
)
|
||||
for unsupported_key in (key.float(), key.cpu()):
|
||||
assert (
|
||||
fused_rope.try_fused_qk_rotary_emb(
|
||||
query,
|
||||
unsupported_key,
|
||||
freqs_cos,
|
||||
freqs_sin,
|
||||
)
|
||||
is None
|
||||
)
|
||||
@@ -1,8 +1,12 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.ernie_image.ernie_image_transformer import (
|
||||
ErnieImageEmbedND3,
|
||||
_apply_qk_rotary_emb,
|
||||
_apply_rotary_emb,
|
||||
rope,
|
||||
)
|
||||
@@ -185,3 +189,30 @@ class TestErnieImageRopePositionEmbedding:
|
||||
out_diffusers = x * cos_ + rotated * sin_
|
||||
|
||||
assert torch.allclose(out_vllm, out_diffusers, atol=1e-6), "vllm-omni and diffusers outputs should match"
|
||||
|
||||
def test_apply_qk_rotary_emb_uses_native_fallback(self, monkeypatch):
|
||||
"""Unsupported routes preserve both existing eager calls exactly."""
|
||||
from vllm_omni.diffusion.models.ernie_image import (
|
||||
ernie_image_transformer,
|
||||
)
|
||||
|
||||
B, S, H, D = 2, 17, 3, 128
|
||||
query = torch.randn(B, S, H, D)
|
||||
key = torch.randn(B, S, H, D)
|
||||
freqs_cos = torch.randn(B, S, D // 2)
|
||||
freqs_sin = torch.randn(B, S, D // 2)
|
||||
monkeypatch.setattr(
|
||||
ernie_image_transformer,
|
||||
"try_fused_qk_rotary_emb",
|
||||
lambda *args: None,
|
||||
)
|
||||
|
||||
actual_query, actual_key = _apply_qk_rotary_emb(query, key, freqs_cos, freqs_sin)
|
||||
assert torch.equal(
|
||||
actual_query,
|
||||
_apply_rotary_emb(query, freqs_cos, freqs_sin),
|
||||
)
|
||||
assert torch.equal(
|
||||
actual_key,
|
||||
_apply_rotary_emb(key, freqs_cos, freqs_sin),
|
||||
)
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from pytest_mock import MockerFixture
|
||||
@@ -265,7 +268,7 @@ class TestFlux2TransformerLayerwiseOffload:
|
||||
]
|
||||
|
||||
def test_get_blocks_from_dit(self):
|
||||
from vllm_omni.diffusion.offloader.layerwise_backend import LayerWiseOffloadBackend
|
||||
from vllm_omni.diffusion.offloader.block_discovery import get_blocks_from_dit
|
||||
|
||||
# Block discovery only needs the ModuleList structure, not production dims.
|
||||
model = Flux2Transformer2DModel(
|
||||
@@ -275,6 +278,6 @@ class TestFlux2TransformerLayerwiseOffload:
|
||||
attention_head_dim=4,
|
||||
joint_attention_dim=16,
|
||||
)
|
||||
attr_names, blocks = LayerWiseOffloadBackend.get_blocks_from_dit(model)
|
||||
attr_names, blocks = get_blocks_from_dit(model)
|
||||
assert attr_names == ["transformer_blocks", "single_transformer_blocks"]
|
||||
assert len(blocks) == 4
|
||||
@@ -399,15 +399,14 @@ class TestGlmImagePrepareModule:
|
||||
prepare = GlmImagePrepare(
|
||||
image_projector=projector,
|
||||
rope=rope,
|
||||
patch_size=2,
|
||||
)
|
||||
|
||||
# Test forward pass
|
||||
hidden_states = torch.randn(1, 16, 64, 64) # [B, C, H, W]
|
||||
result = prepare(hidden_states)
|
||||
|
||||
assert len(result) == 5
|
||||
hidden_out, rope_cos, rope_sin, height, width = result
|
||||
assert len(result) == 3
|
||||
hidden_out, rope_cos, rope_sin = result
|
||||
assert hidden_out.shape[0] == 1 # batch size
|
||||
assert hidden_out.shape[1] == 1024 # seq_len (32 * 32)
|
||||
assert hidden_out.shape[2] == 2560 # hidden_dim
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,258 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Two-process USP smoke test for the Helios transformer.
|
||||
|
||||
Pins the multi-rank mechanism that ``test_helios_usp_shard.py`` (unit tests
|
||||
of ``_sp_split_seq`` alone) cannot see:
|
||||
|
||||
* the manual ``_sp_shard_depth`` bump in ``forward()`` must make attn1 run
|
||||
the Ulysses all-to-all (not ``NoParallelAttention``);
|
||||
* attn2 cross-attention must stay rank-local (``skip_sequence_parallel=
|
||||
True``): if replicated text K/V were fed through Ulysses they would be
|
||||
ws-fold duplicated and outputs would diverge from the single-rank run;
|
||||
* end-to-end correctness: ws=2 output equals the ws=1 baseline for the same
|
||||
weights/inputs (divisible shapes, so the padding path is not involved).
|
||||
|
||||
Runs ``HeliosTransformer3DModel.forward`` with tiny random weights via
|
||||
``torch.multiprocessing.spawn``: first a baseline (world_size=1), then an
|
||||
SP run (world_size=2, ulysses_degree=2); rank-0 outputs are compared.
|
||||
|
||||
Requires >= 2 devices; skipped otherwise.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.helpers.mark import hardware_marks
|
||||
from vllm_omni.diffusion.config import set_current_diffusion_config
|
||||
from vllm_omni.diffusion.data import DiffusionParallelConfig, OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.distributed.parallel_state import (
|
||||
destroy_distributed_env,
|
||||
init_distributed_environment,
|
||||
initialize_model_parallel,
|
||||
)
|
||||
from vllm_omni.diffusion.distributed.sp_plan import (
|
||||
SequenceParallelConfig,
|
||||
get_sp_plan_from_model,
|
||||
)
|
||||
from vllm_omni.diffusion.forward_context import get_forward_context, set_forward_context
|
||||
from vllm_omni.diffusion.hooks.sequence_parallel import apply_sequence_parallel
|
||||
from vllm_omni.diffusion.models.helios.helios_transformer import HeliosTransformer3DModel
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
_L4_TWO_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=2)
|
||||
pytestmark = [pytest.mark.full_model, pytest.mark.diffusion, *_L4_TWO_GPU]
|
||||
|
||||
RANDOM_SEED = 42
|
||||
DTYPE = torch.float32 # keep both runs on the SDPA fallback for tight tolerance
|
||||
|
||||
# Tiny but architecturally faithful config:
|
||||
# inner_dim = 4 * 32 = 128, rope_dim sums to head_dim (32),
|
||||
# current chunk 2x4x6 latent -> (1,2,2) patch -> 2*2*3 = 12 tokens,
|
||||
# short history 1x4x6 latent -> (1,2,2) patch -> 6 tokens.
|
||||
# All component lengths are even, so ws=2 needs no padding replicas.
|
||||
TINY_MODEL_KWARGS = dict(
|
||||
patch_size=(1, 2, 2),
|
||||
num_attention_heads=4,
|
||||
attention_head_dim=32,
|
||||
in_channels=4,
|
||||
out_channels=4,
|
||||
text_dim=64,
|
||||
freq_dim=32,
|
||||
ffn_dim=128,
|
||||
num_layers=2,
|
||||
rope_dim=(12, 10, 10),
|
||||
rope_theta=10000.0,
|
||||
guidance_cross_attn=True,
|
||||
zero_history_timestep=True,
|
||||
has_multi_term_memory_patch=True,
|
||||
is_amplify_history=False,
|
||||
)
|
||||
|
||||
BATCH = 1
|
||||
CUR_FRAMES, LAT_H, LAT_W = 2, 4, 6 # -> 12 current tokens
|
||||
HIST_FRAMES = 1 # short history -> 6 tokens
|
||||
TEXT_LEN = 5
|
||||
|
||||
|
||||
def _update_env(envs_dict: dict[str, str]) -> None:
|
||||
for k, v in envs_dict.items():
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
def _make_inputs(device: torch.device) -> dict[str, torch.Tensor]:
|
||||
torch.manual_seed(RANDOM_SEED + 1)
|
||||
return {
|
||||
"hidden_states": torch.randn(BATCH, 4, CUR_FRAMES, LAT_H, LAT_W, device=device, dtype=DTYPE),
|
||||
"timestep": torch.full((BATCH,), 500.0, device=device),
|
||||
"encoder_hidden_states": torch.randn(
|
||||
BATCH, TEXT_LEN, TINY_MODEL_KWARGS["text_dim"], device=device, dtype=DTYPE
|
||||
),
|
||||
"indices_latents_history_short": torch.tensor([[0]], device=device),
|
||||
"latents_history_short": torch.randn(BATCH, 4, HIST_FRAMES, LAT_H, LAT_W, device=device, dtype=DTYPE),
|
||||
}
|
||||
|
||||
|
||||
def _spy_strategies(model: HeliosTransformer3DModel) -> dict[str, list[str]]:
|
||||
"""Record the parallel strategy each Attention resolves to during forward."""
|
||||
seen: dict[str, list[str]] = {"attn1": [], "attn2": []}
|
||||
for name, attn in (("attn1", model.blocks[0].attn1.attn), ("attn2", model.blocks[0].attn2.attn)):
|
||||
original = attn._get_active_parallel_strategy
|
||||
|
||||
def spy(original=original, name=name):
|
||||
strategy = original()
|
||||
seen[name].append(getattr(strategy, "name", type(strategy).__name__))
|
||||
return strategy
|
||||
|
||||
attn._get_active_parallel_strategy = spy
|
||||
return seen
|
||||
|
||||
|
||||
def helios_forward_on_model(
|
||||
local_rank: int,
|
||||
world_size: int,
|
||||
ulysses_degree: int,
|
||||
output_file: str,
|
||||
model_state_file: str,
|
||||
is_baseline: bool,
|
||||
) -> None:
|
||||
device = torch.device(f"{current_omni_platform.device_type}:{local_rank}")
|
||||
current_omni_platform.set_device(device)
|
||||
torch.set_default_device(device)
|
||||
torch.set_default_dtype(DTYPE)
|
||||
|
||||
_update_env(
|
||||
{
|
||||
"RANK": str(local_rank),
|
||||
"LOCAL_RANK": str(local_rank),
|
||||
"WORLD_SIZE": str(world_size),
|
||||
"MASTER_ADDR": "localhost",
|
||||
"MASTER_PORT": "12361",
|
||||
}
|
||||
)
|
||||
init_distributed_environment()
|
||||
|
||||
parallel_config = DiffusionParallelConfig(
|
||||
pipeline_parallel_size=1,
|
||||
data_parallel_size=1,
|
||||
tensor_parallel_size=1,
|
||||
sequence_parallel_size=ulysses_degree,
|
||||
ulysses_degree=ulysses_degree,
|
||||
ring_degree=1,
|
||||
allgather_degree=1,
|
||||
cfg_parallel_size=1,
|
||||
)
|
||||
od_config = OmniDiffusionConfig.from_kwargs(
|
||||
model="test_model",
|
||||
dtype=DTYPE,
|
||||
parallel_config=parallel_config,
|
||||
diffusion_attention_backend="TORCH_SDPA",
|
||||
)
|
||||
initialize_model_parallel(
|
||||
data_parallel_size=1,
|
||||
cfg_parallel_size=1,
|
||||
sequence_parallel_size=ulysses_degree,
|
||||
ulysses_degree=ulysses_degree,
|
||||
ring_degree=1,
|
||||
allgather_degree=1,
|
||||
tensor_parallel_size=1,
|
||||
pipeline_parallel_size=1,
|
||||
)
|
||||
|
||||
with set_forward_context(omni_diffusion_config=od_config), set_current_diffusion_config(od_config):
|
||||
torch.manual_seed(RANDOM_SEED)
|
||||
model = HeliosTransformer3DModel(**TINY_MODEL_KWARGS).to(device).to(DTYPE)
|
||||
|
||||
if is_baseline and local_rank == 0:
|
||||
with open(model_state_file, "wb") as f:
|
||||
torch.save({k: v.cpu() for k, v in model.state_dict().items()}, f)
|
||||
|
||||
if world_size > 1:
|
||||
torch.distributed.barrier()
|
||||
|
||||
with open(model_state_file, "rb") as f:
|
||||
model_state = torch.load(f)
|
||||
model.load_state_dict({k: v.to(device).to(DTYPE) for k, v in model_state.items()})
|
||||
|
||||
if not is_baseline:
|
||||
# Mirror registry._apply_sequence_parallel_if_enabled: install the
|
||||
# proj_out gather hook from the model's own _sp_plan and flip
|
||||
# sp_plan_hooks_applied so sp_active follows _sp_shard_depth
|
||||
# (i.e. the manual bump in Helios forward is what enables Ulysses).
|
||||
plan = get_sp_plan_from_model(model)
|
||||
assert plan is not None, "HeliosTransformer3DModel must define _sp_plan"
|
||||
apply_sequence_parallel(model, SequenceParallelConfig(ulysses_degree=ulysses_degree), plan)
|
||||
get_forward_context().sp_plan_hooks_applied = True
|
||||
|
||||
seen = _spy_strategies(model)
|
||||
inputs = _make_inputs(device)
|
||||
output = model(**inputs).sample
|
||||
|
||||
assert output.shape == (BATCH, 4, CUR_FRAMES, LAT_H, LAT_W), f"unexpected output shape {output.shape}"
|
||||
|
||||
if is_baseline:
|
||||
assert seen["attn1"] and all(n == "none" for n in seen["attn1"]), (
|
||||
f"baseline attn1 must use NoParallelAttention ('none'), saw {seen['attn1']}"
|
||||
)
|
||||
else:
|
||||
# attn1: SP communication must be active inside the block loop
|
||||
# (regression here = the manual _sp_shard_depth bump was lost).
|
||||
assert seen["attn1"] and all(n == "ulysses" for n in seen["attn1"]), (
|
||||
f"attn1 must run Ulysses under USP (manual _sp_shard_depth bump), saw {seen['attn1']}"
|
||||
)
|
||||
# attn2: replicated text K/V must stay rank-local
|
||||
# (regression here = the ws-fold duplicated-keys bug).
|
||||
assert seen["attn2"] and all(n == "none" for n in seen["attn2"]), (
|
||||
f"attn2 must skip sequence parallel (replicated text K/V), saw {seen['attn2']}"
|
||||
)
|
||||
|
||||
if local_rank == 0:
|
||||
with open(output_file, "wb") as f:
|
||||
torch.save(output.detach().cpu().float(), f)
|
||||
|
||||
destroy_distributed_env()
|
||||
|
||||
|
||||
def test_helios_usp2_matches_single_rank():
|
||||
"""ws=2 (Ulysses) must reproduce the ws=1 output for identical weights/inputs."""
|
||||
available_devices = current_omni_platform.get_device_count()
|
||||
if available_devices < 2:
|
||||
pytest.skip(f"Test requires 2 devices but only {available_devices} available")
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".pkl") as f:
|
||||
baseline_output_file = f.name
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".pkl") as f:
|
||||
sp_output_file = f.name
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".pkl") as f:
|
||||
model_state_file = f.name
|
||||
|
||||
try:
|
||||
torch.multiprocessing.spawn(
|
||||
helios_forward_on_model,
|
||||
args=(1, 1, baseline_output_file, model_state_file, True),
|
||||
nprocs=1,
|
||||
)
|
||||
torch.multiprocessing.spawn(
|
||||
helios_forward_on_model,
|
||||
args=(2, 2, sp_output_file, model_state_file, False),
|
||||
nprocs=2,
|
||||
)
|
||||
|
||||
with open(baseline_output_file, "rb") as f:
|
||||
baseline = torch.load(f)
|
||||
with open(sp_output_file, "rb") as f:
|
||||
sp = torch.load(f)
|
||||
|
||||
assert baseline.shape == sp.shape
|
||||
max_abs_diff = (baseline - sp).abs().max().item()
|
||||
assert torch.allclose(baseline, sp, atol=1e-3, rtol=1e-3), (
|
||||
f"USP=2 output diverges from single-rank baseline: max_abs_diff={max_abs_diff:.6e}"
|
||||
)
|
||||
finally:
|
||||
for path in [baseline_output_file, sp_output_file, model_state_file]:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
@@ -0,0 +1,225 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Unit tests for Helios per-component sequence splitting.
|
||||
|
||||
These tests verify the manual sharding logic that replaces the broken
|
||||
rope/blocks.0 hooks in the Helios ``_sp_plan``. They exercise the *real*
|
||||
``HeliosTransformer3DModel._sp_split_seq`` (the parallel-state getters it
|
||||
imports lazily inside the method body are monkeypatched), so the model-side
|
||||
helper cannot drift silently from what is tested. For the multi-rank
|
||||
mechanism around the helper (manual ``_sp_shard_depth`` bump, attn1/attn2
|
||||
parallel strategy, ws=2 vs ws=1 output equivalence) see
|
||||
``test_helios_usp_2rank.py``.
|
||||
|
||||
Coverage:
|
||||
1. USP disabled (ws == 1) returns the tensor unchanged.
|
||||
2. Divisible split gives each rank the expected slice.
|
||||
3. Non-divisible split pads with replicated tokens (no crash).
|
||||
4. Per-component split: history + current tokens are distributed so
|
||||
that *both* ranks receive some current tokens (no mosaic regression).
|
||||
5. Edge cases: 1-D tensor, empty seq, 4-D tensor.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import vllm_omni.diffusion.distributed.parallel_state as sp_parallel_state
|
||||
from vllm_omni.diffusion.models.helios.helios_transformer import HeliosTransformer3DModel
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _fake_sp_state(ws: int, rank: int) -> Iterator[None]:
|
||||
"""Patch the parallel-state getters used by the real helper.
|
||||
|
||||
``_sp_split_seq`` imports these names from the module *inside* its
|
||||
function body, so replacing the module attributes takes effect.
|
||||
"""
|
||||
prev_ws = sp_parallel_state.get_sequence_parallel_world_size
|
||||
prev_rank = sp_parallel_state.get_sequence_parallel_rank
|
||||
sp_parallel_state.get_sequence_parallel_world_size = lambda: ws
|
||||
sp_parallel_state.get_sequence_parallel_rank = lambda: rank
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
sp_parallel_state.get_sequence_parallel_world_size = prev_ws
|
||||
sp_parallel_state.get_sequence_parallel_rank = prev_rank
|
||||
|
||||
|
||||
def _sp_split_seq(x: torch.Tensor, ws: int, rank: int) -> torch.Tensor:
|
||||
"""Call the real model-side helper without constructing a model."""
|
||||
with _fake_sp_state(ws, rank):
|
||||
return HeliosTransformer3DModel._sp_split_seq(None, x)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSpSplitSeqDisabled:
|
||||
"""When USP is off (ws == 1) the helper must be a no-op."""
|
||||
|
||||
def test_ws1_returns_unchanged(self):
|
||||
x = torch.randn(2, 100, 64)
|
||||
out = _sp_split_seq(x, ws=1, rank=0)
|
||||
assert torch.equal(out, x)
|
||||
assert out is x
|
||||
|
||||
|
||||
class TestSpSplitSeqDivisible:
|
||||
"""Divisible sequences: each rank gets exactly seq_len / ws tokens."""
|
||||
|
||||
def test_each_rank_gets_correct_slice(self):
|
||||
for ws in [2, 4, 8]:
|
||||
seq_len = ws * 10
|
||||
x = torch.arange(seq_len * 4).float().reshape(1, seq_len, 4)
|
||||
for rank in range(ws):
|
||||
out = _sp_split_seq(x, ws=ws, rank=rank)
|
||||
n = seq_len // ws
|
||||
expected = x[:, rank * n : (rank + 1) * n, :]
|
||||
assert out.shape == (1, n, 4)
|
||||
assert torch.equal(out, expected)
|
||||
|
||||
def test_output_is_contiguous(self):
|
||||
x = torch.randn(1, 20, 8)
|
||||
out = _sp_split_seq(x, ws=2, rank=0)
|
||||
assert out.is_contiguous()
|
||||
|
||||
|
||||
class TestSpSplitSeqNonDivisible:
|
||||
"""Non-divisible sequences are padded with replicated tokens."""
|
||||
|
||||
def test_non_divisible_pads_instead_of_crash(self):
|
||||
"""400x272 → 9*25*17 = 3825 tokens, USP=2 → not divisible.
|
||||
Should pad to 3826 and split, not crash."""
|
||||
x = torch.randn(1, 3825, 8)
|
||||
for rank in range(2):
|
||||
out = _sp_split_seq(x, ws=2, rank=rank)
|
||||
# 3825 → pad 1 → 3826 → /2 = 1913
|
||||
assert out.shape[1] == 1913
|
||||
|
||||
def test_non_divisible_pads_with_replicated_last_token(self):
|
||||
"""Verify the padding is the replicated last token, not zeros."""
|
||||
x = torch.randn(1, 7, 4) # 7 % 2 = 1, pad 1
|
||||
last_token = x[:, -1:, :].clone()
|
||||
out = _sp_split_seq(x, ws=2, rank=0)
|
||||
# rank 0 gets tokens [0:4] (first 4 of the padded 8)
|
||||
assert out.shape == (1, 4, 4)
|
||||
# rank 1 gets tokens [4:8] — token 7 (the padded one) = last token
|
||||
out1 = _sp_split_seq(x, ws=2, rank=1)
|
||||
assert out1.shape == (1, 4, 4)
|
||||
assert torch.equal(out1[:, -1:, :], last_token)
|
||||
|
||||
def test_non_divisible_7_4(self):
|
||||
"""7 tokens, ws=4 → pad 1 → 8 → /4 = 2 per rank."""
|
||||
x = torch.randn(1, 7, 8)
|
||||
for rank in range(4):
|
||||
out = _sp_split_seq(x, ws=4, rank=rank)
|
||||
assert out.shape[1] == 2
|
||||
|
||||
def test_non_divisible_15_4(self):
|
||||
"""15 tokens, ws=4 → pad 1 → 16 → /4 = 4 per rank."""
|
||||
x = torch.randn(1, 15, 8)
|
||||
for rank in range(4):
|
||||
out = _sp_split_seq(x, ws=4, rank=rank)
|
||||
assert out.shape[1] == 4
|
||||
|
||||
|
||||
class TestSpSplitSeqPerComponent:
|
||||
"""Simulate Helios forward: split current + history components so that
|
||||
*both* ranks receive some current tokens (no mosaic regression)."""
|
||||
|
||||
def test_both_ranks_get_current_tokens(self):
|
||||
"""history=2400, current=540, ws=2.
|
||||
After per-component split, each rank must have > 0 current tokens."""
|
||||
current = torch.randn(1, 540, 64)
|
||||
history = torch.randn(1, 2400, 64)
|
||||
|
||||
for rank in range(2):
|
||||
cur_local = _sp_split_seq(current, ws=2, rank=rank)
|
||||
hist_local = _sp_split_seq(history, ws=2, rank=rank)
|
||||
|
||||
assert cur_local.shape[1] > 0, f"Rank {rank} received 0 current tokens"
|
||||
assert cur_local.shape[1] == 270 # 540 / 2
|
||||
assert hist_local.shape[1] == 1200 # 2400 / 2
|
||||
|
||||
original_context_length = cur_local.shape[1]
|
||||
assert original_context_length > 0 # no mosaic regression
|
||||
|
||||
def test_gather_restores_full_current(self):
|
||||
"""After splitting current into 2 ranks, simulating proj_out
|
||||
gather must restore the full current sequence length so that
|
||||
the original unpatchify reshape succeeds."""
|
||||
current = torch.randn(1, 540, 64)
|
||||
local_chunks = [_sp_split_seq(current, ws=2, rank=rank) for rank in range(2)]
|
||||
gathered = torch.cat(local_chunks, dim=1)
|
||||
assert gathered.shape[1] == 540 # full current restored
|
||||
|
||||
def test_gather_with_padding_truncates_correctly(self):
|
||||
"""Non-divisible current (3825) → pad to 3826 → split →
|
||||
gather → 3826. Slice to 3825 for unpatchify."""
|
||||
current = torch.randn(1, 3825, 64)
|
||||
local_chunks = [_sp_split_seq(current, ws=2, rank=rank) for rank in range(2)]
|
||||
gathered = torch.cat(local_chunks, dim=1)
|
||||
assert gathered.shape[1] == 3826 # padded size
|
||||
# Simulate the slice before unpatchify
|
||||
expected_seq = 3825
|
||||
if gathered.shape[1] > expected_seq:
|
||||
gathered = gathered[:, :expected_seq, :]
|
||||
assert gathered.shape[1] == 3825 # correct after slice
|
||||
|
||||
def test_rotary_emb_aligned_with_hidden_states(self):
|
||||
"""rotary_emb and hidden_states must be split at the same
|
||||
positions so that tokens align inside the attention layer."""
|
||||
hidden = torch.randn(1, 540, 5120)
|
||||
rotary = torch.randn(1, 540, 256)
|
||||
for rank in range(2):
|
||||
h_local = _sp_split_seq(hidden, ws=2, rank=rank)
|
||||
r_local = _sp_split_seq(rotary, ws=2, rank=rank)
|
||||
assert h_local.shape[1] == r_local.shape[1]
|
||||
|
||||
def test_whole_split_causes_mosaic_but_per_component_does_not(self):
|
||||
"""Demonstrates why per-component split is needed:
|
||||
with whole split (simulated), rank 0 gets 0 current tokens;
|
||||
with per-component split, both ranks get current tokens."""
|
||||
history = torch.randn(1, 2400, 64)
|
||||
current = torch.randn(1, 540, 64)
|
||||
full = torch.cat([history, current], dim=1) # 2940
|
||||
|
||||
# Whole split (the OLD broken behavior)
|
||||
whole_n = full.shape[1] // 2 # 1470
|
||||
rank0_whole = full[:, :whole_n, :]
|
||||
rank0_current = rank0_whole.shape[1] - 2400 # negative → 0 current
|
||||
assert rank0_current <= 0, "Whole split should give rank 0 no current"
|
||||
|
||||
# Per-component split (the NEW correct behavior)
|
||||
for rank in range(2):
|
||||
cur_local = _sp_split_seq(current, ws=2, rank=rank)
|
||||
assert cur_local.shape[1] == 270 # both ranks have current
|
||||
|
||||
|
||||
class TestSpSplitSeqEdgeCases:
|
||||
"""Edge cases that should not crash or silently misbehave."""
|
||||
|
||||
def test_1d_tensor_skipped(self):
|
||||
"""1-D tensors must be returned unchanged (dim < 2 guard)."""
|
||||
x = torch.randn(100)
|
||||
out = _sp_split_seq(x, ws=2, rank=0)
|
||||
assert torch.equal(out, x)
|
||||
|
||||
def test_empty_seq_skipped(self):
|
||||
"""Empty sequence dimension must be returned unchanged."""
|
||||
x = torch.randn(1, 0, 64)
|
||||
out = _sp_split_seq(x, ws=2, rank=0)
|
||||
assert out.shape == x.shape
|
||||
|
||||
def test_4d_tensor_split_on_dim1(self):
|
||||
"""4-D tensor [B, S, H, D] should split along dim=1."""
|
||||
x = torch.randn(1, 20, 8, 32)
|
||||
out = _sp_split_seq(x, ws=2, rank=1)
|
||||
assert out.shape == (1, 10, 8, 32)
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -362,6 +362,20 @@ def test_prepared_model_inputs_match_local_tokenization(
|
||||
local_inputs = pipeline.prepare_model_inputs(**common_kwargs)
|
||||
prepared_inputs = pipeline.prepare_model_inputs(**common_kwargs, prepared_layout=prepared_layout)
|
||||
|
||||
if with_reference_image:
|
||||
# Preparation runs without a forward context. Coverage must come from
|
||||
# the scheduler metadata copied onto this request, including every CFG row.
|
||||
reused = pipeline.prepare_model_inputs(
|
||||
**common_kwargs, prepared_layout=prepared_layout, kv_computed_tokens=(5,) * len(prefix_lens)
|
||||
)
|
||||
assert reused["cond_vae_images"] is None and reused["cond_vit_images"] is None
|
||||
partial = pipeline.prepare_model_inputs(
|
||||
**common_kwargs,
|
||||
prepared_layout=prepared_layout,
|
||||
kv_computed_tokens=(5,) * (len(prefix_lens) - 1) + (4,),
|
||||
)
|
||||
assert partial["cond_vae_images"] is not None and partial["cond_vit_images"] is not None
|
||||
|
||||
comparable_fields = (
|
||||
"input_ids",
|
||||
"position_ids",
|
||||
@@ -474,3 +488,33 @@ def test_paged_preprocess_attaches_layout_and_scheduler_kv_requests(monkeypatch)
|
||||
(12, 17, 32)
|
||||
]
|
||||
assert len(tokenizer.calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("transfer_tokens, expected", [(9, [7, 5]), (4, [4, 4])])
|
||||
def test_cfg_rows_preserve_reusable_token_ids_and_native_transfer_boundary(transfer_tokens, expected):
|
||||
from vllm_omni.diffusion.diffusion_kv.kv_connector import prepare_kv_requests
|
||||
|
||||
tokenizer, image_processor = _components([12, 14])
|
||||
request = _request(guidance_scale=5.0)
|
||||
layout = _prepare(request, tokenizer, image_processor)
|
||||
layout.tokenizer_output.think_recaption_end_pos = [[7], [5]]
|
||||
layout.tokenizer_output.uncond_cfg_start_pos = [[None], [5]]
|
||||
rows = build_hunyuan_diffusion_kv_requests(request, layout)
|
||||
assert [row.prompt_token_ids for row in rows] == [
|
||||
layout.tokenizer_output.tokens[0, :7].tolist(),
|
||||
layout.tokenizer_output.tokens[1, :5].tolist(),
|
||||
]
|
||||
prepare_kv_requests(rows, {"num_transfer_tokens": transfer_tokens})
|
||||
assert [row.num_prompt_tokens for row in rows] == expected
|
||||
assert all(row.kv_transfer_params["num_transfer_tokens"] == transfer_tokens for row in rows)
|
||||
layout.tokenizer_output.uncond_cfg_start_pos = [[5]]
|
||||
with pytest.raises(ValueError, match="one boundary per CFG row"):
|
||||
build_hunyuan_diffusion_kv_requests(request, layout)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("computed, expected", [((8, 8), True), ((8, 5), False), ((0, 0), False), ((), False)])
|
||||
def test_native_image_reuse_requires_coverage_in_every_cfg_row(computed, expected):
|
||||
from vllm_omni.diffusion.models.hunyuan_image3.request_layout import native_kv_covers_cond_images
|
||||
|
||||
output = TokenizerEncodeOutput(joint_image_slices=[[slice(2, 8)], [slice(2, 6)]])
|
||||
assert native_kv_covers_cond_images(output, computed) is expected
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Unit tests for HunyuanImage3 AR sampler logic (stage transitions,
|
||||
ratio restriction, comprehension blocking)."""
|
||||
|
||||
@@ -21,6 +21,10 @@ RATIO_END = 210
|
||||
RATIO_OTHER_START = 220
|
||||
RATIO_OTHER_END = 223
|
||||
TIMESTEP = 224
|
||||
CFG = 110
|
||||
IMG_TOK = 111
|
||||
JOINT_SEP = 112
|
||||
EOI = 113
|
||||
|
||||
|
||||
class FakeSamplerModel:
|
||||
@@ -47,10 +51,13 @@ class FakeSamplerModel:
|
||||
self._stage_transitions[END_OF_THINK] = [RECAPTION]
|
||||
self._stage_transitions[END_OF_RECAPTION] = [ANSWER, BOI, SIZE_TOKEN]
|
||||
|
||||
self._blocked_token_ids: set[int] = set()
|
||||
if is_comprehension:
|
||||
self._blocked_token_ids.update([BOI, SIZE_TOKEN])
|
||||
self._blocked_token_ids.update(self._all_ratio_ids)
|
||||
# Mirrors the model: the blocklist is always built so generation-mode
|
||||
# engines can apply it to per-request comprehension rows (#6088).
|
||||
self._cfg_token_id = CFG
|
||||
self._blocked_token_ids: set[int] = {BOI, EOI, SIZE_TOKEN, CFG, IMG_TOK, TIMESTEP, JOINT_SEP}
|
||||
self._blocked_token_ids.update(self._all_ratio_ids)
|
||||
self._blocked_token_ids_tensor = None
|
||||
self._sampler = None
|
||||
|
||||
# Bind the real methods from the model class.
|
||||
from vllm_omni.model_executor.models.hunyuan_image3.hunyuan_image3 import (
|
||||
@@ -59,6 +66,8 @@ class FakeSamplerModel:
|
||||
|
||||
_get_forced_token = _Real._get_forced_token
|
||||
_apply_ratio_restriction = _Real._apply_ratio_restriction
|
||||
_row_is_comprehension = staticmethod(_Real._row_is_comprehension)
|
||||
sample = _Real.sample
|
||||
|
||||
|
||||
class FakeTokenEmbedding:
|
||||
@@ -250,3 +259,114 @@ class TestForceEosAfterRatio:
|
||||
assert logits[0, EOS].item() == 0
|
||||
non_eos_max = logits[0, :EOS].max().item()
|
||||
assert non_eos_max == min_score
|
||||
|
||||
|
||||
class _RecordingSampler:
|
||||
"""Stub sampler: returns the (mutated) logits so tests can assert on them."""
|
||||
|
||||
def __call__(self, *, logits, sampling_metadata):
|
||||
return logits
|
||||
|
||||
|
||||
def _metadata(output_token_ids):
|
||||
from types import SimpleNamespace
|
||||
|
||||
return SimpleNamespace(output_token_ids=output_token_ids)
|
||||
|
||||
|
||||
class TestPerRequestTaskMode:
|
||||
"""Generation-mode engines must honor per-request comprehension rows (#6088).
|
||||
|
||||
A text chat (I2T/T2T) served by an AR+DiT deployment is marked with
|
||||
extra_args={"ar_task_mode": "comprehension"}; its rows get the stateless
|
||||
image-token blocking instead of stage-transition forcing, while untagged
|
||||
rows keep the generation behavior.
|
||||
"""
|
||||
|
||||
VOCAB = 300
|
||||
|
||||
def _run(self, model, histories, per_req_extra_args):
|
||||
logits = torch.zeros(len(histories), self.VOCAB)
|
||||
logits[:, 50] = 7.0 # ordinary text token, must survive comprehension
|
||||
model._sampler = _RecordingSampler()
|
||||
out = model.sample(logits, _metadata(histories), per_req_extra_args=per_req_extra_args)
|
||||
return out
|
||||
|
||||
def test_comprehension_row_skips_forcing_and_blocks_image_tokens(self):
|
||||
model = FakeSamplerModel(is_comprehension=False)
|
||||
min_score = torch.finfo(torch.float32).min
|
||||
|
||||
logits = self._run(
|
||||
model,
|
||||
histories=[[END_OF_THINK], [END_OF_THINK]],
|
||||
per_req_extra_args=[{"ar_task_mode": "comprehension"}, None],
|
||||
)
|
||||
|
||||
# Row 0 (comprehension): no </think> -> <recaption> forcing (ordinary
|
||||
# text token survives, which a force-fill would have erased) and every
|
||||
# DiT scaffold token is masked.
|
||||
assert logits[0, 50].item() == 7.0
|
||||
for tid in (BOI, EOI, SIZE_TOKEN, CFG, IMG_TOK, TIMESTEP, JOINT_SEP, RATIO_START, RATIO_OTHER_START):
|
||||
assert logits[0, tid].item() == min_score, f"token {tid} not blocked"
|
||||
|
||||
# Row 1 (generation): forced to <recaption> exactly as before.
|
||||
assert logits[1, RECAPTION].item() == 0
|
||||
assert logits[1, 50].item() == min_score
|
||||
|
||||
def test_no_extra_args_keeps_generation_behavior(self):
|
||||
model = FakeSamplerModel(is_comprehension=False)
|
||||
min_score = torch.finfo(torch.float32).min
|
||||
|
||||
logits = self._run(model, histories=[[END_OF_THINK]], per_req_extra_args=None)
|
||||
|
||||
assert logits[0, RECAPTION].item() == 0
|
||||
assert logits[0, 50].item() == min_score
|
||||
|
||||
def test_empty_or_foreign_extra_args_keeps_generation_behavior(self):
|
||||
model = FakeSamplerModel(is_comprehension=False)
|
||||
|
||||
logits = self._run(
|
||||
model,
|
||||
histories=[[END_OF_THINK], [END_OF_THINK]],
|
||||
per_req_extra_args=[{}, {"other_key": 1}],
|
||||
)
|
||||
|
||||
assert logits[0, RECAPTION].item() == 0
|
||||
assert logits[1, RECAPTION].item() == 0
|
||||
|
||||
def test_comprehension_row_without_trigger_history(self):
|
||||
model = FakeSamplerModel(is_comprehension=False)
|
||||
min_score = torch.finfo(torch.float32).min
|
||||
|
||||
logits = self._run(
|
||||
model,
|
||||
histories=[[1, 2, 3]],
|
||||
per_req_extra_args=[{"ar_task_mode": "comprehension"}],
|
||||
)
|
||||
|
||||
assert logits[0, 50].item() == 7.0
|
||||
assert logits[0, CFG].item() == min_score
|
||||
|
||||
def test_static_comprehension_engine_blocks_cfg(self):
|
||||
model = FakeSamplerModel(is_comprehension=True)
|
||||
min_score = torch.finfo(torch.float32).min
|
||||
|
||||
logits = self._run(model, histories=[[1, 2]], per_req_extra_args=None)
|
||||
|
||||
for tid in (BOI, EOI, SIZE_TOKEN, CFG, IMG_TOK, TIMESTEP, JOINT_SEP):
|
||||
assert logits[0, tid].item() == min_score
|
||||
assert logits[0, 50].item() == 7.0
|
||||
|
||||
def test_extra_args_shorter_than_batch_defaults_to_generation(self):
|
||||
model = FakeSamplerModel(is_comprehension=False)
|
||||
|
||||
logits = self._run(
|
||||
model,
|
||||
histories=[[END_OF_THINK], [END_OF_THINK]],
|
||||
per_req_extra_args=[{"ar_task_mode": "comprehension"}],
|
||||
)
|
||||
min_score = torch.finfo(torch.float32).min
|
||||
|
||||
assert logits[0, 50].item() == 7.0 # tagged row: comprehension
|
||||
assert logits[1, RECAPTION].item() == 0 # missing entry: generation
|
||||
assert logits[1, 50].item() == min_score
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -143,6 +144,104 @@ def test_cache_manager_registers_attention_without_adding_dense_state() -> None:
|
||||
assert mgr.state_dict() == {}
|
||||
|
||||
|
||||
def test_cache_manager_initializes_with_quantization_skip_layers(monkeypatch):
|
||||
import vllm_omni.diffusion.attention.layer as layer
|
||||
from vllm_omni.diffusion.config import set_current_diffusion_config
|
||||
from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_image3_transformer import ImageKVCacheManager
|
||||
|
||||
config = SimpleNamespace(
|
||||
diffusion_attention_config=None,
|
||||
parallel_config=SimpleNamespace(ring_degree=1),
|
||||
diffusion_kv_cache_dtype="auto",
|
||||
diffusion_kv_cache_skip_layer_indices={3},
|
||||
)
|
||||
monkeypatch.setattr(layer, "get_attn_backend_for_role", lambda **_: (layer.SDPABackend, None))
|
||||
monkeypatch.setattr(layer, "build_parallel_attention_strategy", lambda **_: object())
|
||||
with patched_mgr_env(), set_current_diffusion_config(config):
|
||||
# Keep the real Attention constructor and its skip-layer validation.
|
||||
with patch(f"{_TRANSFORMER_MODULE}.Attention", layer.Attention):
|
||||
mgr = ImageKVCacheManager(
|
||||
num_heads=NUM_HEADS,
|
||||
num_kv_heads=NUM_KV_HEADS,
|
||||
head_dim=HEAD_DIM,
|
||||
scaling=SCALING,
|
||||
prefix="model.layers.3.self_attn",
|
||||
)
|
||||
assert mgr.attn.layer_idx == 3
|
||||
assert not mgr.attn._should_apply_kv_cache_quant()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("first_step", [True, False])
|
||||
def test_native_connector_keeps_imported_prefix_in_paged_attention(first_step) -> None:
|
||||
from vllm_omni.diffusion.forward_context import override_paged_kv_adapter, set_forward_context
|
||||
|
||||
mgr = _make_cache_mgr()
|
||||
mgr.attn.paged_kv_active = True
|
||||
query_len, prefix_len = IMAGE_TOKEN_LEN, 19
|
||||
key, value = _make_known_kv(2 * query_len)
|
||||
spans = [[(prefix_len, prefix_len + query_len)]] * 2
|
||||
with (
|
||||
set_forward_context(),
|
||||
override_paged_kv_adapter(object()),
|
||||
patch.object(mgr, "_forward_dense_legacy", side_effect=AssertionError("dense KV path")),
|
||||
patch(f"{_TRANSFORMER_MODULE}.repeat_kv", side_effect=AssertionError("KV head expansion")),
|
||||
patch(f"{_TRANSFORMER_MODULE}.get_paged_kv_computed_tokens", return_value=(prefix_len, prefix_len)),
|
||||
):
|
||||
_call_mgr(
|
||||
mgr,
|
||||
2,
|
||||
query_len,
|
||||
prefix_len + query_len,
|
||||
key,
|
||||
value,
|
||||
first_step=first_step,
|
||||
full_attn_spans=spans,
|
||||
)
|
||||
assert len(mgr.attn.paged_calls) == 1
|
||||
assert mgr.attn.paged_calls[0][1].shape == (2, query_len, NUM_KV_HEADS, HEAD_DIM)
|
||||
assert mgr.attn.paged_metadata[0].attn_mask is None
|
||||
assert mgr.attn.paged_metadata[0].full_attn_spans == spans
|
||||
assert mgr.image_kv_cache_map is None
|
||||
assert not hasattr(mgr, "_materialize_paged_prefix")
|
||||
|
||||
|
||||
def test_legacy_tensor_prefix_still_uses_dense_cache_and_reuse() -> None:
|
||||
mgr = _make_cache_mgr()
|
||||
prefix_len, prompt_len = 3, 2
|
||||
imported = _make_known_kv(prefix_len, base=10.0)
|
||||
mgr._injected_ar_kv = [imported]
|
||||
first_len = prompt_len + IMAGE_TOKEN_LEN
|
||||
key, value = _make_known_kv(first_len, base=20.0)
|
||||
with (
|
||||
patch.object(mgr, "_forward_paged", side_effect=AssertionError("legacy entered paged path")),
|
||||
):
|
||||
_call_mgr(
|
||||
mgr,
|
||||
1,
|
||||
first_len,
|
||||
prefix_len + first_len,
|
||||
key,
|
||||
value,
|
||||
first_step=True,
|
||||
gen_timestep_scatter_index=_gen_timestep_index(1, prompt_len),
|
||||
)
|
||||
assert mgr.image_kv_cache_map is not None
|
||||
assert mgr.image_kv_cache_map[0].shape[1] == prefix_len + prompt_len
|
||||
next_key, next_value = _make_known_kv(IMAGE_TOKEN_LEN, base=30.0)
|
||||
_call_mgr(
|
||||
mgr,
|
||||
1,
|
||||
IMAGE_TOKEN_LEN,
|
||||
prefix_len + first_len,
|
||||
next_key,
|
||||
next_value,
|
||||
position_ids=torch.arange(prefix_len + prompt_len, prefix_len + first_len).unsqueeze(0),
|
||||
)
|
||||
assert len(mgr.attn.calls) == 2
|
||||
assert not mgr.attn.paged_calls
|
||||
assert mgr.attn.calls[-1][1].shape == (1, prefix_len + first_len, NUM_HEADS, HEAD_DIM)
|
||||
|
||||
|
||||
def test_scheduler_paged_kv_runs_piecewise_for_first_and_later_steps() -> None:
|
||||
from vllm_omni.diffusion.forward_context import override_paged_kv_adapter, set_forward_context
|
||||
|
||||
|
||||
@@ -13,9 +13,11 @@ from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import (
|
||||
HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS,
|
||||
available_bot_tasks,
|
||||
available_tasks,
|
||||
build_ar_prompt_inputs,
|
||||
build_prompt,
|
||||
build_prompt_tokens,
|
||||
resolve_stop_token_ids,
|
||||
validate_special_token_ids,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
@@ -296,19 +298,42 @@ def _repo_root() -> pathlib.Path:
|
||||
return pathlib.Path(__file__).resolve().parents[4]
|
||||
|
||||
|
||||
def test_end2end_routes_through_shared_prompt_utils():
|
||||
end2end_path = _repo_root() / "examples" / "offline_inference" / "hunyuan_image3" / "end2end.py"
|
||||
tree = ast.parse(end2end_path.read_text(encoding="utf-8"))
|
||||
def test_example_routes_through_shared_seam():
|
||||
"""Neither the shared task examples nor the ``model_extras`` seam itself
|
||||
should duplicate AR prompt tokenization.
|
||||
|
||||
local_func_names = {n.name for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
|
||||
assert not (local_func_names & {"build_prompt", "build_prompt_tokens"})
|
||||
After the model_extras migration, t2i / image-editing run through the
|
||||
shared ``text_to_image.py`` / ``image_edit.py`` examples (both call
|
||||
``build_ar_stage_inputs``), and t2t / i2t run through the shared
|
||||
``x_to_text.py`` example (which calls ``build_x_to_text_prompt``). This
|
||||
test pins that both ``model_extras.hunyuan_image3`` seam functions
|
||||
delegate to ``prompt_utils.build_ar_prompt_inputs`` / ``build_ar_stage_inputs``
|
||||
rather than re-implementing tokenization inline.
|
||||
"""
|
||||
seam_path = _repo_root() / "vllm_omni" / "model_extras" / "hunyuan_image3.py"
|
||||
seam_tree = ast.parse(seam_path.read_text(encoding="utf-8"))
|
||||
|
||||
imported_from_prompt_utils: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
local_func_names = {n.name for n in ast.walk(seam_tree) if isinstance(n, ast.FunctionDef)}
|
||||
assert not (local_func_names & {"build_prompt", "build_prompt_tokens", "resolve_stop_token_ids"})
|
||||
|
||||
# build_x_to_text_prompt must route through build_ar_stage_inputs, not
|
||||
# re-derive the AR prefill / stop-token logic itself.
|
||||
build_x_to_text_prompt_node = next(
|
||||
n for n in ast.walk(seam_tree) if isinstance(n, ast.FunctionDef) and n.name == "build_x_to_text_prompt"
|
||||
)
|
||||
called_names = {
|
||||
node.func.id
|
||||
for node in ast.walk(build_x_to_text_prompt_node)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
||||
}
|
||||
assert "build_ar_stage_inputs" in called_names
|
||||
|
||||
# The model_extras seam itself must delegate to the shared prompt_utils.
|
||||
routed_from_prompt_utils: set[str] = set()
|
||||
for node in ast.walk(seam_tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module and node.module.endswith("hunyuan_image3.prompt_utils"):
|
||||
imported_from_prompt_utils.update(alias.name for alias in node.names)
|
||||
expected_imports = {"build_prompt_tokens", "resolve_stop_token_ids", "resolve_sys_type"}
|
||||
assert expected_imports <= imported_from_prompt_utils
|
||||
routed_from_prompt_utils.update(alias.name for alias in node.names)
|
||||
assert "build_ar_prompt_inputs" in routed_from_prompt_utils
|
||||
|
||||
|
||||
_HUNYUAN_MODEL_ID = "tencent/HunyuanImage-3.0-Instruct"
|
||||
@@ -331,3 +356,87 @@ def test_segment_tokenize_diverges_from_full_string_encode():
|
||||
full_ids = tok.encode(build_prompt(user_prompt, task="i2t", bot_task=None), add_special_tokens=False)
|
||||
assert seg_ids != full_ids
|
||||
assert len(seg_ids) >= len(full_ids)
|
||||
|
||||
|
||||
def test_build_ar_prompt_inputs_tokenizer_path_matches_build_prompt_tokens():
|
||||
"""With a tokenizer, the helper delegates to build_prompt_tokens (parity)."""
|
||||
tok = FakeTokenizer()
|
||||
ar = build_ar_prompt_inputs("a red panda", task="t2i", bot_task="think", tokenizer=tok)
|
||||
expected = build_prompt_tokens("a red panda", FakeTokenizer(), task="t2i", bot_task="think")
|
||||
assert ar.prompt is None
|
||||
assert ar.prompt_token_ids == expected.token_ids
|
||||
assert ar.system_prompt_type == expected.system_prompt_type
|
||||
# think + no explicit size -> AR predicts ratio, so stop range is non-trivial.
|
||||
assert len(ar.stop_token_ids) > 1
|
||||
|
||||
|
||||
def test_build_ar_prompt_inputs_string_fallback_without_tokenizer():
|
||||
"""Without a tokenizer, the helper returns the build_prompt string form."""
|
||||
ar = build_ar_prompt_inputs("a red panda", task="t2i", bot_task="recaption")
|
||||
assert ar.prompt_token_ids is None
|
||||
assert ar.prompt == build_prompt("a red panda", task="t2i", bot_task="recaption")
|
||||
assert ar.system_prompt_type
|
||||
assert ar.stop_token_ids
|
||||
|
||||
|
||||
def test_build_ar_prompt_inputs_explicit_size_stops_at_terminator():
|
||||
"""Explicit image_size makes 'recaption' stop at </recaption> only."""
|
||||
ar = build_ar_prompt_inputs("x", task="t2i", bot_task="recaption", image_size="1024x1024")
|
||||
assert ar.stop_token_ids == [HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["</recaption>"]]
|
||||
|
||||
|
||||
def test_build_ar_prompt_inputs_comprehension_stops_on_answer():
|
||||
"""Text-output tasks (t2t / i2t) stop on <answer>."""
|
||||
ar = build_ar_prompt_inputs("hi", task="t2t")
|
||||
assert ar.stop_token_ids == [HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<answer>"]]
|
||||
|
||||
|
||||
class _MatchingTokenizer:
|
||||
"""Reports exactly HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS -- a "real" tokenizer
|
||||
that hasn't drifted from the hardcoded table."""
|
||||
|
||||
def convert_tokens_to_ids(self, tok: str) -> int:
|
||||
return HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS[tok]
|
||||
|
||||
|
||||
class _DriftedTokenizer:
|
||||
"""Reports a different id for one token -- simulates a model/tokenizer
|
||||
revision bump shifting special-token ids out from under the hardcoded
|
||||
table."""
|
||||
|
||||
def convert_tokens_to_ids(self, tok: str) -> int:
|
||||
if tok == "<think>":
|
||||
return 999999
|
||||
return HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS[tok]
|
||||
|
||||
|
||||
class _BaseCheckpointTokenizer:
|
||||
"""Simulates tencent/HunyuanImage-3.0 (base): reports the same
|
||||
model_class_name as -Instruct, but its vocab doesn't include the
|
||||
Instruct-only <img_ratio_33>-<img_ratio_36> tokens -- convert_tokens_to_ids
|
||||
falls back to unk_token_id for those, like a real HF tokenizer would."""
|
||||
|
||||
unk_token_id = 0
|
||||
_INSTRUCT_ONLY = {"<img_ratio_33>", "<img_ratio_36>"}
|
||||
|
||||
def convert_tokens_to_ids(self, tok: str) -> int:
|
||||
if tok in self._INSTRUCT_ONLY:
|
||||
return self.unk_token_id
|
||||
return HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS[tok]
|
||||
|
||||
|
||||
def test_validate_special_token_ids_passes_for_matching_tokenizer():
|
||||
validate_special_token_ids(_MatchingTokenizer()) # must not raise
|
||||
|
||||
|
||||
def test_validate_special_token_ids_raises_on_drift():
|
||||
with pytest.raises(ValueError, match="<think>.*expected 128023.*got 999999"):
|
||||
validate_special_token_ids(_DriftedTokenizer())
|
||||
|
||||
|
||||
def test_validate_special_token_ids_tolerates_base_checkpoint_missing_tokens():
|
||||
"""The base checkpoint's tokenizer not recognizing Instruct-only ratio
|
||||
tokens (unk_token_id, not a real drifted id) must not be flagged as a
|
||||
mismatch -- both checkpoints share model_class_name, so this hook fires
|
||||
for either one."""
|
||||
validate_special_token_ids(_BaseCheckpointTokenizer()) # must not raise
|
||||
@@ -0,0 +1,171 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.diffusion.models.lance.lance_transformer import LanceBagel
|
||||
from vllm_omni.diffusion.models.lance.pipeline_lance import LancePipeline
|
||||
from vllm_omni.model_executor.models.utils import normalize_decoded_video_frames
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class FramesWithMetadata(list[Image.Image]):
|
||||
def __init__(self, frames: list[Image.Image], **metadata: object) -> None:
|
||||
super().__init__(frames)
|
||||
for name, value in metadata.items():
|
||||
setattr(self, name, value)
|
||||
|
||||
|
||||
class StopAfterVideoPreprocessError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def test_normalize_decoded_video_frames_preserves_rgb_pixels_and_fps():
|
||||
red = Image.new("RGB", (3, 2), color=(255, 0, 0))
|
||||
rgba = Image.new("RGBA", (3, 2), color=(0, 255, 0, 64))
|
||||
rgba_bytes = rgba.tobytes()
|
||||
|
||||
video, fps = normalize_decoded_video_frames(FramesWithMetadata([red, rgba], fps=8), default_fps=24.0)
|
||||
|
||||
assert video.shape == (2, 2, 3, 3)
|
||||
assert video.dtype == np.uint8
|
||||
assert video.flags.c_contiguous
|
||||
np.testing.assert_array_equal(video[0, 0, 0], [255, 0, 0])
|
||||
np.testing.assert_array_equal(video[1, 0, 0], [0, 255, 0])
|
||||
assert fps == 8.0
|
||||
assert rgba.mode == "RGBA"
|
||||
assert rgba.tobytes() == rgba_bytes
|
||||
|
||||
|
||||
def test_normalize_decoded_video_frames_uses_frame_rate_when_fps_is_missing():
|
||||
frames = FramesWithMetadata([Image.new("RGB", (2, 2))] * 2, frame_rate=12)
|
||||
|
||||
_, fps = normalize_decoded_video_frames(frames, default_fps=24.0)
|
||||
|
||||
assert fps == 12.0
|
||||
|
||||
|
||||
def test_normalize_decoded_video_frames_prefers_valid_fps_over_frame_rate():
|
||||
frames = FramesWithMetadata([Image.new("RGB", (2, 2))] * 2, fps=8, frame_rate=12)
|
||||
|
||||
_, fps = normalize_decoded_video_frames(frames, default_fps=24.0)
|
||||
|
||||
assert fps == 8.0
|
||||
|
||||
|
||||
def test_normalize_decoded_video_frames_uses_default_fps_for_plain_list_and_tuple():
|
||||
frames = [Image.new("RGB", (2, 2)), Image.new("RGB", (2, 2))]
|
||||
|
||||
assert normalize_decoded_video_frames(frames, default_fps=24.0)[1] == 24.0
|
||||
assert normalize_decoded_video_frames(tuple(frames), default_fps=24.0)[1] == 24.0
|
||||
|
||||
|
||||
def test_normalize_decoded_video_frames_accepts_numeric_string_fps():
|
||||
frames = FramesWithMetadata([Image.new("RGB", (2, 2))] * 2, fps="12.5")
|
||||
|
||||
_, frame_rate = normalize_decoded_video_frames(frames, default_fps=24.0)
|
||||
|
||||
assert frame_rate == 12.5
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("fps", "frame_rate", "expected"),
|
||||
[
|
||||
(float("nan"), 12, 12.0),
|
||||
(0, 15, 15.0),
|
||||
(-1, 8, 8.0),
|
||||
(float("inf"), 10, 10.0),
|
||||
("invalid", "6.5", 6.5),
|
||||
],
|
||||
)
|
||||
def test_normalize_decoded_video_frames_uses_valid_frame_rate_after_invalid_fps(fps, frame_rate, expected):
|
||||
frames = FramesWithMetadata([Image.new("RGB", (2, 2))] * 2, fps=fps, frame_rate=frame_rate)
|
||||
|
||||
_, frame_rate = normalize_decoded_video_frames(frames, default_fps=24.0)
|
||||
|
||||
assert frame_rate == expected
|
||||
|
||||
|
||||
def test_normalize_decoded_video_frames_uses_default_when_both_metadata_values_are_invalid():
|
||||
frames = FramesWithMetadata([Image.new("RGB", (2, 2))] * 2, fps="invalid", frame_rate=0)
|
||||
|
||||
_, frame_rate = normalize_decoded_video_frames(frames, default_fps=24.0)
|
||||
|
||||
assert frame_rate == 24.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("frames", "match"),
|
||||
[
|
||||
([], "empty decoded video frame sequence"),
|
||||
([Image.new("RGB", (2, 2)), object()], "must be a PIL.Image.Image"),
|
||||
(
|
||||
[Image.new("RGB", (2, 2)), Image.new("RGB", (3, 2))],
|
||||
"must have identical dimensions",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_normalize_decoded_video_frames_rejects_invalid_sequences(frames, match):
|
||||
with pytest.raises(ValueError, match=match):
|
||||
normalize_decoded_video_frames(frames, default_fps=24.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("frames", "match"),
|
||||
[
|
||||
([Image.new("RGB", (0, 16))], r"index 0.*0x16"),
|
||||
([Image.new("RGB", (16, 0))], r"index 0.*16x0"),
|
||||
([Image.new("RGB", (0, 0))], r"index 0.*0x0"),
|
||||
(
|
||||
[Image.new("RGB", (16, 16)), Image.new("RGB", (0, 16))],
|
||||
r"index 1.*0x16",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_normalize_decoded_video_frames_rejects_zero_dimensions(frames, match):
|
||||
with pytest.raises(ValueError, match=match):
|
||||
normalize_decoded_video_frames(frames, default_fps=24.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("video_input", "expected_fps"),
|
||||
[
|
||||
(FramesWithMetadata([Image.new("RGB", (3, 2))] * 2, fps=8), 8.0),
|
||||
(tuple([Image.new("RGBA", (3, 2))] * 2), 12.0),
|
||||
],
|
||||
)
|
||||
def test_lance_video_edit_normalizes_decoded_frame_sequences(monkeypatch, video_input, expected_fps):
|
||||
captured = {}
|
||||
|
||||
def capture_preprocessed_input(video, fps):
|
||||
captured["video"] = video
|
||||
captured["fps"] = fps
|
||||
raise StopAfterVideoPreprocessError
|
||||
|
||||
monkeypatch.setattr(LanceBagel, "_lance_video_preprocess", staticmethod(capture_preprocessed_input))
|
||||
request = SimpleNamespace(
|
||||
prompts=[
|
||||
{
|
||||
"modalities": ["video"],
|
||||
"user_text": "edit the video",
|
||||
"multi_modal_data": {"video": video_input},
|
||||
"extra_args": {"origin_fps": 12.0},
|
||||
}
|
||||
],
|
||||
sampling_params=SimpleNamespace(extra_args={}, num_inference_steps=1),
|
||||
)
|
||||
|
||||
with pytest.raises(StopAfterVideoPreprocessError):
|
||||
object.__new__(LancePipeline)._forward_video_edit(request)
|
||||
|
||||
video = captured["video"]
|
||||
assert isinstance(video, np.ndarray)
|
||||
assert video.shape == (2, 2, 3, 3)
|
||||
assert video.dtype == np.uint8
|
||||
assert video.flags.c_contiguous
|
||||
assert captured["fps"] == expected_fps
|
||||
@@ -12,6 +12,7 @@ from vllm_omni.diffusion.models.lingbot_world.actions import (
|
||||
LINGBOT_CAMERA_ACTION_SCHEMA,
|
||||
LINGBOT_CAMERA_TRAJECTORY_SCHEMA,
|
||||
LingBotCameraControlReducer,
|
||||
camera_trajectory_from_absolute_pose,
|
||||
integrate_lingbot_camera_actions,
|
||||
parse_lingbot_camera_action_frames,
|
||||
parse_lingbot_camera_action_script,
|
||||
@@ -212,6 +213,11 @@ def test_action_integrator_matches_lingbot_motion_and_calibration() -> None:
|
||||
torch.tensor([500.0, 500.0, 208.0, 120.0]),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="resolution must be positive"):
|
||||
camera_trajectory_from_absolute_pose(trajectory.poses[:1], width=0, height=480)
|
||||
with pytest.raises(ValueError, match=r"\[frames, 4, 4\]"):
|
||||
camera_trajectory_from_absolute_pose(torch.zeros(4, 4), width=832, height=480)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("data", "message"),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,7 +7,7 @@ import importlib.util
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
@@ -33,6 +33,10 @@ _STUBBED_MODULE_NAMES = (
|
||||
"vllm_omni.diffusion",
|
||||
"vllm_omni.diffusion.attention",
|
||||
"vllm_omni.diffusion.attention.layer",
|
||||
"vllm_omni.diffusion.distributed",
|
||||
"vllm_omni.diffusion.distributed.comm",
|
||||
"vllm_omni.diffusion.distributed.parallel_state",
|
||||
"vllm_omni.diffusion.distributed.sp_plan",
|
||||
"vllm_omni.diffusion.layers",
|
||||
"vllm_omni.diffusion.layers.norm",
|
||||
"vllm_omni.diffusion.layers.rope",
|
||||
@@ -58,6 +62,58 @@ def _install_vllm_stubs() -> None:
|
||||
distributed.get_tensor_model_parallel_world_size = lambda: 1
|
||||
distributed.tensor_model_parallel_all_reduce = lambda value: value
|
||||
|
||||
class _SeqAllToAll4D:
|
||||
@staticmethod
|
||||
def apply(group, value, scatter_idx, gather_idx, use_sync=False):
|
||||
del group, scatter_idx, gather_idx, use_sync
|
||||
return value
|
||||
|
||||
setattr(
|
||||
sys.modules["vllm_omni.diffusion.distributed.comm"],
|
||||
"SeqAllToAll4D",
|
||||
_SeqAllToAll4D,
|
||||
)
|
||||
|
||||
def _all_to_all_5D(value, scatter_idx=3, gather_idx=1, group=None, use_sync=False):
|
||||
# Single-rank stub: the fused q/k/v exchange is the identity when N == 1.
|
||||
del scatter_idx, gather_idx, group, use_sync
|
||||
return value
|
||||
|
||||
setattr(
|
||||
sys.modules["vllm_omni.diffusion.distributed.comm"],
|
||||
"all_to_all_5D",
|
||||
_all_to_all_5D,
|
||||
)
|
||||
|
||||
def get_sp_group():
|
||||
return SimpleNamespace(
|
||||
ulysses_world_size=1,
|
||||
ulysses_rank=0,
|
||||
ulysses_group=None,
|
||||
)
|
||||
|
||||
setattr(
|
||||
sys.modules["vllm_omni.diffusion.distributed.parallel_state"],
|
||||
"get_sp_group",
|
||||
get_sp_group,
|
||||
)
|
||||
|
||||
class _SequenceParallelInput:
|
||||
def __init__(self, split_dim, expected_dims=None, split_output=False, auto_pad=False):
|
||||
self.split_dim = split_dim
|
||||
self.expected_dims = expected_dims
|
||||
self.split_output = split_output
|
||||
self.auto_pad = auto_pad
|
||||
|
||||
class _SequenceParallelOutput:
|
||||
def __init__(self, gather_dim, expected_dims=None):
|
||||
self.gather_dim = gather_dim
|
||||
self.expected_dims = expected_dims
|
||||
|
||||
sp_plan = sys.modules["vllm_omni.diffusion.distributed.sp_plan"]
|
||||
setattr(sp_plan, "SequenceParallelInput", _SequenceParallelInput)
|
||||
setattr(sp_plan, "SequenceParallelOutput", _SequenceParallelOutput)
|
||||
|
||||
def set_weight_attrs(weight: torch.Tensor, attrs: dict) -> None:
|
||||
for name, value in attrs.items():
|
||||
setattr(weight, name, value)
|
||||
@@ -541,3 +597,51 @@ def test_tp_rmsnorm_weight_loader_selects_rank_shard(monkeypatch: pytest.MonkeyP
|
||||
norm.weight.weight_loader(norm.weight, torch.tensor([10.0, 20.0, 30.0, 40.0]))
|
||||
|
||||
torch.testing.assert_close(norm.weight, torch.tensor([30.0, 40.0]))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("attention_class", ["LingBotSelfAttention"])
|
||||
def test_ulysses_rejects_non_divisible_head_count(monkeypatch, attention_class):
|
||||
module = _load_module()
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"get_sp_group",
|
||||
lambda: SimpleNamespace(
|
||||
ulysses_world_size=3,
|
||||
ulysses_rank=0,
|
||||
ulysses_group=None,
|
||||
),
|
||||
)
|
||||
with pytest.raises(ValueError, match="heads must be divisible"):
|
||||
getattr(module, attention_class)(dim=8, num_heads=4)
|
||||
|
||||
|
||||
def test_supplied_camera_modulation_matches_computing_it_inline() -> None:
|
||||
"""Passing a prebuilt modulation must be arithmetically identical to letting the block build its own."""
|
||||
module = _load_module()
|
||||
torch.manual_seed(0)
|
||||
block = module.LingBotAttentionBlock(dim=4, num_heads=2, prefix="blocks.0")
|
||||
camera = torch.randn(1, 3, 4)
|
||||
|
||||
scale, shift = block.camera_modulation(camera)
|
||||
inline_scale, inline_shift = block.camera_modulation(camera)
|
||||
|
||||
torch.testing.assert_close(scale, inline_scale, rtol=0, atol=0)
|
||||
torch.testing.assert_close(shift, inline_shift, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_the_camera_modulation_reads_only_the_camera_tokens() -> None:
|
||||
"""The reuse window is only sound because the modulation depends on nothing else that varies per forward."""
|
||||
module = _load_module()
|
||||
torch.manual_seed(0)
|
||||
block = module.LingBotAttentionBlock(dim=4, num_heads=2, prefix="blocks.0")
|
||||
camera = torch.randn(1, 3, 4)
|
||||
|
||||
first = block.camera_modulation(camera)
|
||||
# Same camera, different everything else a forward would carry: the modulation cannot move.
|
||||
second = block.camera_modulation(camera.clone())
|
||||
|
||||
torch.testing.assert_close(first[0], second[0], rtol=0, atol=0)
|
||||
torch.testing.assert_close(first[1], second[1], rtol=0, atol=0)
|
||||
# A different camera must move it, or the cache key would be hiding a real dependency.
|
||||
other = block.camera_modulation(camera + 1)
|
||||
assert not torch.equal(first[0], other[0])
|
||||
@@ -8,6 +8,7 @@ import math
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
@@ -20,7 +21,10 @@ _CAMERA_MODULE = importlib.util.module_from_spec(_SPEC)
|
||||
sys.modules[_SPEC.name] = _CAMERA_MODULE
|
||||
_SPEC.loader.exec_module(_CAMERA_MODULE)
|
||||
|
||||
CameraTrajectory = _CAMERA_MODULE.CameraTrajectory
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.diffusion.models.lingbot_world.camera import CameraTrajectory
|
||||
else:
|
||||
CameraTrajectory = _CAMERA_MODULE.CameraTrajectory
|
||||
build_plucker_embedding = _CAMERA_MODULE.build_plucker_embedding
|
||||
interpolate_camera_trajectory = _CAMERA_MODULE.interpolate_camera_trajectory
|
||||
load_camera_trajectory = _CAMERA_MODULE.load_camera_trajectory
|
||||
@@ -138,21 +142,20 @@ def test_load_camera_trajectory_rejects_object_dtype_before_materializing(tmp_pa
|
||||
load_camera_trajectory(_trusted(tmp_path))
|
||||
|
||||
|
||||
def test_load_camera_trajectory_accepts_official_length_and_materializes_request_prefix(tmp_path: Path) -> None:
|
||||
_write_trajectory(
|
||||
tmp_path,
|
||||
_identity_poses(269),
|
||||
np.arange(269 * 4, dtype=np.float32).reshape(269, 4),
|
||||
)
|
||||
@pytest.mark.parametrize("num_frames", [129, 269, 4096])
|
||||
def test_load_camera_trajectory_preserves_all_bounded_source_frames(tmp_path: Path, num_frames: int) -> None:
|
||||
poses = _identity_poses(num_frames)
|
||||
poses[:, 0, 3] = np.arange(num_frames)
|
||||
intrinsics = np.arange(num_frames * 4, dtype=np.float32).reshape(num_frames, 4)
|
||||
_write_trajectory(tmp_path, poses, intrinsics)
|
||||
|
||||
trajectory = load_camera_trajectory(_trusted(tmp_path))
|
||||
|
||||
assert trajectory.poses.shape == (117, 4, 4)
|
||||
assert trajectory.intrinsics.shape == (117, 4)
|
||||
np.testing.assert_array_equal(
|
||||
trajectory.intrinsics.numpy(),
|
||||
np.arange(269 * 4, dtype=np.float32).reshape(269, 4)[:117],
|
||||
)
|
||||
assert trajectory.poses.shape == (num_frames, 4, 4)
|
||||
assert trajectory.intrinsics.shape == (num_frames, 4)
|
||||
assert trajectory.poses.dtype == trajectory.intrinsics.dtype == torch.float32
|
||||
np.testing.assert_array_equal(trajectory.poses.numpy(), poses.astype(np.float32))
|
||||
np.testing.assert_array_equal(trajectory.intrinsics.numpy(), intrinsics)
|
||||
|
||||
|
||||
def test_load_camera_trajectory_rejects_unbounded_source_frame_count(tmp_path: Path) -> None:
|
||||
@@ -419,7 +422,17 @@ def test_build_plucker_embedding_uses_framewise_deltas_for_rotations_and_transla
|
||||
torch.testing.assert_close(result[2, 3:, 0, 1], expected_direction)
|
||||
|
||||
|
||||
def test_build_plucker_embedding_normalizes_framewise_translation_by_max_norm() -> None:
|
||||
@pytest.mark.parametrize(
|
||||
("translation_scale", "expected_origins"),
|
||||
[
|
||||
(None, [[0.0, 0.0, 0.0], [0.3, 0.4, 0.0], [0.6, 0.8, 0.0]]),
|
||||
(0.05, [[0.0, 0.0, 0.0], [60.0, 80.0, 0.0], [120.0, 160.0, 0.0]]),
|
||||
],
|
||||
)
|
||||
def test_build_plucker_embedding_normalizes_framewise_translation_by_max_norm(
|
||||
translation_scale: float | None,
|
||||
expected_origins: list[list[float]],
|
||||
) -> None:
|
||||
poses = torch.eye(4).repeat(3, 1, 1)
|
||||
poses[:, :3, 3] = torch.tensor(
|
||||
[
|
||||
@@ -437,10 +450,37 @@ def test_build_plucker_embedding_normalizes_framewise_translation_by_max_norm()
|
||||
target_width=1,
|
||||
device=torch.device("cpu"),
|
||||
dtype=torch.float32,
|
||||
translation_scale=translation_scale,
|
||||
)
|
||||
|
||||
expected_origins = torch.tensor([[0.0, 0.0, 0.0], [0.3, 0.4, 0.0], [0.6, 0.8, 0.0]])
|
||||
torch.testing.assert_close(result[:, :3, 0, 0], expected_origins)
|
||||
torch.testing.assert_close(result[:, :3, 0, 0], torch.tensor(expected_origins))
|
||||
|
||||
from vllm_omni.diffusion.models.lingbot_world.actions import LINGBOT_CONTROLLER_TRANSLATION_UNIT
|
||||
|
||||
if translation_scale != LINGBOT_CONTROLLER_TRANSLATION_UNIT:
|
||||
return
|
||||
# Fixed controller unit keeps relative speed; max-norm would collapse both to 1.
|
||||
slow = torch.eye(4).repeat(2, 1, 1)
|
||||
slow[1, 2, 3] = 0.005
|
||||
fast = torch.eye(4).repeat(2, 1, 1)
|
||||
fast[1, 2, 3] = 0.5
|
||||
kwargs = dict(
|
||||
height=1,
|
||||
width=1,
|
||||
target_height=1,
|
||||
target_width=1,
|
||||
device=torch.device("cpu"),
|
||||
dtype=torch.float32,
|
||||
translation_scale=LINGBOT_CONTROLLER_TRANSLATION_UNIT,
|
||||
)
|
||||
slow_origin = build_plucker_embedding(_trajectory(slow), **kwargs)[1, :3, 0, 0]
|
||||
fast_origin = build_plucker_embedding(_trajectory(fast), **kwargs)[1, :3, 0, 0]
|
||||
torch.testing.assert_close(slow_origin, torch.tensor([0.0, 0.0, 0.1]))
|
||||
torch.testing.assert_close(fast_origin, torch.tensor([0.0, 0.0, 10.0]))
|
||||
wasd = torch.eye(4).repeat(2, 1, 1)
|
||||
wasd[1, 2, 3] = LINGBOT_CONTROLLER_TRANSLATION_UNIT
|
||||
wasd_origin = build_plucker_embedding(_trajectory(wasd), **kwargs)[1, :3, 0, 0]
|
||||
torch.testing.assert_close(wasd_origin, torch.tensor([0.0, 0.0, 1.0]))
|
||||
|
||||
|
||||
def test_build_plucker_embedding_interpolates_raw_poses_before_framewise_conversion() -> None:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,14 +7,17 @@ import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import math
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.diffusion.models.lingbot_world import test_lingbot_world_attention as attention_tests
|
||||
from tests.helpers.mark import hardware_test
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
|
||||
|
||||
_FIXTURE_PATH = Path(__file__).with_name("fixtures") / "lingbot_world_weight_index_names.json.fixture"
|
||||
_SHAPE_FIXTURE_PATH = Path(__file__).with_name("fixtures") / "lingbot_world_official_shapes.json.fixture"
|
||||
@@ -32,11 +35,13 @@ def _tiny_model(
|
||||
num_layers: int = 2,
|
||||
num_frames_per_block: int = 1,
|
||||
sliding_window_num_frames: int = 3,
|
||||
attention_head_dim: int = 2,
|
||||
rope_max_seq_len: int = 16,
|
||||
):
|
||||
return module.CausalLingBotWorldTransformer3DModel(
|
||||
patch_size=(1, 2, 2),
|
||||
num_attention_heads=2,
|
||||
attention_head_dim=2,
|
||||
attention_head_dim=attention_head_dim,
|
||||
in_channels=36,
|
||||
out_channels=2,
|
||||
text_dim=6,
|
||||
@@ -45,7 +50,7 @@ def _tiny_model(
|
||||
num_layers=num_layers,
|
||||
cross_attn_norm=True,
|
||||
eps=1e-6,
|
||||
rope_max_seq_len=16,
|
||||
rope_max_seq_len=rope_max_seq_len,
|
||||
sink_size=1,
|
||||
num_frames_per_block=num_frames_per_block,
|
||||
sliding_window_num_frames=sliding_window_num_frames,
|
||||
@@ -117,7 +122,9 @@ def _assert_self_cache_unchanged(cache, snapshot) -> None:
|
||||
attention_tests._assert_cache_unchanged(layer_cache, layer_snapshot)
|
||||
|
||||
|
||||
def test_tiny_transformer_runs_four_chunks_with_explicit_cache_commit_and_camera_path() -> None:
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("num_chunks", [4, 18])
|
||||
def test_tiny_transformer_runs_contiguous_chunks_with_explicit_cache_commit_and_camera_path(num_chunks) -> None:
|
||||
torch.manual_seed(7)
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module).eval()
|
||||
@@ -131,7 +138,7 @@ def test_tiny_transformer_runs_four_chunks_with_explicit_cache_commit_and_camera
|
||||
assert model.patch_embedding.in_channels == 36
|
||||
assert model.patch_embedding_wancamctrl.in_features == 6 * 8 * 8 * 1 * 2 * 2
|
||||
|
||||
for start_frame in range(4):
|
||||
for start_frame in range(num_chunks):
|
||||
hidden_states = torch.randn(1, 36, 1, 4, 4)
|
||||
timestep = torch.tensor([float(start_frame + 1)])
|
||||
snapshot = _self_cache_snapshot(cache)
|
||||
@@ -165,7 +172,7 @@ def test_tiny_transformer_runs_four_chunks_with_explicit_cache_commit_and_camera
|
||||
expected_token_end = (start_frame + 1) * 2 * 2
|
||||
assert all(layer_cache.absolute_end == expected_token_end for layer_cache in cache.self_attention)
|
||||
|
||||
assert len(outputs) == 4
|
||||
assert len(outputs) == num_chunks
|
||||
assert all(len(outputs) == 1 for outputs in cross_key_outputs)
|
||||
assert len(text_projection_outputs) == 1
|
||||
|
||||
@@ -191,6 +198,7 @@ def test_tiny_transformer_runs_four_chunks_with_explicit_cache_commit_and_camera
|
||||
assert not torch.equal(camera_output, alternate_output)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_partial_cross_attention_cache_projects_text_only_for_missing_layers() -> None:
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_layers=2).eval()
|
||||
@@ -229,6 +237,7 @@ def test_partial_cross_attention_cache_projects_text_only_for_missing_layers() -
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frames", [1, 6], ids=("partial", "multiple_blocks"))
|
||||
@pytest.mark.cpu
|
||||
def test_forward_rejects_chunks_that_do_not_equal_configured_block_size(frames: int) -> None:
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(
|
||||
@@ -257,6 +266,7 @@ def test_forward_rejects_chunks_that_do_not_equal_configured_block_size(frames:
|
||||
assert cache.cross_attention == [None]
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_forward_accepts_exactly_one_configured_frame_block() -> None:
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(
|
||||
@@ -279,6 +289,7 @@ def test_forward_accepts_exactly_one_configured_frame_block() -> None:
|
||||
assert output.shape == (1, 2, 3, 4, 4)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_transformer_allocates_request_cache_from_its_configured_geometry() -> None:
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(
|
||||
@@ -303,6 +314,7 @@ def test_transformer_allocates_request_cache_from_its_configured_geometry() -> N
|
||||
assert all(layer.value.shape == (2, 6 * 4 * 6, 2, 2) for layer in cache.self_attention)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_video_patch_embedding_uses_temporal_height_width_token_order() -> None:
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_layers=1, num_frames_per_block=2)
|
||||
@@ -323,6 +335,7 @@ def test_video_patch_embedding_uses_temporal_height_width_token_order() -> None:
|
||||
torch.testing.assert_close(tokens[0, :, 0], torch.tensor([1.0, 3.0, 9.0, 11.0]))
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_unpatchify_restores_two_frame_channel_and_spatial_order() -> None:
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_layers=1, num_frames_per_block=2)
|
||||
@@ -363,6 +376,7 @@ def test_unpatchify_restores_two_frame_channel_and_spatial_order() -> None:
|
||||
torch.testing.assert_close(output, expected)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_head_modulation_broadcasts_distinct_condition_per_frame() -> None:
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_layers=1, num_frames_per_block=2)
|
||||
@@ -381,6 +395,7 @@ def test_head_modulation_broadcasts_distinct_condition_per_frame() -> None:
|
||||
torch.testing.assert_close(output, expected)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_constructor_defaults_match_official_checkpoint_config() -> None:
|
||||
module = attention_tests._load_module()
|
||||
parameters = inspect.signature(module.CausalLingBotWorldTransformer3DModel.__init__).parameters
|
||||
@@ -406,6 +421,7 @@ def test_constructor_defaults_match_official_checkpoint_config() -> None:
|
||||
assert {name: parameters[name].default for name in expected} == expected
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_transformer_exposes_parameter_dtype_for_pipeline_runtime() -> None:
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module)
|
||||
@@ -413,12 +429,14 @@ def test_transformer_exposes_parameter_dtype_for_pipeline_runtime() -> None:
|
||||
assert model.dtype == next(model.parameters()).dtype
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_transformer_declares_regional_compile_block() -> None:
|
||||
module = attention_tests._load_module()
|
||||
|
||||
assert module.CausalLingBotWorldTransformer3DModel._repeated_blocks == ["LingBotAttentionBlock"]
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_lingbot_rms_norm_uses_global_tp_square_mean(monkeypatch) -> None:
|
||||
module = attention_tests._load_module()
|
||||
reduced_values: list[torch.Tensor] = []
|
||||
@@ -440,6 +458,7 @@ def test_lingbot_rms_norm_uses_global_tp_square_mean(monkeypatch) -> None:
|
||||
torch.testing.assert_close(reduced_values[0], torch.tensor([[25.0]]))
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_attention_rejects_heads_not_divisible_by_tp_size(monkeypatch) -> None:
|
||||
module = attention_tests._load_module()
|
||||
monkeypatch.setattr(module, "get_tensor_model_parallel_world_size", lambda: 3)
|
||||
@@ -448,6 +467,7 @@ def test_attention_rejects_heads_not_divisible_by_tp_size(monkeypatch) -> None:
|
||||
module.LingBotSelfAttention(dim=4, num_heads=2)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_constructor_rejects_unsupported_qk_norm_with_config_error() -> None:
|
||||
module = attention_tests._load_module()
|
||||
|
||||
@@ -456,6 +476,7 @@ def test_constructor_rejects_unsupported_qk_norm_with_config_error() -> None:
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["image_dim", "added_kv_proj_dim", "pos_embed_seq_len"])
|
||||
@pytest.mark.cpu
|
||||
def test_constructor_rejects_non_null_image_embedding_fields(field: str) -> None:
|
||||
module = attention_tests._load_module()
|
||||
|
||||
@@ -463,6 +484,7 @@ def test_constructor_rejects_non_null_image_embedding_fields(field: str) -> None
|
||||
module.CausalLingBotWorldTransformer3DModel(**{field: 4})
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_constructor_rejects_unsupported_quantization_with_runtime_error() -> None:
|
||||
module = attention_tests._load_module()
|
||||
|
||||
@@ -470,6 +492,7 @@ def test_constructor_rejects_unsupported_quantization_with_runtime_error() -> No
|
||||
module.CausalLingBotWorldTransformer3DModel(quant_config=object())
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_from_config_accepts_diffusers_metadata_and_normalizes_patch_size() -> None:
|
||||
module = attention_tests._load_module()
|
||||
|
||||
@@ -522,6 +545,7 @@ def test_from_config_accepts_diffusers_metadata_and_normalizes_patch_size() -> N
|
||||
("sink_size", 0),
|
||||
],
|
||||
)
|
||||
@pytest.mark.cpu
|
||||
def test_from_config_rejects_checkpoint_topology_drift(field: str, value: object) -> None:
|
||||
module = attention_tests._load_module()
|
||||
config = {
|
||||
@@ -554,6 +578,7 @@ def test_from_config_rejects_checkpoint_topology_drift(field: str, value: object
|
||||
module.CausalLingBotWorldTransformer3DModel.from_config(config)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_from_config_ignores_non_semantic_checkpoint_metadata() -> None:
|
||||
module = attention_tests._load_module()
|
||||
config = {
|
||||
@@ -569,6 +594,7 @@ def test_from_config_ignores_non_semantic_checkpoint_metadata() -> None:
|
||||
assert model.config.num_layers == 40
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_load_weights_uses_parameter_loaders_and_rejects_unknown_model_keys() -> None:
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_layers=1)
|
||||
@@ -606,6 +632,7 @@ def test_load_weights_uses_parameter_loaders_and_rejects_unknown_model_keys() ->
|
||||
model.load_weights([("unexpected_model.weight", torch.ones(1))])
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_load_weights_consumes_checkpoint_iterator_incrementally() -> None:
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_layers=1)
|
||||
@@ -619,6 +646,7 @@ def test_load_weights_consumes_checkpoint_iterator_incrementally() -> None:
|
||||
assert model.load_weights(weights()) == {first_name}
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_real_auto_weights_loader_covers_fused_model_parameter_namespace() -> None:
|
||||
utils = pytest.importorskip("vllm.model_executor.models.utils")
|
||||
module = attention_tests._load_module()
|
||||
@@ -638,6 +666,7 @@ def test_real_auto_weights_loader_covers_fused_model_parameter_namespace() -> No
|
||||
assert set(dict(pipeline.named_parameters())) <= loaded
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_checkpoint_weight_index_fixture_matches_model_namespaces() -> None:
|
||||
module = attention_tests._load_module()
|
||||
fixture = json.loads(_FIXTURE_PATH.read_text())
|
||||
@@ -667,6 +696,7 @@ def test_checkpoint_weight_index_fixture_matches_model_namespaces() -> None:
|
||||
assert all(len(shard["parameter_names_sha256"]) == 64 for shard in fixture["shards"].values())
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_official_default_shapes_match_public_safetensors_header_fixture() -> None:
|
||||
module = attention_tests._load_module()
|
||||
fixture = json.loads(_SHAPE_FIXTURE_PATH.read_text())
|
||||
@@ -683,3 +713,377 @@ def test_official_default_shapes_match_public_safetensors_header_fixture() -> No
|
||||
shape, dtype = parameter_specs[name]
|
||||
assert tuple(shape) == tuple(expected_shape), name
|
||||
assert dtype == expected_dtype
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_timestep_expansion_only_runs_with_ulysses(monkeypatch):
|
||||
dtype = torch.bfloat16
|
||||
module = attention_tests._load_module()
|
||||
hidden = torch.randn(2, 12, 4).to(dtype)
|
||||
camera = torch.randn_like(hidden)
|
||||
table = torch.randn(2, 3, 6, 4).to(dtype)
|
||||
rotary = (torch.randn(12, 2), torch.randn(12, 2))
|
||||
prepare = module._LingBotSPPrepare()
|
||||
assert prepare(hidden, camera, table, rotary)[2] is table
|
||||
monkeypatch.setattr(module, "get_sp_group", lambda: SimpleNamespace(ulysses_world_size=2))
|
||||
expanded = prepare(hidden, camera, table, rotary)[2]
|
||||
assert expanded.shape == (2, 12, 6, 4)
|
||||
torch.testing.assert_close(expanded, table.repeat_interleave(4, dim=1), rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unsharded", [None, 3], ids=["missing-hook", "unsharded-rope"])
|
||||
@pytest.mark.cpu
|
||||
def test_missing_or_partial_sp_split_fails_before_attention(monkeypatch, unsharded):
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_frames_per_block=3, sliding_window_num_frames=6)
|
||||
cache = _cache(module, model)
|
||||
for block in model.blocks:
|
||||
block.self_attn.ulysses_world_size = 2
|
||||
monkeypatch.setattr(module, "get_sp_group", lambda: SimpleNamespace(ulysses_world_size=2))
|
||||
original = model.sp_prepare.forward
|
||||
|
||||
def partial_split(*args):
|
||||
values = original(*args)
|
||||
if unsharded is None:
|
||||
return values
|
||||
return tuple(
|
||||
value if i == unsharded else value.chunk(2, dim=1 if i < 3 else 0)[0] for i, value in enumerate(values)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(model.sp_prepare, "forward", partial_split)
|
||||
|
||||
def forbidden(*args, **kwargs):
|
||||
raise AssertionError("attention/collectives must not run before shard validation")
|
||||
|
||||
monkeypatch.setattr(model.blocks[0], "forward", forbidden)
|
||||
with pytest.raises(RuntimeError, match="SP input hooks"):
|
||||
model(
|
||||
torch.randn(1, 36, 3, 4, 4),
|
||||
torch.tensor([1.0]),
|
||||
torch.randn(1, 3, 6),
|
||||
torch.randn(1, 384, 3, 4, 4),
|
||||
cache=cache,
|
||||
start_frame=0,
|
||||
update_cache=False,
|
||||
)
|
||||
|
||||
|
||||
# Real multi-rank regression: synthetic weights, native SP/TP and attention.
|
||||
_HEADS, _HEAD_DIM, _LAYERS = 8, 32, 2
|
||||
_FRAMES, _SIDE, _TOKENS_PER_FRAME = 3, 32, 256
|
||||
|
||||
|
||||
def _model(dtype):
|
||||
from vllm_omni.diffusion.models.lingbot_world.transformer import CausalLingBotWorldTransformer3DModel
|
||||
|
||||
model = CausalLingBotWorldTransformer3DModel(
|
||||
num_attention_heads=_HEADS,
|
||||
attention_head_dim=_HEAD_DIM,
|
||||
num_layers=_LAYERS,
|
||||
in_channels=4,
|
||||
out_channels=4,
|
||||
text_dim=8,
|
||||
freq_dim=16,
|
||||
ffn_dim=512,
|
||||
patch_size=(1, 2, 2),
|
||||
sink_size=1,
|
||||
num_frames_per_block=_FRAMES,
|
||||
sliding_window_num_frames=6,
|
||||
rope_max_seq_len=32,
|
||||
).eval()
|
||||
return model.to(device=torch.device("cuda", torch.accelerator.current_device_index()), dtype=dtype)
|
||||
|
||||
|
||||
def _checkpoint(model):
|
||||
weights = {}
|
||||
generator = torch.Generator().manual_seed(17)
|
||||
for name, param in sorted(model.named_parameters()):
|
||||
data = torch.randn(param.shape, generator=generator) * 0.02
|
||||
if "norm" in name and name.endswith("weight"):
|
||||
data.fill_(1)
|
||||
if name.endswith("modulation") and data.shape[-2] == 6:
|
||||
data[..., 2, :] = 1
|
||||
data[..., 5, :] = 1
|
||||
data = data.to(param.dtype)
|
||||
param.copy_(data)
|
||||
if ".self_attn.qkv." in name:
|
||||
for component, chunk in zip(("q", "k", "v"), data.chunk(3), strict=True):
|
||||
weights[name.replace(".self_attn.qkv.", f".self_attn.{component}.")] = chunk
|
||||
else:
|
||||
weights[name] = data
|
||||
return weights
|
||||
|
||||
|
||||
def _rollout(model, mode, dtype, batch):
|
||||
from vllm_omni.diffusion.models.lingbot_world.transformer import LingBotTransformerCache
|
||||
from vllm_omni.experimental.ar_diffusion.capability import ARDiffusionKVBranchSpec
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache import ARDiffusionKVCache, ARDiffusionKVConfig
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState
|
||||
|
||||
device = next(model.parameters()).device
|
||||
state = None
|
||||
if mode == "paged":
|
||||
kv = ARDiffusionKVCache(
|
||||
ARDiffusionKVConfig(enable=True, chunk_size=_TOKENS_PER_FRAME, window_chunks=5, sink_chunks=1),
|
||||
num_layers=_LAYERS,
|
||||
num_kv_heads=model.blocks[0].self_attn.num_sp_heads,
|
||||
head_size=_HEAD_DIM,
|
||||
dtype=dtype,
|
||||
block_size=_TOKENS_PER_FRAME,
|
||||
max_model_len=4096,
|
||||
available_bytes=1 << 27,
|
||||
kv_branches=(ARDiffusionKVBranchSpec("main", 0),),
|
||||
session_capacity=1,
|
||||
frames_per_block=_FRAMES,
|
||||
max_scratch_tokens_per_branch=_FRAMES * _TOKENS_PER_FRAME,
|
||||
cross_attention_lengths={"text": 5},
|
||||
# Cross-attention keeps every local head on every SP rank, so its pool is wider than the
|
||||
# self-attention share above.
|
||||
cross_attention_kv_heads={"text": model.blocks[0].cross_attn.num_local_heads},
|
||||
device=device,
|
||||
)
|
||||
state = ARDiffusionKVState(kv, "numeric", {"main": kv.begin_request("numeric")}, num_layers=_LAYERS)
|
||||
cache = LingBotTransformerCache(self_attention=[], cross_attention=[None] * _LAYERS)
|
||||
else:
|
||||
cache = model.allocate_cache(
|
||||
batch_size=batch, latent_height=_SIDE, latent_width=_SIDE, device=device, dtype=dtype
|
||||
)
|
||||
generator = torch.Generator().manual_seed(123)
|
||||
text = torch.randn(batch, 5, 8, generator=generator).to(device, dtype)
|
||||
outputs = []
|
||||
try:
|
||||
for start in range(0, 12, _FRAMES):
|
||||
latent = torch.randn(batch, 4, _FRAMES, _SIDE, _SIDE, generator=generator).to(device, dtype)
|
||||
camera = torch.randn(batch, 384, _FRAMES, _SIDE, _SIDE, generator=generator).to(device, dtype)
|
||||
for commit in (False, False, True):
|
||||
if state is not None:
|
||||
cache.self_attention = state.get_kv_caches(
|
||||
"main", seq_len=_FRAMES * _TOKENS_PER_FRAME, commit_current=commit
|
||||
)
|
||||
output = model(
|
||||
latent,
|
||||
torch.tensor([100.0, 400.0, 900.0], device=device).expand(batch, -1),
|
||||
text,
|
||||
camera,
|
||||
cache=cache,
|
||||
start_frame=start,
|
||||
update_cache=commit,
|
||||
)
|
||||
outputs.append(output.float().cpu())
|
||||
if state is not None:
|
||||
if not state.is_cross_attention_populated("main", "text"):
|
||||
state.populate_cross_attention(
|
||||
"main", "text", [(layer.key, layer.value) for layer in cache.cross_attention]
|
||||
)
|
||||
for layer, pool in zip(
|
||||
cache.cross_attention, state.get_cross_attention_kv("main", "text"), strict=True
|
||||
):
|
||||
layer.key, layer.value = pool["k"], pool["v"]
|
||||
state.commit_paged_context("main")
|
||||
cross = [
|
||||
(layer.key.detach().float().cpu(), layer.value.detach().float().cpu()) for layer in cache.cross_attention
|
||||
]
|
||||
return torch.stack(outputs), cross
|
||||
finally:
|
||||
if state is not None:
|
||||
state.close()
|
||||
|
||||
|
||||
def _worker(rank, world_size, sp_size, tp_size, mode, dtype, batch, rendezvous):
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.config.vllm import set_current_vllm_config
|
||||
from vllm.distributed import get_tensor_model_parallel_rank
|
||||
|
||||
from vllm_omni.diffusion.config import set_current_diffusion_config
|
||||
from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec, DiffusionParallelConfig, OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.distributed.parallel_state import (
|
||||
destroy_distributed_env,
|
||||
destroy_model_parallel,
|
||||
init_distributed_environment,
|
||||
initialize_model_parallel,
|
||||
)
|
||||
from vllm_omni.diffusion.forward_context import set_forward_context
|
||||
from vllm_omni.diffusion.registry import _apply_sequence_parallel_if_enabled
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
current_omni_platform.set_device(torch.device("cuda", rank))
|
||||
torch.backends.cuda.matmul.allow_tf32 = False
|
||||
torch.backends.cudnn.allow_tf32 = False
|
||||
torch.distributed.init_process_group(
|
||||
"nccl", init_method=f"file://{rendezvous}", world_size=world_size, rank=rank, timeout=timedelta(seconds=90)
|
||||
)
|
||||
init_distributed_environment(world_size=world_size, rank=rank, local_rank=rank, backend="nccl")
|
||||
try:
|
||||
with torch.inference_mode(), set_current_vllm_config(VllmConfig()):
|
||||
for baseline in (True, False):
|
||||
sp, tp = (1, 1) if baseline else (sp_size, tp_size)
|
||||
initialize_model_parallel(
|
||||
data_parallel_size=world_size // (sp * tp),
|
||||
sequence_parallel_size=sp,
|
||||
ulysses_degree=sp,
|
||||
tensor_parallel_size=tp,
|
||||
)
|
||||
config = OmniDiffusionConfig(
|
||||
model=str(Path(rendezvous).parent),
|
||||
dtype=dtype,
|
||||
enforce_eager=True,
|
||||
parallel_config=DiffusionParallelConfig(
|
||||
data_parallel_size=world_size // (sp * tp),
|
||||
sequence_parallel_size=sp,
|
||||
ulysses_degree=sp,
|
||||
tensor_parallel_size=tp,
|
||||
),
|
||||
diffusion_attention_config=AttentionConfig(default=AttentionSpec(backend="TORCH_SDPA")),
|
||||
)
|
||||
with set_current_diffusion_config(config), set_forward_context(omni_diffusion_config=config):
|
||||
model = _model(dtype)
|
||||
if baseline:
|
||||
weights = _checkpoint(model)
|
||||
else:
|
||||
model.load_weights(iter(weights.items()))
|
||||
_apply_sequence_parallel_if_enabled(SimpleNamespace(transformer=model), config)
|
||||
result, cross = _rollout(model, mode, dtype, batch)
|
||||
if baseline:
|
||||
expected, expected_cross = result, cross
|
||||
else:
|
||||
assert expected.abs().max() > 1e-3
|
||||
bound = 1e-5 if dtype == torch.float32 else 1e-2
|
||||
error = (result - expected).double().norm() / expected.double().norm()
|
||||
assert error <= bound, f"rank={rank}, TP{tp} SP{sp}: relative L2 {error.item():.3g} > {bound}"
|
||||
# Cross-attention K/V is replicated across the Ulysses group, not split over it, so
|
||||
# every SP rank holds its whole TP shard of heads. Only TP slices the cache now.
|
||||
local_heads = _HEADS // tp
|
||||
first = get_tensor_model_parallel_rank() * local_heads
|
||||
for (k, v), (ek, ev) in zip(cross, expected_cross, strict=True):
|
||||
tolerance = 1e-5 if dtype == torch.float32 else 2e-2
|
||||
torch.testing.assert_close(
|
||||
k, ek[:, :, first : first + local_heads], rtol=tolerance, atol=tolerance
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
v, ev[:, :, first : first + local_heads], rtol=tolerance, atol=tolerance
|
||||
)
|
||||
if rank == 0:
|
||||
print(f"{mode} {dtype} TP{tp} SP{sp}: relative L2={error.item():.3g}", flush=True)
|
||||
del model
|
||||
torch.distributed.barrier()
|
||||
destroy_model_parallel()
|
||||
finally:
|
||||
destroy_distributed_env()
|
||||
|
||||
|
||||
def _run(tmp_path: Path, sp, tp, mode, dtype, batch):
|
||||
if torch.accelerator.device_count() < sp * tp:
|
||||
pytest.skip(f"requires {sp * tp} GPUs")
|
||||
torch.multiprocessing.spawn(
|
||||
_worker, args=(sp * tp, sp, tp, mode, dtype, batch, str(tmp_path / "rendezvous")), nprocs=sp * tp
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=2)
|
||||
def test_sp2_direct_matches_sp1_fp32(tmp_path):
|
||||
_run(tmp_path, 2, 1, "direct", torch.float32, 2)
|
||||
|
||||
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=4)
|
||||
def test_sp4_direct_matches_sp1_bf16(tmp_path):
|
||||
_run(tmp_path, 4, 1, "direct", torch.bfloat16, 1)
|
||||
|
||||
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=4)
|
||||
def test_tp2_sp2_paged_matches_sp1_bf16(tmp_path):
|
||||
_run(tmp_path, 2, 2, "paged", torch.bfloat16, 1)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("start_frame", [0, 1021, 1023, 1024, 21600])
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
|
||||
def test_temporal_rope_matches_absolute_positions_without_growing_cache(start_frame, dtype):
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_layers=1, attention_head_dim=128, rope_max_seq_len=1024).to(dtype)
|
||||
buffers = {name: (value.data_ptr(), value.shape) for name, value in model.named_buffers()}
|
||||
cosine, sine = model._rotary_embedding(
|
||||
frames=3, height=2, width=3, start_frame=start_frame, dtype=dtype, device=torch.device("cpu")
|
||||
)
|
||||
# Independent complex-valued reference uses absolute (time, height, width)
|
||||
# coordinates, including blocks crossing the original precomputed table.
|
||||
grids = torch.meshgrid(
|
||||
torch.arange(start_frame, start_frame + 3, dtype=torch.float64),
|
||||
torch.arange(2, dtype=torch.float64),
|
||||
torch.arange(3, dtype=torch.float64),
|
||||
indexing="ij",
|
||||
)
|
||||
reference = []
|
||||
for positions, dim in zip(grids, (44, 42, 42), strict=True):
|
||||
frequencies = 1.0 / 10000 ** (torch.arange(0, dim, 2, dtype=torch.float64) / dim)
|
||||
phase = positions.flatten()[:, None] * frequencies[None, :]
|
||||
reference.append(torch.polar(torch.ones_like(phase), phase))
|
||||
expected = torch.cat(reference, dim=-1)
|
||||
torch.testing.assert_close(cosine, expected.real.float().to(dtype), rtol=0, atol=0)
|
||||
torch.testing.assert_close(sine, expected.imag.float().to(dtype), rtol=0, atol=0)
|
||||
assert {name: (value.data_ptr(), value.shape) for name, value in model.named_buffers()} == buffers
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_temporal_rope_extension_keeps_spatial_limit():
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_layers=1)
|
||||
with pytest.raises(ValueError, match="Spatial RoPE"):
|
||||
model._rotary_embedding(
|
||||
frames=3, height=17, width=1, start_frame=16, dtype=torch.float32, device=torch.device("cpu")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_the_camera_injector_runs_once_per_held_cache_and_gives_the_same_output() -> None:
|
||||
"""Two forwards sharing one caller-held cache: the injector runs once per block, outputs unchanged."""
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_layers=2).eval()
|
||||
hidden_states = torch.randn(1, 36, 1, 4, 4)
|
||||
timestep = torch.tensor([1.0])
|
||||
encoder_hidden_states = torch.randn(1, 3, 6)
|
||||
camera_hidden_states = torch.randn(1, 6 * 8 * 8, 1, 4, 4)
|
||||
|
||||
def run(cache, camera_cache=None):
|
||||
return model(
|
||||
hidden_states,
|
||||
timestep,
|
||||
encoder_hidden_states,
|
||||
camera_hidden_states,
|
||||
cache=cache,
|
||||
start_frame=0,
|
||||
update_cache=False,
|
||||
camera_modulation_cache=camera_cache,
|
||||
)
|
||||
|
||||
# Baseline: no cache, so every forward rebuilds the injector in every block.
|
||||
uncached_first = run(_cache(module, model))
|
||||
uncached_second = run(_cache(module, model))
|
||||
injector_calls = [attention_tests._record_outputs(block.cam_injector_layer1) for block in model.blocks]
|
||||
held = module.CameraModulationCache()
|
||||
cached_first = run(_cache(module, model), held)
|
||||
cached_second = run(_cache(module, model), held)
|
||||
|
||||
# One build per block for both forwards, instead of one per block per forward.
|
||||
assert [len(calls) for calls in injector_calls] == [1, 1]
|
||||
torch.testing.assert_close(cached_first, uncached_first, rtol=0, atol=0)
|
||||
torch.testing.assert_close(cached_second, uncached_second, rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_timestep_projection_is_staged_into_one_static_buffer_per_shape() -> None:
|
||||
"""Two forwards' projections land in the same buffer; the blocks never see the fresh per-forward tensor."""
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_layers=1).eval()
|
||||
first = torch.randn(1, 8, 6, model.dim)
|
||||
second = torch.randn(1, 8, 6, model.dim)
|
||||
|
||||
staged_first = model._stage_timestep_projection(first)
|
||||
assert staged_first is not first and torch.equal(staged_first, first)
|
||||
staged_second = model._stage_timestep_projection(second)
|
||||
assert staged_second is staged_first and torch.equal(staged_second, second)
|
||||
# A different shape gets its own buffer; the first one is kept.
|
||||
other = model._stage_timestep_projection(torch.randn(1, 4, 6, model.dim))
|
||||
assert other is not staged_first and len(model._timestep_projection_buffers) == 2
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,239 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from diffusers.models.embeddings import apply_rotary_emb
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_qk_rope_signature_caches():
|
||||
from vllm_omni.diffusion.models.longcat_image import longcat_image_transformer as longcat
|
||||
|
||||
longcat._FAILED_QK_ROPE_SIGNATURES.clear()
|
||||
yield
|
||||
longcat._FAILED_QK_ROPE_SIGNATURES.clear()
|
||||
|
||||
|
||||
def _inputs(sequence: int = 5, head_dim: int = 8):
|
||||
q = torch.randn(2, sequence, 3, head_dim, dtype=torch.bfloat16)
|
||||
k = torch.randn_like(q)
|
||||
cos = torch.randn(sequence, head_dim)
|
||||
sin = torch.randn_like(cos)
|
||||
return q, k, (cos, sin)
|
||||
|
||||
|
||||
def _reference(q, k, rotary_emb):
|
||||
return (
|
||||
apply_rotary_emb(q, rotary_emb, sequence_dim=1),
|
||||
apply_rotary_emb(k, rotary_emb, sequence_dim=1),
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_rotary_emb_resolves_threshold_once_only_when_enabled(monkeypatch):
|
||||
from vllm_omni.diffusion.models.longcat_image import longcat_image_transformer as longcat
|
||||
|
||||
calls = 0
|
||||
|
||||
def resolve(default):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
assert default == 512
|
||||
return 37
|
||||
|
||||
monkeypatch.setattr(longcat, "fused_qk_norm_rope_min_tokens", resolve)
|
||||
txt_cos, txt_sin = torch.randn(2, 8), torch.randn(2, 8)
|
||||
img_cos, img_sin = torch.randn(3, 8), torch.randn(3, 8)
|
||||
native = longcat._prepare_rotary_emb(txt_cos, txt_sin, img_cos, img_sin, enable_fusion=False)
|
||||
fused = longcat._prepare_rotary_emb(txt_cos, txt_sin, img_cos, img_sin, enable_fusion=True)
|
||||
|
||||
assert calls == 1
|
||||
assert len(native) == 2
|
||||
assert len(fused) == 3 and fused[2] == 37
|
||||
assert torch.equal(native[0], fused[0])
|
||||
assert torch.equal(native[1], fused[1])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "sp_size"),
|
||||
[
|
||||
("unsupported", 1),
|
||||
("compile", 1),
|
||||
("grad", 1),
|
||||
("capture", 1),
|
||||
("sequence_parallel", 2),
|
||||
("below_threshold", 1),
|
||||
],
|
||||
)
|
||||
def test_qk_rope_non_eager_modes_use_exact_original_path(monkeypatch, mode, sp_size):
|
||||
from vllm_omni.diffusion.models.longcat_image import longcat_image_transformer as longcat
|
||||
|
||||
torch.manual_seed(1)
|
||||
q, k, rotary_emb = _inputs()
|
||||
expected = _reference(q, k, rotary_emb)
|
||||
monkeypatch.setattr(torch.compiler, "is_compiling", lambda: mode == "compile")
|
||||
monkeypatch.setattr(torch.cuda, "is_current_stream_capturing", lambda: mode == "capture")
|
||||
monkeypatch.setattr(longcat, "fused_qk_rope_supported", lambda *args: mode != "unsupported")
|
||||
monkeypatch.setattr(
|
||||
longcat,
|
||||
"fused_qk_rope",
|
||||
lambda *args: (_ for _ in ()).throw(AssertionError("fallback called fused op")),
|
||||
)
|
||||
|
||||
context = nullcontext() if mode == "grad" else torch.no_grad()
|
||||
min_tokens = q.shape[0] * q.shape[1] + 1 if mode == "below_threshold" else 0
|
||||
with context:
|
||||
actual = longcat._apply_qk_rope(q, k, rotary_emb, sp_size, min_tokens)
|
||||
|
||||
assert torch.equal(actual[0], expected[0])
|
||||
assert torch.equal(actual[1], expected[1])
|
||||
|
||||
|
||||
def test_qk_rope_eligible_path_skips_runtime_parity_check(monkeypatch):
|
||||
from vllm_omni.diffusion.models.longcat_image import longcat_image_transformer as longcat
|
||||
|
||||
torch.manual_seed(2)
|
||||
monkeypatch.setattr(torch.compiler, "is_compiling", lambda: False)
|
||||
monkeypatch.setattr(torch.cuda, "is_current_stream_capturing", lambda: False)
|
||||
monkeypatch.setattr(longcat, "fused_qk_rope_supported", lambda *args: True)
|
||||
fused_calls = 0
|
||||
|
||||
def fused(query, key, cos, sin):
|
||||
nonlocal fused_calls
|
||||
fused_calls += 1
|
||||
return query + 1, key + 2
|
||||
|
||||
monkeypatch.setattr(
|
||||
longcat,
|
||||
"_apply_qk_rope_reference",
|
||||
lambda *args: (_ for _ in ()).throw(AssertionError("eligible path ran the native parity check")),
|
||||
)
|
||||
monkeypatch.setattr(longcat, "fused_qk_rope", fused)
|
||||
|
||||
with torch.no_grad():
|
||||
q, k, rotary_emb = _inputs()
|
||||
actual = longcat._apply_qk_rope(q, k, rotary_emb, 1, 0)
|
||||
|
||||
assert fused_calls == 1
|
||||
assert torch.equal(actual[0], q + 1)
|
||||
assert torch.equal(actual[1], k + 2)
|
||||
|
||||
|
||||
def test_qk_rope_launch_failure_permanently_falls_back(monkeypatch):
|
||||
from vllm_omni.diffusion.models.longcat_image import longcat_image_transformer as longcat
|
||||
|
||||
torch.manual_seed(3)
|
||||
monkeypatch.setattr(torch.compiler, "is_compiling", lambda: False)
|
||||
monkeypatch.setattr(torch.cuda, "is_current_stream_capturing", lambda: False)
|
||||
monkeypatch.setattr(longcat, "fused_qk_rope_supported", lambda *args: True)
|
||||
fused_calls = 0
|
||||
|
||||
def broken_fused(query, key, cos, sin):
|
||||
nonlocal fused_calls
|
||||
fused_calls += 1
|
||||
raise RuntimeError("kernel failed")
|
||||
|
||||
monkeypatch.setattr(longcat, "fused_qk_rope", broken_fused)
|
||||
q, k, rotary_emb = _inputs()
|
||||
expected = _reference(q, k, rotary_emb)
|
||||
with torch.no_grad():
|
||||
first = longcat._apply_qk_rope(q, k, rotary_emb, 1, 0)
|
||||
second = longcat._apply_qk_rope(q, k, rotary_emb, 1, 0)
|
||||
|
||||
assert fused_calls == 1
|
||||
assert len(longcat._FAILED_QK_ROPE_SIGNATURES) == 1
|
||||
for actual in (first, second):
|
||||
assert torch.equal(actual[0], expected[0])
|
||||
assert torch.equal(actual[1], expected[1])
|
||||
|
||||
|
||||
def test_failed_qk_rope_signature_cache_is_bounded():
|
||||
from vllm_omni.diffusion.models.longcat_image import longcat_image_transformer as longcat
|
||||
|
||||
for index in range(longcat._FAILED_QK_ROPE_SIGNATURES_MAX_SIZE + 1):
|
||||
longcat._record_failed_qk_rope_signature((index,))
|
||||
|
||||
assert len(longcat._FAILED_QK_ROPE_SIGNATURES) == longcat._FAILED_QK_ROPE_SIGNATURES_MAX_SIZE
|
||||
assert (0,) not in longcat._FAILED_QK_ROPE_SIGNATURES
|
||||
assert (longcat._FAILED_QK_ROPE_SIGNATURES_MAX_SIZE,) in longcat._FAILED_QK_ROPE_SIGNATURES
|
||||
|
||||
|
||||
class _FakeQKV(torch.nn.Module):
|
||||
def __init__(self, heads: int, head_dim: int) -> None:
|
||||
super().__init__()
|
||||
self.num_heads = heads
|
||||
self.num_kv_heads = heads
|
||||
self.width = heads * head_dim
|
||||
|
||||
def forward(self, x):
|
||||
return torch.cat((x, x + 1, x + 2), dim=-1), None
|
||||
|
||||
|
||||
class _AddNorm(torch.nn.Module):
|
||||
def __init__(self, value: float) -> None:
|
||||
super().__init__()
|
||||
self.value = value
|
||||
|
||||
def forward(self, x):
|
||||
return x + self.value
|
||||
|
||||
|
||||
class _CaptureAttention(torch.nn.Module):
|
||||
def forward(self, query, key, value, metadata=None):
|
||||
del key, value, metadata
|
||||
return query
|
||||
|
||||
|
||||
class _PassthroughLinear(torch.nn.Module):
|
||||
def forward(self, x):
|
||||
return x, None
|
||||
|
||||
|
||||
def test_dual_attention_keeps_native_norm_and_text_image_concat_order(monkeypatch):
|
||||
from vllm_omni.diffusion.models.longcat_image import longcat_image_transformer as longcat
|
||||
|
||||
attention = longcat.LongCatImageAttention.__new__(longcat.LongCatImageAttention)
|
||||
torch.nn.Module.__init__(attention)
|
||||
attention.parallel_config = SimpleNamespace(sequence_parallel_size=1)
|
||||
attention.head_dim = 4
|
||||
attention.added_kv_proj_dim = 8
|
||||
attention.to_qkv = _FakeQKV(2, 4)
|
||||
attention.add_kv_proj = _FakeQKV(2, 4)
|
||||
attention.norm_q = _AddNorm(10)
|
||||
attention.norm_k = _AddNorm(20)
|
||||
attention.norm_added_q = _AddNorm(100)
|
||||
attention.norm_added_k = _AddNorm(200)
|
||||
attention.attn = _CaptureAttention()
|
||||
attention.to_out = _PassthroughLinear()
|
||||
attention.to_add_out = _PassthroughLinear()
|
||||
|
||||
captured = []
|
||||
|
||||
def paired(query, key, rotary_emb, sp_size, min_tokens):
|
||||
captured.append((query.clone(), key.clone(), rotary_emb, sp_size, min_tokens))
|
||||
return query, key
|
||||
|
||||
monkeypatch.setattr(longcat, "_apply_qk_rope", paired)
|
||||
image = torch.randn(1, 3, 8)
|
||||
text = torch.randn(1, 2, 8)
|
||||
rotary_pair = (torch.randn(5, 4), torch.randn(5, 4))
|
||||
rotary_emb = (*rotary_pair, 0)
|
||||
image_out, text_out = attention(image, text, rotary_emb)
|
||||
|
||||
image_q = image.unflatten(-1, (2, 4))
|
||||
text_q = text.unflatten(-1, (2, 4))
|
||||
expected_q = torch.cat((text_q + 100, image_q + 10), dim=1)
|
||||
expected_k = torch.cat((text_q + 1 + 200, image_q + 1 + 20), dim=1)
|
||||
assert len(captured) == 1
|
||||
assert torch.equal(captured[0][0], expected_q)
|
||||
assert torch.equal(captured[0][1], expected_k)
|
||||
assert captured[0][2][0] is rotary_pair[0]
|
||||
assert captured[0][2][1] is rotary_pair[1]
|
||||
assert captured[0][3:] == (1, 0)
|
||||
assert image_out.shape == image.shape
|
||||
assert text_out.shape == text.shape
|
||||
@@ -233,6 +233,7 @@ def _fake_ltx_parallel_attention():
|
||||
|
||||
layer = object.__new__(ltx2_transformer._LTX2ParallelAttention)
|
||||
nn.Module.__init__(layer)
|
||||
layer.allow_fp32_fallback = False
|
||||
layer.attn_backend = Backend
|
||||
layer.attention = SimpleNamespace(forward=run("native"))
|
||||
layer.sdpa_fallback = SimpleNamespace(forward=run("sdpa"))
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.diffusion.models.magi2.test_native_packing import (
|
||||
_longer_text_tensors,
|
||||
_sampler_tensors,
|
||||
_tiny_model,
|
||||
_tiny_sampler,
|
||||
)
|
||||
from vllm_omni.diffusion.models.magi2.sampler_magi2 import CFGConfig
|
||||
|
||||
pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model]
|
||||
|
||||
|
||||
def test_layer_regions_bracket_the_eager_kernels() -> None:
|
||||
moe_layer, dense_layer = _tiny_model().block.layers
|
||||
|
||||
assert moe_layer.region_methods == ("_attention_input", "_moe_input", "_moe_output")
|
||||
assert dense_layer.region_methods == ("_attention_input", "_dense_output")
|
||||
|
||||
|
||||
def test_compile_regions_fullgraph_matches_eager_bitwise() -> None:
|
||||
model = _tiny_model()
|
||||
sampler = _tiny_sampler(model)
|
||||
steps = [
|
||||
sampler.prepare_model_input(**_sampler_tensors(seed), t=t, cfg_config=CFGConfig())
|
||||
for seed, t in enumerate((torch.tensor([900.0]), torch.tensor([450.0])))
|
||||
]
|
||||
expected = [sampler.forward(step) for step in steps]
|
||||
|
||||
torch._dynamo.reset()
|
||||
graphs_before = torch._dynamo.utils.counters["stats"]["unique_graphs"]
|
||||
model.compile_regions(fullgraph=True, backend="eager", dynamic=True)
|
||||
first = sampler.forward(steps[0])
|
||||
graphs = torch._dynamo.utils.counters["stats"]["unique_graphs"] - graphs_before
|
||||
with torch._dynamo.config.patch(error_on_recompile=True):
|
||||
second = sampler.forward(steps[1])
|
||||
|
||||
for (video, audio), (video_ref, audio_ref) in zip((first, second), expected, strict=True):
|
||||
assert torch.equal(video, video_ref)
|
||||
assert torch.equal(audio, audio_ref)
|
||||
# pre adapter, post adapter, three MoE-layer regions, two dense-layer regions
|
||||
assert graphs == 7
|
||||
|
||||
|
||||
def test_pipeline_setup_compile_forwards_config_dynamic() -> None:
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.models.magi2.pipeline_magi2 import Magi2Pipeline
|
||||
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
class _Transformer(torch.nn.Module):
|
||||
def compile_regions(self, **compile_kwargs: object) -> None:
|
||||
calls.append(compile_kwargs)
|
||||
|
||||
pipeline = object.__new__(Magi2Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = OmniDiffusionConfig(
|
||||
model="sand-ai/MAGI-2-preview",
|
||||
model_class_name="Magi2Pipeline",
|
||||
diffusion_compile_dynamic=False,
|
||||
)
|
||||
pipeline.transformer = _Transformer()
|
||||
|
||||
pipeline.setup_compile()
|
||||
|
||||
assert calls == [{"fullgraph": True, "dynamic": False, "options": {"emulate_precision_casts": True}}]
|
||||
|
||||
|
||||
def test_pipeline_setup_compile_warns_when_granularity_is_not_regional(caplog: pytest.LogCaptureFixture) -> None:
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.models.magi2.pipeline_magi2 import Magi2Pipeline
|
||||
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
class _Transformer(torch.nn.Module):
|
||||
def compile_regions(self, **compile_kwargs: object) -> None:
|
||||
calls.append(compile_kwargs)
|
||||
|
||||
pipeline = object.__new__(Magi2Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = OmniDiffusionConfig(
|
||||
model="sand-ai/MAGI-2-preview",
|
||||
model_class_name="Magi2Pipeline",
|
||||
diffusion_compile_granularity="full",
|
||||
)
|
||||
pipeline.transformer = _Transformer()
|
||||
|
||||
pipeline.setup_compile()
|
||||
|
||||
assert calls == [{"fullgraph": True, "dynamic": True, "options": {"emulate_precision_casts": True}}]
|
||||
assert "diffusion_compile_granularity='full'" in caplog.text
|
||||
|
||||
|
||||
def test_compiled_graphs_are_shared_across_layers_of_one_kind() -> None:
|
||||
model = _tiny_model(moe_layers=2)
|
||||
sampler = _tiny_sampler(model)
|
||||
step = sampler.prepare_model_input(**_sampler_tensors(0), t=torch.tensor([900.0]), cfg_config=CFGConfig())
|
||||
expected = sampler.forward(step)
|
||||
|
||||
torch._dynamo.reset()
|
||||
graphs_before = torch._dynamo.utils.counters["stats"]["unique_graphs"]
|
||||
model.compile_regions(fullgraph=True, backend="eager", dynamic=True)
|
||||
actual = sampler.forward(step)
|
||||
|
||||
assert torch.equal(actual[0], expected[0])
|
||||
# Two MoE layers and two dense layers still compile one graph per region
|
||||
# kind: pre adapter, post adapter, three MoE regions, two dense regions.
|
||||
assert torch._dynamo.utils.counters["stats"]["unique_graphs"] - graphs_before == 7
|
||||
|
||||
|
||||
def test_compiled_regions_survive_new_requests_without_recompiling() -> None:
|
||||
model = _tiny_model()
|
||||
sampler = _tiny_sampler(model)
|
||||
t = torch.tensor([900.0])
|
||||
first_request = sampler.prepare_model_input(**_sampler_tensors(0), t=t, cfg_config=CFGConfig())
|
||||
second_request = sampler.prepare_model_input(**_sampler_tensors(1), t=t, cfg_config=CFGConfig())
|
||||
longer_request = sampler.prepare_model_input(**_longer_text_tensors(2), t=t, cfg_config=CFGConfig())
|
||||
expected = [sampler.forward(request) for request in (first_request, second_request, longer_request)]
|
||||
|
||||
torch._dynamo.reset()
|
||||
graphs_before = torch._dynamo.utils.counters["stats"]["unique_graphs"]
|
||||
model.compile_regions(fullgraph=True, backend="eager", dynamic=True)
|
||||
actual = [sampler.forward(first_request)]
|
||||
with torch._dynamo.config.patch(error_on_recompile=True):
|
||||
actual.append(sampler.forward(second_request))
|
||||
actual.append(sampler.forward(longer_request))
|
||||
|
||||
for (video, audio), (video_ref, audio_ref) in zip(actual, expected, strict=True):
|
||||
assert torch.equal(video, video_ref)
|
||||
assert torch.equal(audio, audio_ref)
|
||||
assert torch._dynamo.utils.counters["stats"]["unique_graphs"] - graphs_before == 7
|
||||
@@ -0,0 +1,78 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.diffusion.models.magi2.test_native_packing import (
|
||||
_longer_text_tensors,
|
||||
_sampler_tensors,
|
||||
_tiny_model,
|
||||
_tiny_sampler,
|
||||
)
|
||||
from tests.helpers.mark import hardware_marks
|
||||
from vllm_omni.diffusion.models.magi2.sampler_magi2 import CFGConfig
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.diffusion,
|
||||
pytest.mark.core_model,
|
||||
*hardware_marks(res={"cuda": "L4"}, num_cards=1),
|
||||
pytest.mark.skipif(not current_omni_platform.is_cuda(), reason="requires CUDA"),
|
||||
]
|
||||
|
||||
|
||||
def _cuda_steps(sampler, tensors=_sampler_tensors):
|
||||
timesteps = (torch.tensor([900.0]), torch.tensor([600.0]), torch.tensor([300.0]))
|
||||
return [
|
||||
sampler.prepare_model_input(
|
||||
**{name: value.cuda() for name, value in tensors(seed).items()},
|
||||
t=t,
|
||||
cfg_config=CFGConfig(),
|
||||
)
|
||||
for seed, t in enumerate(timesteps)
|
||||
]
|
||||
|
||||
|
||||
def test_inductor_regions_match_eager_in_deterministic_mode(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("MAGI2_DETERMINISTIC", "1")
|
||||
model = _tiny_model(params_dtype=torch.bfloat16).cuda()
|
||||
sampler = _tiny_sampler(model)
|
||||
steps = _cuda_steps(sampler)
|
||||
expected = [sampler.forward(step) for step in steps]
|
||||
new_request_step = _cuda_steps(sampler)[0]
|
||||
longer_text_step = _cuda_steps(sampler, _longer_text_tensors)[0]
|
||||
expected_new_request = sampler.forward(new_request_step)
|
||||
expected_longer_text = sampler.forward(longer_text_step)
|
||||
|
||||
torch._dynamo.reset()
|
||||
graphs_before = torch._dynamo.utils.counters["stats"]["unique_graphs"]
|
||||
# The pipeline compiles with inductor emulating eager's intermediate
|
||||
# low-precision casts, so fused bf16 chains keep eager's rounding.
|
||||
model.compile_regions(fullgraph=True, dynamic=True, options={"emulate_precision_casts": True})
|
||||
first = sampler.forward(steps[0])
|
||||
with torch._dynamo.config.patch(error_on_recompile=True):
|
||||
compiled = [first, sampler.forward(steps[1]), sampler.forward(steps[2])]
|
||||
repeated = [sampler.forward(step) for step in steps]
|
||||
# A second request repacks the same shapes; a third changes the
|
||||
# text length and therefore every token count.
|
||||
compiled_new_request = sampler.forward(new_request_step)
|
||||
compiled_longer_text = sampler.forward(longer_text_step)
|
||||
|
||||
# Emulated precision casts restore eager's rounding boundaries; what is
|
||||
# left is one-ulp transcendental noise that some Triton backends carry.
|
||||
for (video, audio), (video_ref, audio_ref) in zip(compiled, expected, strict=True):
|
||||
torch.testing.assert_close(video, video_ref, atol=1e-6, rtol=0)
|
||||
torch.testing.assert_close(audio, audio_ref, atol=1e-6, rtol=0)
|
||||
for (video, audio), (video_again, audio_again) in zip(compiled, repeated, strict=True):
|
||||
assert torch.equal(video, video_again)
|
||||
assert torch.equal(audio, audio_again)
|
||||
for (video, audio), (video_ref, audio_ref) in (
|
||||
(compiled_new_request, expected_new_request),
|
||||
(compiled_longer_text, expected_longer_text),
|
||||
):
|
||||
torch.testing.assert_close(video, video_ref, atol=1e-6, rtol=0)
|
||||
torch.testing.assert_close(audio, audio_ref, atol=1e-6, rtol=0)
|
||||
assert torch._dynamo.utils.counters["stats"]["unique_graphs"] - graphs_before == 7
|
||||
@@ -0,0 +1,179 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
import torch.multiprocessing as mp
|
||||
import torch.nn as nn
|
||||
from torch.distributed import init_device_mesh
|
||||
from torch.distributed.fsdp import MixedPrecisionPolicy
|
||||
|
||||
import vllm_omni.diffusion.offloader.distributed_layerwise_backend as dlo_module
|
||||
from tests.diffusion.models.magi2.test_native_distributed_parity import (
|
||||
_current_group,
|
||||
_new_groups,
|
||||
_patched_groups,
|
||||
)
|
||||
from tests.diffusion.models.magi2.test_native_packing import _tiny_config, _tiny_model, _tiny_sampler
|
||||
from tests.diffusion.offloader.helpers import patch_offload_runtime
|
||||
from vllm_omni.diffusion.distributed.hsdp import shard_model
|
||||
from vllm_omni.diffusion.models.magi2.modeling_magi2 import Magi2PreviewTransformer
|
||||
from vllm_omni.diffusion.models.magi2.parallel import Magi2ParallelGroup
|
||||
from vllm_omni.diffusion.models.magi2.pipeline_magi2 import Magi2Pipeline
|
||||
from vllm_omni.diffusion.models.magi2.sampler_magi2 import CFGConfig
|
||||
from vllm_omni.diffusion.offloader.base import OffloadConfig
|
||||
from vllm_omni.diffusion.offloader.config import OffloadStrategy
|
||||
|
||||
_WORLD_SIZE = 4
|
||||
pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _cpu_offload_runtime():
|
||||
with pytest.MonkeyPatch.context() as monkeypatch:
|
||||
patch_offload_runtime(monkeypatch, dlo_module.current_omni_platform, synchronize=True)
|
||||
yield
|
||||
|
||||
|
||||
def _enable_dlo(
|
||||
model: Magi2PreviewTransformer,
|
||||
*,
|
||||
dp_group: dist.ProcessGroup | None,
|
||||
dp_size: int,
|
||||
dp_rank: int,
|
||||
) -> None:
|
||||
pipeline = Magi2Pipeline.__new__(Magi2Pipeline)
|
||||
nn.Module.__init__(pipeline)
|
||||
pipeline.transformer = model
|
||||
backend = dlo_module.DistributedLayerwiseOffloadBackend(
|
||||
OffloadConfig(
|
||||
strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE,
|
||||
pin_cpu_memory=False,
|
||||
dp_size=dp_size,
|
||||
dlo_use_allgather=dp_size > 1,
|
||||
),
|
||||
torch.device("cpu"),
|
||||
)
|
||||
backend.dp_group = dp_group
|
||||
backend.rank = dp_rank
|
||||
backend.enable(pipeline)
|
||||
assert backend.enabled
|
||||
|
||||
|
||||
def _enable_hsdp(model: Magi2PreviewTransformer) -> None:
|
||||
mesh = init_device_mesh("cpu", mesh_shape=(1, _WORLD_SIZE), mesh_dim_names=("replicate", "shard"))
|
||||
ignored = {
|
||||
parameter for name in model._hsdp_ignored_modules for parameter in model.get_submodule(name).parameters()
|
||||
}
|
||||
shard_model(
|
||||
model,
|
||||
mesh=mesh,
|
||||
mp_policy=MixedPrecisionPolicy(cast_forward_inputs=False),
|
||||
hsdp_shard_conditions=model._hsdp_shard_conditions,
|
||||
ignored_params=ignored,
|
||||
)
|
||||
model.requires_grad_(False)
|
||||
|
||||
|
||||
def _sampler_tensors(seed: int) -> dict[str, torch.Tensor]:
|
||||
# 14 tokens per CFG branch, so the packed sequence splits evenly across
|
||||
# two Ulysses ranks; gloo cannot gather uneven shards.
|
||||
generator = torch.Generator(device="cpu").manual_seed(seed)
|
||||
return {
|
||||
"latent": torch.randn(1, 4, 2, 1, 3, generator=generator),
|
||||
"audio_latent": torch.randn(1, 5, 4, generator=generator),
|
||||
"txt_feat": torch.randn(1, 3, 4, generator=generator),
|
||||
"null_txt_feat": torch.randn(1, 3, 4, generator=generator),
|
||||
}
|
||||
|
||||
|
||||
def _steps(sampler):
|
||||
return [
|
||||
sampler.prepare_model_input(**_sampler_tensors(seed), t=t, cfg_config=CFGConfig())
|
||||
for seed, t in enumerate((torch.tensor([900.0]), torch.tensor([450.0])))
|
||||
]
|
||||
|
||||
|
||||
def _run(model: Magi2PreviewTransformer, *, compile_regions: bool) -> list[tuple[torch.Tensor, torch.Tensor]]:
|
||||
sampler = _tiny_sampler(model)
|
||||
steps = _steps(sampler)
|
||||
if not compile_regions:
|
||||
return [sampler.forward(step) for step in steps]
|
||||
torch._dynamo.reset()
|
||||
model.compile_regions(fullgraph=True, backend="eager", dynamic=True)
|
||||
outputs = [sampler.forward(steps[0])]
|
||||
with torch._dynamo.config.patch(error_on_recompile=True):
|
||||
outputs.append(sampler.forward(steps[1]))
|
||||
return outputs
|
||||
|
||||
|
||||
def _assert_same(actual, expected, *, exact: bool, label: str) -> None:
|
||||
for (video, audio), (video_ref, audio_ref) in zip(actual, expected, strict=True):
|
||||
if exact:
|
||||
assert torch.equal(video, video_ref), label
|
||||
assert torch.equal(audio, audio_ref), label
|
||||
else:
|
||||
torch.testing.assert_close(video, video_ref, atol=2e-5, rtol=2e-4, msg=label)
|
||||
torch.testing.assert_close(audio, audio_ref, atol=2e-5, rtol=2e-4, msg=label)
|
||||
|
||||
|
||||
def _distributed_worker(rank: int, rendezvous: str) -> None:
|
||||
torch.set_num_threads(1)
|
||||
dist.init_process_group("gloo", init_method=rendezvous, rank=rank, world_size=_WORLD_SIZE)
|
||||
try:
|
||||
singleton = Magi2ParallelGroup(None, world_size=1, rank=0)
|
||||
with _patched_groups(singleton, singleton):
|
||||
oracle = _tiny_model()
|
||||
checkpoint = [(name, value.detach().clone()) for name, value in oracle.state_dict().items()]
|
||||
expected = _run(oracle, compile_regions=False)
|
||||
|
||||
sp2_groups = _new_groups(((0, 1), (2, 3)))
|
||||
dp2_groups = _new_groups(((0, 2), (1, 3)))
|
||||
sp_group = _current_group(rank, sp2_groups)
|
||||
dp_ranks, dp_process_group = next((ranks, group) for ranks, group in dp2_groups if rank in ranks)
|
||||
|
||||
def dlo_rank_local(model: Magi2PreviewTransformer) -> None:
|
||||
_enable_dlo(model, dp_group=None, dp_size=1, dp_rank=0)
|
||||
|
||||
def dlo_allgather(model: Magi2PreviewTransformer) -> None:
|
||||
_enable_dlo(model, dp_group=dp_process_group, dp_size=len(dp_ranks), dp_rank=dp_ranks.index(rank))
|
||||
|
||||
variants = (
|
||||
("dlo_rank_local_sp2", sp_group, dlo_rank_local),
|
||||
("dlo_allgather_dp2sp2", sp_group, dlo_allgather),
|
||||
("hsdp4", singleton, _enable_hsdp),
|
||||
)
|
||||
for name, ulysses_group, enable in variants:
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(_patched_groups(singleton, ulysses_group))
|
||||
stack.enter_context(_cpu_offload_runtime())
|
||||
outputs = {}
|
||||
for compile_regions in (False, True):
|
||||
model = Magi2PreviewTransformer(_tiny_config())
|
||||
assert model.load_weights(checkpoint) == set(model.state_dict())
|
||||
enable(model)
|
||||
outputs[compile_regions] = _run(model, compile_regions=compile_regions)
|
||||
_assert_same(outputs[False], expected, exact=False, label=f"{name} eager vs single-rank oracle")
|
||||
_assert_same(outputs[True], outputs[False], exact=True, label=f"{name} compiled vs eager")
|
||||
dist.barrier()
|
||||
finally:
|
||||
dist.destroy_process_group()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not dist.is_available() or not dist.is_gloo_available(),
|
||||
reason="requires torch.distributed gloo",
|
||||
)
|
||||
def test_compiled_regions_match_eager_under_dlo_and_hsdp() -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
rendezvous = f"file://{os.path.join(temp_dir, 'gloo-rendezvous')}"
|
||||
with patch.dict(os.environ, {"CUDA_VISIBLE_DEVICES": ""}):
|
||||
mp.spawn(_distributed_worker, args=(rendezvous,), nprocs=_WORLD_SIZE, join=True)
|
||||
@@ -36,6 +36,7 @@ from vllm_omni.diffusion.offloader.distributed_layerwise_backend import (
|
||||
DistributedLayerwiseOffloadBackend,
|
||||
DistributedLayerwiseOffloadHook,
|
||||
)
|
||||
from vllm_omni.diffusion.offloader.plan_resolver import ResolvedComponent
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -46,12 +47,6 @@ class _WeightSourceStub:
|
||||
prefix: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PipelineModulesStub:
|
||||
dits: list[nn.Module]
|
||||
dit_names: list[str]
|
||||
|
||||
|
||||
_WORLD_SIZE = 4
|
||||
pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model]
|
||||
|
||||
@@ -189,7 +184,7 @@ def _load_mmap_transform_and_reconstruct(
|
||||
backend._mmap_transforms_by_tensor_id = {}
|
||||
backend._load_weights_via_mmap(
|
||||
pipeline,
|
||||
_PipelineModulesStub(dits=[target], dit_names=["transformer"]),
|
||||
(ResolvedComponent(path="transformer", module=target, selected=True),),
|
||||
plan_result.plan,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.magi2.configuration_magi2 import (
|
||||
Magi2MHCConfig,
|
||||
Magi2MoEConfig,
|
||||
Magi2PreviewConfig,
|
||||
)
|
||||
from vllm_omni.diffusion.models.magi2.layers import MultiModalityRMSNorm
|
||||
from vllm_omni.diffusion.models.magi2.mh_moe import Magi2MultiHeadMoE
|
||||
from vllm_omni.diffusion.models.magi2.modeling_magi2 import Magi2PreviewTransformer
|
||||
from vllm_omni.diffusion.models.magi2.preview_data_proxy import (
|
||||
Magi2DataProxy,
|
||||
Magi2PreviewDataProxyConfig,
|
||||
)
|
||||
from vllm_omni.diffusion.models.magi2.sampler_magi2 import CFGConfig, Magi2PreviewSampler
|
||||
|
||||
pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model]
|
||||
|
||||
|
||||
def _tiny_config(params_dtype: torch.dtype = torch.float32, *, moe_layers: int = 1) -> Magi2PreviewConfig:
|
||||
# The leading layers are multimodal with MoE, the rest single-modality
|
||||
# dense, so every layer belongs to one of two kinds.
|
||||
moe_indices = tuple(range(moe_layers))
|
||||
return Magi2PreviewConfig(
|
||||
num_layers=2 * moe_layers,
|
||||
hidden_size=16,
|
||||
head_dim=8,
|
||||
num_query_groups=2,
|
||||
video_in_channels=4,
|
||||
audio_in_channels=4,
|
||||
text_in_channels=4,
|
||||
intermediate_factor=2,
|
||||
multimodal_layers=moe_indices,
|
||||
params_dtype=params_dtype,
|
||||
mhc=Magi2MHCConfig(num_streams=2),
|
||||
moe=Magi2MoEConfig(
|
||||
num_heads=2,
|
||||
num_experts=4,
|
||||
top_k=2,
|
||||
expert_intermediate_size=8,
|
||||
shared_expert_intermediate_size=8,
|
||||
modality_shared_expert_intermediate_size=8,
|
||||
layers=moe_indices,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _tiny_model(
|
||||
seed: int = 11,
|
||||
params_dtype: torch.dtype = torch.float32,
|
||||
*,
|
||||
moe_layers: int = 1,
|
||||
) -> Magi2PreviewTransformer:
|
||||
model = Magi2PreviewTransformer(_tiny_config(params_dtype, moe_layers=moe_layers))
|
||||
generator = torch.Generator(device="cpu").manual_seed(seed)
|
||||
with torch.no_grad():
|
||||
for parameter in model.parameters():
|
||||
parameter.copy_(torch.randn(parameter.shape, generator=generator, dtype=parameter.dtype) * 0.02)
|
||||
for module in model.modules():
|
||||
if isinstance(module, MultiModalityRMSNorm):
|
||||
module.weight.zero_()
|
||||
elif isinstance(module, Magi2MultiHeadMoE):
|
||||
module.router.expert_bias.zero_()
|
||||
module.router.expert_bias_ema.zero_()
|
||||
return model
|
||||
|
||||
|
||||
def _tiny_sampler(model: torch.nn.Module) -> Magi2PreviewSampler:
|
||||
return Magi2PreviewSampler(model, Magi2DataProxy(Magi2PreviewDataProxyConfig(time_channel_dim=8)))
|
||||
|
||||
|
||||
def _sampler_tensors(seed: int) -> dict[str, torch.Tensor]:
|
||||
generator = torch.Generator(device="cpu").manual_seed(seed)
|
||||
return {
|
||||
"latent": torch.randn(1, 4, 2, 1, 3, generator=generator),
|
||||
"audio_latent": torch.randn(1, 5, 4, generator=generator),
|
||||
"txt_feat": torch.randn(1, 3, 4, generator=generator),
|
||||
"null_txt_feat": torch.randn(1, 2, 4, generator=generator),
|
||||
}
|
||||
|
||||
|
||||
def _longer_text_tensors(seed: int) -> dict[str, torch.Tensor]:
|
||||
tensors = _sampler_tensors(seed)
|
||||
generator = torch.Generator(device="cpu").manual_seed(seed + 100)
|
||||
tensors["txt_feat"] = torch.randn(1, 4, 4, generator=generator)
|
||||
return tensors
|
||||
|
||||
|
||||
def test_prepare_model_input_keeps_lengths_on_host() -> None:
|
||||
sampler = Magi2PreviewSampler(torch.nn.Identity())
|
||||
model_input = sampler.prepare_model_input(**_sampler_tensors(0), t=torch.tensor([500.0]), cfg_config=CFGConfig())
|
||||
|
||||
assert model_input.audio_feat_len == [5, 5]
|
||||
assert model_input.txt_feat_len == [3, 2]
|
||||
assert model_input.ref_audio_feat_len == [0, 0]
|
||||
assert model_input.ref_video_feat_len == [0, 0]
|
||||
|
||||
positive, negative = Magi2PreviewSampler._split_cfg_model_input(model_input)
|
||||
assert positive.txt_feat_len == [3]
|
||||
assert negative.txt_feat_len == [2]
|
||||
|
||||
|
||||
def test_pre_adapter_embeds_directly_in_checkpoint_dtype() -> None:
|
||||
model = _tiny_model(params_dtype=torch.bfloat16)
|
||||
packed = torch.randn(6, 4)
|
||||
indices = torch.tensor([0, 1]), torch.tensor([2, 3]), torch.tensor([4, 5])
|
||||
|
||||
with torch.no_grad():
|
||||
hidden = model.pre_adapter(packed, *indices)
|
||||
|
||||
assert hidden.dtype == torch.bfloat16
|
||||
assert hidden.shape == (6, model.config.virtual_width)
|
||||
@@ -90,6 +90,21 @@ def test_hsdp_policy_shards_individual_preview_layers():
|
||||
assert not condition("pre_adapter", model.pre_adapter)
|
||||
|
||||
|
||||
def test_layerwise_offload_contract() -> None:
|
||||
from vllm_omni.diffusion.offloader import get_blocks_from_dit
|
||||
|
||||
model = Magi2PreviewTransformer(_tiny_config(num_layers=2))
|
||||
|
||||
assert model._layerwise_offload_blocks_attrs == ["block"]
|
||||
assert "block" in dict(model.named_children())
|
||||
assert list(model.block) == list(model.block.layers)
|
||||
assert model.layers is model.block.layers
|
||||
|
||||
attr_names, blocks = get_blocks_from_dit(model)
|
||||
assert attr_names == ["block"]
|
||||
assert blocks == list(model.block.layers)
|
||||
|
||||
|
||||
def test_data_proxy_keeps_output_layout_request_scoped() -> None:
|
||||
proxy = Magi2DataProxy(Magi2PreviewDataProxyConfig(time_channel_dim=0))
|
||||
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,189 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""MammothModa2 DiT self-attention runs through the shared Omni attention layer.
|
||||
|
||||
``_reference_attention`` is the arithmetic the processor used before it was
|
||||
wired to the shared layer: SDPA over K/V replicated to the query head count,
|
||||
padded rows zeroed. Both sides read the same module weights, so any drift in
|
||||
the projections, the QK RMSNorm, the interleaved real RoPE, the softmax scale
|
||||
or the padding mask shows up as a mismatch here.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
from vllm_omni.diffusion.attention.backends.registry import DiffusionAttentionBackendEnum
|
||||
from vllm_omni.diffusion.config import set_current_diffusion_config
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.models.mammoth_moda2.mammothmoda2_dit_model import (
|
||||
TransformerBlock,
|
||||
apply_real_rotary_emb,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
# Small stand-in for the real 2520 / 21 / 7 (head_dim 120) layout.
|
||||
DIM, HEADS = 48, 6
|
||||
SEQ, BATCH = 37, 2
|
||||
|
||||
# The shared layer resolves its backend from the diffusion config at construction;
|
||||
# without one it takes the platform default, which on a CUDA host is FLASH_ATTN
|
||||
# and cannot run CPU tensors. Pin SDPA the way a deployment would.
|
||||
_SDPA_CONFIG = OmniDiffusionConfig(diffusion_attention_config={"default": {"backend": "TORCH_SDPA"}})
|
||||
|
||||
|
||||
def _block(kv_heads: int, modulation: bool = True) -> TransformerBlock:
|
||||
torch.manual_seed(0)
|
||||
with set_current_diffusion_config(_SDPA_CONFIG):
|
||||
block = TransformerBlock(
|
||||
DIM,
|
||||
HEADS,
|
||||
kv_heads,
|
||||
multiple_of=8,
|
||||
ffn_dim_multiplier=1.0,
|
||||
norm_eps=1e-5,
|
||||
modulation=modulation,
|
||||
)
|
||||
return block.eval()
|
||||
|
||||
|
||||
def _inputs(head_dim: int):
|
||||
torch.manual_seed(1)
|
||||
hidden = torch.randn(BATCH, SEQ, DIM)
|
||||
mask = torch.ones(BATCH, SEQ, dtype=torch.bool)
|
||||
mask[0, SEQ - 9 :] = False
|
||||
mask[1, SEQ - 2 :] = False
|
||||
angles = torch.rand(1, SEQ, head_dim) * 6.283
|
||||
rotary = (angles.cos(), angles.sin())
|
||||
temb = torch.randn(BATCH, min(DIM, 1024))
|
||||
return hidden, mask, rotary, temb
|
||||
|
||||
|
||||
def _reference_attention(attn, hidden, mask, rotary):
|
||||
"""The pre-change processor, kept verbatim as the oracle."""
|
||||
batch, seq, _ = hidden.shape
|
||||
query, key, value = attn.to_q(hidden), attn.to_k(hidden), attn.to_v(hidden)
|
||||
head_dim = query.shape[-1] // attn.heads
|
||||
kv_heads = key.shape[-1] // head_dim
|
||||
query = attn.norm_q(query.view(batch, seq, attn.heads, head_dim))
|
||||
key = attn.norm_k(key.view(batch, seq, kv_heads, head_dim))
|
||||
value = value.view(batch, seq, kv_heads, head_dim)
|
||||
query = apply_real_rotary_emb(query, rotary[0], rotary[1])
|
||||
key = apply_real_rotary_emb(key, rotary[0], rotary[1])
|
||||
query, key, value = query.transpose(1, 2), key.transpose(1, 2), value.transpose(1, 2)
|
||||
if kv_heads < attn.heads:
|
||||
key = key.repeat_interleave(attn.heads // kv_heads, dim=1)
|
||||
value = value.repeat_interleave(attn.heads // kv_heads, dim=1)
|
||||
out = F.scaled_dot_product_attention(query, key, value, attn_mask=mask.view(batch, 1, 1, seq), scale=attn.scale)
|
||||
out = (out * mask[:, None, :, None]).transpose(1, 2).reshape(batch, seq, attn.heads * head_dim)
|
||||
return attn.to_out[1](attn.to_out[0](out))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kv_heads", [2, HEADS], ids=["gqa_3to1", "mha"])
|
||||
def test_processor_matches_previous_arithmetic(kv_heads):
|
||||
block = _block(kv_heads)
|
||||
hidden, mask, rotary, _ = _inputs(block.head_dim)
|
||||
with torch.no_grad():
|
||||
got = block.attn(
|
||||
hidden_states=hidden, encoder_hidden_states=hidden, attention_mask=mask, image_rotary_emb=rotary
|
||||
)
|
||||
want = _reference_attention(block.attn, hidden, mask, rotary)
|
||||
torch.testing.assert_close(got, want, atol=1e-5, rtol=1e-5)
|
||||
assert torch.count_nonzero(got[~mask]) == 0, "padded rows must stay zero"
|
||||
|
||||
|
||||
def test_no_mask_equals_all_valid_mask():
|
||||
block = _block(2)
|
||||
hidden, _, rotary, _ = _inputs(block.head_dim)
|
||||
full = torch.ones(BATCH, SEQ, dtype=torch.bool)
|
||||
with torch.no_grad():
|
||||
unmasked = block.attn(
|
||||
hidden_states=hidden, encoder_hidden_states=hidden, attention_mask=None, image_rotary_emb=rotary
|
||||
)
|
||||
masked = block.attn(
|
||||
hidden_states=hidden, encoder_hidden_states=hidden, attention_mask=full, image_rotary_emb=rotary
|
||||
)
|
||||
torch.testing.assert_close(unmasked, masked, atol=1e-6, rtol=1e-6)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("modulation", [True, False], ids=["noise_block", "context_refiner"])
|
||||
def test_block_forward_hands_native_kv_heads_to_shared_layer(monkeypatch, modulation):
|
||||
"""Through the block's real forward, on both of its branches (the modulated
|
||||
noise/refiner blocks and the unmodulated context refiner): the shared layer
|
||||
is what runs, and it receives K/V with their native head count -- no
|
||||
replication before the call."""
|
||||
block = _block(2, modulation=modulation)
|
||||
hidden, mask, rotary, temb = _inputs(block.head_dim)
|
||||
if not modulation:
|
||||
temb = None
|
||||
seen = []
|
||||
original = block.attn.omni_attn.forward
|
||||
|
||||
def spy(query, key, value, attn_metadata=None):
|
||||
seen.append((tuple(query.shape), tuple(key.shape), tuple(value.shape), attn_metadata))
|
||||
return original(query, key, value, attn_metadata)
|
||||
|
||||
monkeypatch.setattr(block.attn.omni_attn, "forward", spy)
|
||||
with torch.no_grad():
|
||||
out = block(hidden, mask, rotary, temb)
|
||||
assert out.shape == hidden.shape and torch.isfinite(out).all()
|
||||
assert len(seen) == 1
|
||||
q_shape, k_shape, v_shape, metadata = seen[0]
|
||||
assert q_shape == (BATCH, SEQ, HEADS, block.head_dim)
|
||||
assert k_shape == v_shape == (BATCH, SEQ, 2, block.head_dim)
|
||||
assert metadata is not None and metadata.attn_mask.dtype == torch.bool
|
||||
assert torch.equal(metadata.attn_mask, mask)
|
||||
|
||||
|
||||
def test_backend_selection_reaches_the_dit_layer():
|
||||
"""The diffusion config selects the backend when the DiT layer is constructed."""
|
||||
assert _block(2).attn.omni_attn.attn_backend is DiffusionAttentionBackendEnum.TORCH_SDPA.get_class()
|
||||
with set_current_diffusion_config(
|
||||
OmniDiffusionConfig(diffusion_attention_config={"default": {"backend": "FLASH_ATTN"}})
|
||||
):
|
||||
block = TransformerBlock(DIM, HEADS, 2, multiple_of=8, ffn_dim_multiplier=1.0, norm_eps=1e-5)
|
||||
assert block.attn.omni_attn.attn_backend is DiffusionAttentionBackendEnum.FLASH_ATTN.get_class()
|
||||
|
||||
|
||||
def test_shared_layer_owns_no_parameters_and_keeps_checkpoint_keys():
|
||||
block = _block(2)
|
||||
assert sum(p.numel() for p in block.attn.omni_attn.parameters()) == 0
|
||||
assert not list(block.attn.omni_attn.buffers())
|
||||
keys = set(block.state_dict())
|
||||
assert not any("omni_attn" in key for key in keys)
|
||||
# The projections and QK norms the checkpoint provides are still where they were.
|
||||
for expected in (
|
||||
"attn.to_q.weight",
|
||||
"attn.to_k.weight",
|
||||
"attn.to_v.weight",
|
||||
"attn.to_out.0.weight",
|
||||
"attn.norm_q.weight",
|
||||
"attn.norm_k.weight",
|
||||
):
|
||||
assert expected in keys, expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("with_mask", [True, False], ids=["empty_mask", "no_mask"])
|
||||
def test_empty_text_stream_skips_the_kernel(monkeypatch, with_mask):
|
||||
"""CFG's unconditional branch carries zero text tokens (the pipeline's default
|
||||
negative_prompt_embeds has no rows), so the context refiner attends over an
|
||||
empty sequence. The flash-attention varlen fallback cannot take that
|
||||
(arange step 0), so the processor must not reach the shared layer at all."""
|
||||
block = _block(kv_heads=2, modulation=False)
|
||||
attn = block.attn
|
||||
|
||||
def _never(*args, **kwargs):
|
||||
raise AssertionError("shared layer called with an empty sequence")
|
||||
|
||||
monkeypatch.setattr(attn.omni_attn, "forward", _never)
|
||||
hidden = torch.randn(BATCH, 0, DIM)
|
||||
mask = torch.ones(BATCH, 0, dtype=torch.bool) if with_mask else None
|
||||
angles = torch.rand(1, 0, block.head_dim)
|
||||
out = attn(
|
||||
hidden_states=hidden,
|
||||
encoder_hidden_states=hidden,
|
||||
attention_mask=mask,
|
||||
image_rotary_emb=(angles.cos(), angles.sin()),
|
||||
)
|
||||
assert out.shape == (BATCH, 0, DIM)
|
||||
@@ -0,0 +1,151 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Real-shape check of the MammothModa2 DiT attention on CUDA: the shared
|
||||
backend (FLASH_ATTN by default, head_dim 120) against the pre-change arithmetic
|
||||
in bf16, with the padding mask the joint text+image sequence carries."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import vllm_omni.diffusion.attention.backends.sdpa as sdpa_backend
|
||||
from tests.helpers.mark import hardware_marks
|
||||
from vllm_omni.diffusion.models.mammoth_moda2.mammothmoda2_dit_model import TransformerBlock
|
||||
|
||||
from .test_dit_attention import _reference_attention
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.core_model,
|
||||
pytest.mark.advanced_model,
|
||||
pytest.mark.cuda,
|
||||
*hardware_marks(res={"cuda": "L4"}, num_cards=1),
|
||||
]
|
||||
|
||||
# MammothModa2-Preview gen_dit_config: hidden 2520, 21 heads, 7 KV heads, head_dim 120.
|
||||
DIM, HEADS, KV_HEADS = 2520, 21, 7
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seq", [77 + 4096, 512], ids=["t2i_1024", "short"])
|
||||
def test_real_shape_matches_previous_arithmetic_bf16(seq):
|
||||
torch.manual_seed(0)
|
||||
block = (
|
||||
TransformerBlock(DIM, HEADS, KV_HEADS, multiple_of=256, ffn_dim_multiplier=1.0, norm_eps=1e-5)
|
||||
.cuda()
|
||||
.to(torch.bfloat16)
|
||||
.eval()
|
||||
)
|
||||
hidden = torch.randn(2, seq, DIM, device="cuda", dtype=torch.bfloat16)
|
||||
mask = torch.ones(2, seq, dtype=torch.bool, device="cuda")
|
||||
mask[0, seq - 300 :] = False
|
||||
mask[1, seq - 17 :] = False
|
||||
angles = torch.rand(1, seq, block.head_dim, device="cuda") * 6.283
|
||||
rotary = (angles.cos().to(torch.bfloat16), angles.sin().to(torch.bfloat16))
|
||||
with torch.no_grad():
|
||||
got = block.attn(
|
||||
hidden_states=hidden, encoder_hidden_states=hidden, attention_mask=mask, image_rotary_emb=rotary
|
||||
)
|
||||
want = _reference_attention(block.attn, hidden, mask, rotary)
|
||||
assert torch.isfinite(got).all()
|
||||
assert torch.count_nonzero(got[~mask]) == 0
|
||||
diff = (got.float() - want.float()).abs()[mask]
|
||||
# bf16 kernels differ in accumulation order; measured on A800: max 1.0e-3, mean 5e-5.
|
||||
assert diff.max().item() < 2e-2, diff.max().item()
|
||||
assert diff.mean().item() < 1e-3, diff.mean().item()
|
||||
|
||||
|
||||
def test_empty_text_stream_on_the_default_backend():
|
||||
"""The recipe's text-to-image request with text_guidance_scale > 1 runs the
|
||||
context refiner on a zero-token unconditional prompt. Before the guard this
|
||||
raised ``RuntimeError: step must be nonzero`` from the FA varlen fallback."""
|
||||
torch.manual_seed(0)
|
||||
block = (
|
||||
TransformerBlock(DIM, HEADS, KV_HEADS, multiple_of=256, ffn_dim_multiplier=1.0, norm_eps=1e-5, modulation=False)
|
||||
.cuda()
|
||||
.to(torch.bfloat16)
|
||||
.eval()
|
||||
)
|
||||
hidden = torch.randn(1, 0, DIM, device="cuda", dtype=torch.bfloat16)
|
||||
mask = torch.ones(1, 0, dtype=torch.bool, device="cuda")
|
||||
angles = torch.rand(1, 0, block.head_dim, device="cuda")
|
||||
with torch.no_grad():
|
||||
out = block.attn(
|
||||
hidden_states=hidden,
|
||||
encoder_hidden_states=hidden,
|
||||
attention_mask=mask,
|
||||
image_rotary_emb=(angles.cos().to(torch.bfloat16), angles.sin().to(torch.bfloat16)),
|
||||
)
|
||||
assert out.shape == (1, 0, DIM)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("seq", "force_gqa_fallback"),
|
||||
[(512, False), (77 + 4096, False), (32, True)],
|
||||
ids=["short", "t2i_1024", "unsupported_native_gqa"],
|
||||
)
|
||||
def test_fp32_falls_back_to_sdpa_and_matches_reference(monkeypatch, seq, force_gqa_fallback):
|
||||
"""FP32 keeps its dtype and reaches shared SDPA's GQA compatibility check.
|
||||
|
||||
Cover the long masked shape that otherwise falls back to quadratic math
|
||||
attention, and force the unsupported-GQA branch independently of hardware.
|
||||
"""
|
||||
torch.manual_seed(0)
|
||||
dt = torch.float32
|
||||
block = (
|
||||
TransformerBlock(DIM, HEADS, KV_HEADS, multiple_of=256, ffn_dim_multiplier=1.0, norm_eps=1e-5)
|
||||
.cuda()
|
||||
.to(dt)
|
||||
.eval()
|
||||
)
|
||||
hidden = torch.randn(1, seq, DIM, device="cuda", dtype=dt)
|
||||
mask = torch.ones(1, seq, dtype=torch.bool, device="cuda")
|
||||
mask[:, -13:] = False
|
||||
angles = torch.rand(1, seq, block.head_dim, device="cuda")
|
||||
rotary = (angles.cos().to(dt), angles.sin().to(dt))
|
||||
capabilities, calls, phases = [], [], []
|
||||
can_use_fused_gqa = sdpa_backend.can_sdpa_use_fused_gqa
|
||||
sdpa = torch.nn.functional.scaled_dot_product_attention
|
||||
strategy = block.attn.omni_attn._get_active_parallel_strategy()
|
||||
pre_attention, post_attention = strategy.pre_attention, strategy.post_attention
|
||||
|
||||
def prepare(*args):
|
||||
phases.append("pre")
|
||||
return pre_attention(*args)
|
||||
|
||||
def restore(*args):
|
||||
phases.append("post")
|
||||
return post_attention(*args)
|
||||
|
||||
def check_gqa(*args):
|
||||
supported = False if force_gqa_fallback else can_use_fused_gqa(*args)
|
||||
capabilities.append(supported)
|
||||
return supported
|
||||
|
||||
def record_sdpa(query, key, value, **kwargs):
|
||||
phases.append("sdpa")
|
||||
calls.append((query.shape, key.shape, value.shape, query.dtype, kwargs))
|
||||
return sdpa(query, key, value, **kwargs)
|
||||
|
||||
with torch.no_grad(), monkeypatch.context() as patch:
|
||||
patch.setattr(sdpa_backend, "can_sdpa_use_fused_gqa", check_gqa)
|
||||
patch.setattr(torch.nn.functional, "scaled_dot_product_attention", record_sdpa)
|
||||
patch.setattr(strategy, "pre_attention", prepare)
|
||||
patch.setattr(strategy, "post_attention", restore)
|
||||
got = block.attn(hidden, hidden, attention_mask=mask, image_rotary_emb=rotary)
|
||||
|
||||
assert phases == ["pre", "sdpa", "post"], "FP32 must preserve the shared parallel dispatch"
|
||||
assert len(capabilities) == len(calls) == 1, "FP32 must use shared SDPA's runtime GQA check"
|
||||
q_shape, k_shape, v_shape, dtype, kwargs = calls[0]
|
||||
expected_kv_heads = KV_HEADS if capabilities[0] else HEADS
|
||||
assert q_shape == (1, HEADS, seq, block.head_dim)
|
||||
assert k_shape == v_shape == (1, expected_kv_heads, seq, block.head_dim)
|
||||
assert dtype == got.dtype == torch.float32
|
||||
assert kwargs["enable_gqa"] == capabilities[0]
|
||||
assert kwargs["scale"] == block.attn.scale
|
||||
assert kwargs["is_causal"] is False
|
||||
assert torch.equal(kwargs["attn_mask"], mask[:, None, None, :])
|
||||
|
||||
with torch.no_grad():
|
||||
want = _reference_attention(block.attn, hidden, mask, rotary)
|
||||
assert torch.isfinite(got).all()
|
||||
assert torch.count_nonzero(got[~mask]) == 0
|
||||
diff = (got.float() - want.float()).abs()[mask]
|
||||
assert diff.max().item() < 1e-3, diff.max().item()
|
||||
@@ -0,0 +1,392 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig, TransformerConfig
|
||||
from vllm_omni.diffusion.models.mammoth_moda2 import pipeline_mammothmoda2_dit
|
||||
from vllm_omni.diffusion.models.mammoth_moda2.pipeline_mammothmoda2_dit import (
|
||||
MammothModa2DiTPipeline,
|
||||
_build_mammoth_config,
|
||||
_root_weight_source,
|
||||
)
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _raw_config() -> dict:
|
||||
return {
|
||||
"model_type": "mammothmoda2",
|
||||
"llm_config": {
|
||||
"model_type": "mammothmoda2_qwen2_5_vl",
|
||||
"text_config": {
|
||||
"model_type": "mammothmoda2_qwen2_5_vl_text",
|
||||
"hidden_size": 8,
|
||||
"gen_vocab_start_index": 100,
|
||||
},
|
||||
},
|
||||
"gen_vae_config": {"block_out_channels": [8, 8]},
|
||||
"gen_dit_config": {"hidden_size": 8, "in_channels": 4},
|
||||
}
|
||||
|
||||
|
||||
def _od_config() -> OmniDiffusionConfig:
|
||||
return OmniDiffusionConfig(
|
||||
model="/models/MammothModa2-Preview",
|
||||
model_class_name="MammothModa2DiTPipeline",
|
||||
tf_model_config=TransformerConfig.from_dict(_raw_config()),
|
||||
)
|
||||
|
||||
|
||||
def test_build_mammoth_config_uses_shared_transformer_projection() -> None:
|
||||
config = _build_mammoth_config(_od_config())
|
||||
assert config.model_type == "mammothmoda2"
|
||||
assert config.get_text_config().hidden_size == 8
|
||||
assert config.gen_dit_config == {"hidden_size": 8, "in_channels": 4}
|
||||
|
||||
|
||||
def test_build_mammoth_config_rejects_empty_shared_projection() -> None:
|
||||
config = _od_config()
|
||||
config.tf_model_config = TransformerConfig()
|
||||
with pytest.raises(ValueError, match="root checkpoint config"):
|
||||
_build_mammoth_config(config)
|
||||
|
||||
|
||||
def test_root_weight_source_loads_combined_checkpoint_once() -> None:
|
||||
source = _root_weight_source(_od_config())
|
||||
assert source.model_or_path == "/models/MammothModa2-Preview"
|
||||
assert source.subfolder is None
|
||||
assert source.prefix == ""
|
||||
assert source.fall_back_to_pt is True
|
||||
|
||||
|
||||
def test_root_weight_source_forwards_revision() -> None:
|
||||
config = _od_config()
|
||||
config.revision = "rev-7"
|
||||
assert _root_weight_source(config).revision == "rev-7"
|
||||
|
||||
|
||||
def test_pipeline_declares_native_components_and_single_request_mode_only() -> None:
|
||||
assert MammothModa2DiTPipeline._dit_modules == ["gen_transformer"]
|
||||
assert MammothModa2DiTPipeline._encoder_modules == ["gen_image_condition_refiner"]
|
||||
assert MammothModa2DiTPipeline._vae_modules == ["gen_vae"]
|
||||
assert MammothModa2DiTPipeline.supports_request_batch is False
|
||||
assert MammothModa2DiTPipeline.supports_step_execution is False
|
||||
|
||||
|
||||
def test_mammoth_postprocess_denormalizes_nonnegative_raw_vae_output() -> None:
|
||||
factory = getattr(pipeline_mammothmoda2_dit, "get_mammoth_moda2_post_process_func", None)
|
||||
assert factory is not None
|
||||
|
||||
images = factory(_od_config())(torch.zeros(1, 3, 2, 2))
|
||||
|
||||
assert len(images) == 1
|
||||
assert images[0].getpixel((0, 0)) == (128, 128, 128)
|
||||
|
||||
|
||||
def test_mammoth_postprocess_is_registered() -> None:
|
||||
from vllm_omni.diffusion.registry import _DIFFUSION_POST_PROCESS_FUNCS
|
||||
|
||||
assert _DIFFUSION_POST_PROCESS_FUNCS["MammothModa2DiTPipeline"] == "get_mammoth_moda2_post_process_func"
|
||||
|
||||
|
||||
def test_root_weight_source_rejects_missing_model_path() -> None:
|
||||
config = _od_config()
|
||||
config.model = None
|
||||
with pytest.raises(ValueError, match="model path"):
|
||||
_root_weight_source(config)
|
||||
|
||||
|
||||
def _pipeline_shell() -> MammothModa2DiTPipeline:
|
||||
pipeline = object.__new__(MammothModa2DiTPipeline)
|
||||
nn.Module.__init__(pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline.config = _build_mammoth_config(_od_config())
|
||||
pipeline._llm_hidden_size = 8
|
||||
return pipeline
|
||||
|
||||
|
||||
def _batch(
|
||||
*,
|
||||
request_id: str = "req-7",
|
||||
prompt: object | None = None,
|
||||
sampling: OmniDiffusionSamplingParams | None = None,
|
||||
) -> DiffusionRequestBatch:
|
||||
if prompt is None:
|
||||
prompt = {
|
||||
"prompt": "",
|
||||
"height": 32,
|
||||
"width": 48,
|
||||
"additional_information": {
|
||||
"full_hidden_states": torch.arange(32, dtype=torch.float32).reshape(4, 8),
|
||||
"full_token_ids": [10, 11, 100, 101],
|
||||
"answer_start_index": 2,
|
||||
},
|
||||
}
|
||||
if sampling is None:
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
height=32,
|
||||
width=48,
|
||||
seed=42,
|
||||
guidance_scale=4.0,
|
||||
num_inference_steps=7,
|
||||
extra_args={"cfg_range": [0.2, 0.8]},
|
||||
)
|
||||
return DiffusionRequestBatch([OmniDiffusionRequest(prompt=prompt, sampling_params=sampling, request_id=request_id)])
|
||||
|
||||
|
||||
def test_parse_request_uses_standard_sampling_fields() -> None:
|
||||
parsed = _pipeline_shell()._parse_request(_batch())
|
||||
assert parsed.request_id == "req-7"
|
||||
assert (parsed.height, parsed.width) == (32, 48)
|
||||
assert parsed.num_inference_steps == 7
|
||||
assert parsed.text_guidance_scale == 4.0
|
||||
assert parsed.cfg_range == (0.2, 0.8)
|
||||
assert parsed.seed == 42
|
||||
assert parsed.answer_start_index == 2
|
||||
|
||||
|
||||
def test_parse_request_prefers_legacy_sampling_overrides() -> None:
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
guidance_scale=3.0,
|
||||
num_inference_steps=5,
|
||||
seed=9,
|
||||
extra_args={"text_guidance_scale": 6.0, "num_inference_steps": 11, "cfg_range": [0.0, 0.5]},
|
||||
)
|
||||
parsed = _pipeline_shell()._parse_request(_batch(sampling=sampling))
|
||||
assert parsed.text_guidance_scale == 6.0
|
||||
assert parsed.num_inference_steps == 11
|
||||
assert parsed.cfg_range == (0.0, 0.5)
|
||||
|
||||
|
||||
def test_parse_request_falls_back_to_request_level_sampling_values() -> None:
|
||||
prompt = _batch().prompts[0]
|
||||
prompt["additional_information"].update(
|
||||
text_guidance_scale=[1.5],
|
||||
num_inference_steps=[3],
|
||||
cfg_range=[0.25, 0.75],
|
||||
)
|
||||
|
||||
parsed = _pipeline_shell()._parse_request(_batch(prompt=prompt, sampling=OmniDiffusionSamplingParams()))
|
||||
|
||||
assert parsed.text_guidance_scale == 1.5
|
||||
assert parsed.num_inference_steps == 3
|
||||
assert parsed.cfg_range == (0.25, 0.75)
|
||||
|
||||
|
||||
def test_parse_request_standard_fields_precede_request_level_fallbacks() -> None:
|
||||
prompt = _batch().prompts[0]
|
||||
prompt["additional_information"].update(
|
||||
text_guidance_scale=[1.5],
|
||||
num_inference_steps=[3],
|
||||
)
|
||||
sampling = OmniDiffusionSamplingParams(guidance_scale=4.0, num_inference_steps=7)
|
||||
|
||||
parsed = _pipeline_shell()._parse_request(_batch(prompt=prompt, sampling=sampling))
|
||||
|
||||
assert parsed.text_guidance_scale == 4.0
|
||||
assert parsed.num_inference_steps == 7
|
||||
|
||||
|
||||
def test_parse_request_rejects_multiple_requests_and_outputs() -> None:
|
||||
pipeline = _pipeline_shell()
|
||||
batch = _batch()
|
||||
batch.requests.append(_batch(request_id="req-8").requests[0])
|
||||
with pytest.raises(ValueError, match="exactly one request"):
|
||||
pipeline._parse_request(batch)
|
||||
with pytest.raises(ValueError, match="num_outputs_per_prompt=1"):
|
||||
pipeline._parse_request(_batch(sampling=OmniDiffusionSamplingParams(num_outputs_per_prompt=2)))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cfg_range", "message"),
|
||||
[
|
||||
([0.5], "two values"),
|
||||
([-0.1, 0.5], "0 <= start <= end <= 1"),
|
||||
([0.7, 0.2], "0 <= start <= end <= 1"),
|
||||
([0.2, 1.1], "0 <= start <= end <= 1"),
|
||||
],
|
||||
)
|
||||
def test_parse_request_rejects_invalid_cfg_range(cfg_range, message) -> None:
|
||||
batch = _batch(sampling=OmniDiffusionSamplingParams(extra_args={"cfg_range": cfg_range}))
|
||||
with pytest.raises(ValueError, match=message):
|
||||
_pipeline_shell()._parse_request(batch)
|
||||
|
||||
|
||||
def test_parse_request_requires_real_ar_conditions() -> None:
|
||||
with pytest.raises(ValueError, match="req-missing"):
|
||||
_pipeline_shell()._parse_request(_batch(request_id="req-missing", prompt={"prompt": "draw a cat"}))
|
||||
|
||||
|
||||
def test_parse_request_rejects_hidden_state_token_count_mismatch() -> None:
|
||||
batch = _batch()
|
||||
batch.prompts[0]["additional_information"]["full_hidden_states"] = torch.zeros(3, 8)
|
||||
with pytest.raises(ValueError, match="hidden-state/token-count mismatch"):
|
||||
_pipeline_shell()._parse_request(batch)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("height", "width", "message"),
|
||||
[
|
||||
(0, 32, "Invalid image size"),
|
||||
(32, -1, "Invalid image size"),
|
||||
(30, 32, "multiples of 16"),
|
||||
(32, 31, "multiples of 16"),
|
||||
],
|
||||
)
|
||||
def test_parse_request_rejects_invalid_dimensions(height, width, message) -> None:
|
||||
batch = _batch()
|
||||
batch.prompts[0].update(height=height, width=width)
|
||||
with pytest.raises(ValueError, match=message):
|
||||
_pipeline_shell()._parse_request(batch)
|
||||
|
||||
|
||||
def test_parse_request_rejects_explicit_zero_steps() -> None:
|
||||
with pytest.raises(ValueError, match="num_inference_steps must be positive"):
|
||||
_pipeline_shell()._parse_request(_batch(sampling=OmniDiffusionSamplingParams(num_inference_steps=0)))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("standard_steps", "expected_steps"), [(7, 7), (None, 50)])
|
||||
def test_parse_request_falls_through_null_legacy_step_count(standard_steps, expected_steps) -> None:
|
||||
sampling = OmniDiffusionSamplingParams(num_inference_steps=standard_steps, extra_args={"num_inference_steps": None})
|
||||
parsed = _pipeline_shell()._parse_request(_batch(sampling=sampling))
|
||||
assert parsed.num_inference_steps == expected_steps
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("standard_guidance", "expected_guidance"), [(4.0, 4.0), (None, 9.0)])
|
||||
def test_parse_request_falls_through_null_legacy_guidance(standard_guidance, expected_guidance) -> None:
|
||||
sampling = OmniDiffusionSamplingParams(guidance_scale=standard_guidance, extra_args={"text_guidance_scale": None})
|
||||
parsed = _pipeline_shell()._parse_request(_batch(sampling=sampling))
|
||||
assert parsed.text_guidance_scale == expected_guidance
|
||||
|
||||
|
||||
def test_parse_request_defaults_null_cfg_range() -> None:
|
||||
sampling = OmniDiffusionSamplingParams(extra_args={"cfg_range": None})
|
||||
parsed = _pipeline_shell()._parse_request(_batch(sampling=sampling))
|
||||
assert parsed.cfg_range == (0.0, 1.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("generators", [[], [torch.Generator(), torch.Generator()]])
|
||||
def test_parse_request_rejects_invalid_generator_list_cardinality(generators) -> None:
|
||||
sampling = OmniDiffusionSamplingParams(generator=generators)
|
||||
with pytest.raises(ValueError, match="exactly one generator.*req-generators"):
|
||||
_pipeline_shell()._parse_request(_batch(request_id="req-generators", sampling=sampling))
|
||||
|
||||
|
||||
def test_parse_request_synthesizes_dummy_ar_conditions() -> None:
|
||||
batch = _batch(
|
||||
request_id="dummy_req_id",
|
||||
prompt={"prompt": "dummy run"},
|
||||
sampling=OmniDiffusionSamplingParams(height=512, width=512, seed=1, guidance_scale=0.0, num_inference_steps=2),
|
||||
)
|
||||
parsed = _pipeline_shell()._parse_request(batch)
|
||||
assert parsed.full_hidden_states.shape == (2, 8)
|
||||
assert parsed.full_token_ids == [0, 100]
|
||||
assert parsed.answer_start_index == 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeTransformerConfig:
|
||||
in_channels: int = 4
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeTimeCaptionEmbed:
|
||||
image_embedder: nn.Module | None = None
|
||||
|
||||
|
||||
class _FakeTransformer(nn.Module):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.anchor = nn.Parameter(torch.zeros(1))
|
||||
self.config = _FakeTransformerConfig()
|
||||
self.time_caption_embed = _FakeTimeCaptionEmbed()
|
||||
self.calls = 0
|
||||
|
||||
def forward(self, *, hidden_states, **kwargs):
|
||||
self.calls += 1
|
||||
return torch.zeros_like(hidden_states)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeVaeConfig:
|
||||
scaling_factor: float | None = None
|
||||
shift_factor: float | None = None
|
||||
|
||||
|
||||
class _FakeVae(nn.Module):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.anchor = nn.Parameter(torch.zeros(1))
|
||||
self.config = _FakeVaeConfig()
|
||||
|
||||
def decode(self, latents, return_dict=False):
|
||||
assert return_dict is False
|
||||
return (torch.zeros(1, 3, 32, 48, dtype=latents.dtype),)
|
||||
|
||||
|
||||
class _FakeScheduler:
|
||||
def __init__(self) -> None:
|
||||
self.timesteps = torch.tensor([])
|
||||
self.requested_steps = None
|
||||
|
||||
def set_timesteps(self, *, num_inference_steps, device, num_tokens):
|
||||
self.requested_steps = num_inference_steps
|
||||
self.timesteps = torch.arange(num_inference_steps, device=device, dtype=torch.float32)
|
||||
|
||||
def step(self, model_pred, timestep, latents, return_dict=False):
|
||||
assert return_dict is False
|
||||
return (latents - model_pred,)
|
||||
|
||||
|
||||
def test_forward_returns_diffusion_output_with_request_sampling(mocker) -> None:
|
||||
pipeline = _pipeline_shell()
|
||||
pipeline.gen_transformer = _FakeTransformer()
|
||||
pipeline.gen_image_condition_refiner = None
|
||||
pipeline.gen_vae = _FakeVae()
|
||||
pipeline.gen_freqs_cis = torch.zeros(1)
|
||||
scheduler = _FakeScheduler()
|
||||
captured = {}
|
||||
|
||||
def fake_randn_tensor(shape, *, generator, device, dtype):
|
||||
captured["shape"] = shape
|
||||
captured["seed"] = generator.initial_seed()
|
||||
return torch.zeros(shape, device=device, dtype=dtype)
|
||||
|
||||
module = "vllm_omni.diffusion.models.mammoth_moda2.pipeline_mammothmoda2_dit"
|
||||
mocker.patch(f"{module}.FlowMatchEulerDiscreteScheduler", return_value=scheduler)
|
||||
mocker.patch(f"{module}.randn_tensor", side_effect=fake_randn_tensor)
|
||||
result = pipeline.forward(
|
||||
_batch(
|
||||
sampling=OmniDiffusionSamplingParams(
|
||||
height=32, width=48, seed=42, guidance_scale=1.0, num_inference_steps=2
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert isinstance(result, DiffusionOutput)
|
||||
assert result.output.shape == (1, 3, 32, 48)
|
||||
assert captured["shape"] == (1, 4, 4, 6)
|
||||
assert captured["seed"] == 42
|
||||
assert scheduler.requested_steps == 2
|
||||
assert pipeline.gen_transformer.calls == 2
|
||||
|
||||
|
||||
def test_forward_rejects_missing_visual_tokens_before_model_access() -> None:
|
||||
prompt = {
|
||||
"prompt": "",
|
||||
"additional_information": {
|
||||
"full_hidden_states": torch.zeros(3, 8),
|
||||
"full_token_ids": [10, 11, 12],
|
||||
"answer_start_index": 2,
|
||||
},
|
||||
}
|
||||
with pytest.raises(ValueError, match="no visual-token hidden states.*req-empty"):
|
||||
_pipeline_shell().forward(_batch(request_id="req-empty", prompt=prompt))
|
||||
@@ -0,0 +1,679 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec, DiffusionParallelConfig, OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.models.minimax_h3.fasth3_checkpoint import (
|
||||
FASTH3_BASE_MODEL,
|
||||
FASTH3_V2_BASE_SCHEDULE,
|
||||
FASTH3_V2_MODEL_ID,
|
||||
FastH3CheckpointSpec,
|
||||
)
|
||||
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
|
||||
MiniMaxH3DiTArchConfig,
|
||||
MiniMaxH3DiTModel,
|
||||
)
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
|
||||
resolve_minimax_h3_diffusion_model_path,
|
||||
)
|
||||
from vllm_omni.diffusion.sched.sigma_schedule import DMD2SigmaSchedule
|
||||
from vllm_omni.errors import OmniClientError
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.lora.request import LoRARequest
|
||||
from vllm_omni.model_executor.models.minimax_h3.checkpoint import (
|
||||
is_minimax_h3_modular,
|
||||
resolve_minimax_h3_model_root,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _fastvideo_metadata() -> dict[str, object]:
|
||||
"""Return the release-owned metadata read from fastvideo_inference.json."""
|
||||
return {
|
||||
"model_id": FASTH3_V2_MODEL_ID,
|
||||
"schema_version": "fasth3-inference-contract-v1",
|
||||
"dmd_denoising_steps": [999, 874, 749, 624, 500, 375, 250, 125],
|
||||
"transformer_forwards": 8,
|
||||
"video_scheduler_shift": 10.0,
|
||||
"audio_scheduler_shift": 3.0,
|
||||
"guidance_scale": 1.0,
|
||||
"attention_backend": "VIDEO_SPARSE_ATTN_H3",
|
||||
"vsa_sparsity": 0.8,
|
||||
"vsa_tile_size": 64,
|
||||
"task": "t2av",
|
||||
}
|
||||
|
||||
|
||||
def _sampling(
|
||||
*,
|
||||
num_inference_steps: int | str | None = 8,
|
||||
guidance_scale: float | None = 1.0,
|
||||
extra_args: dict[str, object] | None = None,
|
||||
lora_request: LoRARequest | None = None,
|
||||
timesteps: torch.Tensor | None = None,
|
||||
sigmas: list[float] | None = None,
|
||||
) -> OmniDiffusionSamplingParams:
|
||||
return OmniDiffusionSamplingParams(
|
||||
# Keep the string form to exercise malformed client input validation.
|
||||
num_inference_steps=num_inference_steps, # type: ignore[arg-type]
|
||||
guidance_scale=guidance_scale,
|
||||
extra_args=extra_args or {},
|
||||
lora_request=lora_request,
|
||||
timesteps=timesteps,
|
||||
sigmas=sigmas,
|
||||
)
|
||||
|
||||
|
||||
def _od_config(
|
||||
*,
|
||||
backend: str = "FASTVIDEO_VSA",
|
||||
topk: int | None = None,
|
||||
per_role: dict[str, AttentionSpec] | None = None,
|
||||
lora_path: str | None = None,
|
||||
ring_degree: int = 1,
|
||||
allgather_degree: int = 1,
|
||||
) -> OmniDiffusionConfig:
|
||||
attention_config = AttentionConfig(
|
||||
default=AttentionSpec(backend=backend, fastvideo_vsa_topk=topk),
|
||||
per_role=per_role or {},
|
||||
)
|
||||
return OmniDiffusionConfig(
|
||||
diffusion_attention_config=attention_config,
|
||||
lora_path=lora_path,
|
||||
parallel_config=DiffusionParallelConfig(ring_degree=ring_degree, allgather_degree=allgather_degree),
|
||||
)
|
||||
|
||||
|
||||
def test_v2_release_has_nine_sigma_nodes_and_eight_intervals():
|
||||
assert FASTH3_V2_BASE_SCHEDULE.base_schedule == (
|
||||
0.999,
|
||||
0.874,
|
||||
0.749,
|
||||
0.624,
|
||||
0.5,
|
||||
0.375,
|
||||
0.25,
|
||||
0.125,
|
||||
0.0,
|
||||
)
|
||||
assert len(FASTH3_V2_BASE_SCHEDULE.base_schedule) == 9
|
||||
assert FASTH3_V2_BASE_SCHEDULE.num_inference_steps == 8
|
||||
assert FASTH3_V2_BASE_SCHEDULE.shifted_sigmas(10.0) == pytest.approx(
|
||||
[
|
||||
0.9998999099189271,
|
||||
0.985788405143244,
|
||||
0.9675752486758817,
|
||||
0.94316807738815,
|
||||
0.9090909090909091,
|
||||
0.8571428571428571,
|
||||
0.7692307692307693,
|
||||
0.5882352941176471,
|
||||
0.0,
|
||||
],
|
||||
abs=1e-12,
|
||||
)
|
||||
assert FASTH3_V2_BASE_SCHEDULE.shifted_sigmas(3.0) == pytest.approx(
|
||||
[
|
||||
0.9996664442961973,
|
||||
0.9541484716157204,
|
||||
0.8995196156925539,
|
||||
0.8327402135231315,
|
||||
0.75,
|
||||
0.6428571428571429,
|
||||
0.5,
|
||||
0.3,
|
||||
0.0,
|
||||
],
|
||||
abs=1e-12,
|
||||
)
|
||||
|
||||
|
||||
def test_fastvideo_metadata_builds_release_sampling_metadata():
|
||||
spec = FastH3CheckpointSpec.from_metadata(_fastvideo_metadata())
|
||||
|
||||
assert spec.vsa_sparsity == 0.8
|
||||
assert spec.release_metadata() == {
|
||||
"partition": "fl2va",
|
||||
"tasks": ["t2va"],
|
||||
"sigma_shift_scales": {"video": 10.0, "audio": 3.0},
|
||||
"base_schedule": list(FASTH3_V2_BASE_SCHEDULE.base_schedule),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("model_id", "FastVideo/FastH3-other"),
|
||||
("schema_version", "fasth3-inference-contract-v0"),
|
||||
("dmd_denoising_steps", [999, 749, 500, 250]),
|
||||
("transformer_forwards", 4),
|
||||
("video_scheduler_shift", 12.0),
|
||||
("audio_scheduler_shift", 4.0),
|
||||
("guidance_scale", 2.0),
|
||||
("attention_backend", "FLASH_ATTN"),
|
||||
("vsa_sparsity", 0.9),
|
||||
("vsa_tile_size", 32),
|
||||
("task", "t2va"),
|
||||
],
|
||||
)
|
||||
def test_fastvideo_metadata_rejects_contract_drift(field, value):
|
||||
metadata = _fastvideo_metadata()
|
||||
metadata[field] = value
|
||||
|
||||
with pytest.raises(ValueError, match=field):
|
||||
FastH3CheckpointSpec.from_metadata(metadata)
|
||||
|
||||
|
||||
def test_fastvideo_metadata_rejects_missing_contract_field():
|
||||
metadata = _fastvideo_metadata()
|
||||
metadata.pop("task")
|
||||
|
||||
with pytest.raises(ValueError, match="task"):
|
||||
FastH3CheckpointSpec.from_metadata(metadata)
|
||||
|
||||
|
||||
def test_legacy_h3_metadata_is_not_treated_as_fastvideo_release():
|
||||
with pytest.raises(ValueError, match="model_id"):
|
||||
FastH3CheckpointSpec.from_metadata(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"partition": "fl2va",
|
||||
"tasks": ["t2va", "fl2va"],
|
||||
"sigma_shift_scales": {"video": 12.0, "audio": 3.0},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_v2_serving_contract_accepts_backend_selected_for_h3_self_role():
|
||||
config = _od_config(
|
||||
backend="TORCH_SDPA",
|
||||
per_role={"self": AttentionSpec(backend="FASTVIDEO_VSA")},
|
||||
)
|
||||
FastH3CheckpointSpec.from_metadata(_fastvideo_metadata()).check_serving_contract(
|
||||
partition="fl2va", od_config=config
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "partition", "match"),
|
||||
[
|
||||
({}, "ref2va", "task-type fl2va"),
|
||||
({"lora_path": "/tmp/adapter"}, "fl2va", "LoRA"),
|
||||
({"backend": "TORCH_SDPA"}, "fl2va", "FASTVIDEO_VSA"),
|
||||
({"topk": 2}, "fl2va", "fixed fastvideo_vsa_topk"),
|
||||
({"ring_degree": 2}, "fl2va", "local attention"),
|
||||
({"allgather_degree": 2}, "fl2va", "local attention"),
|
||||
],
|
||||
)
|
||||
def test_v2_serving_contract_rejects_incompatible_runtime(kwargs, partition, match):
|
||||
spec = FastH3CheckpointSpec.from_metadata(_fastvideo_metadata())
|
||||
with pytest.raises(ValueError, match=match):
|
||||
spec.check_serving_contract(partition=partition, od_config=_od_config(**kwargs))
|
||||
|
||||
|
||||
def test_v2_serving_contract_reads_a_per_role_fixed_topk_override():
|
||||
spec = FastH3CheckpointSpec.from_metadata(_fastvideo_metadata())
|
||||
config = _od_config(
|
||||
per_role={"self": AttentionSpec(backend="FASTVIDEO_VSA", fastvideo_vsa_topk=1)},
|
||||
)
|
||||
with pytest.raises(ValueError, match="fixed fastvideo_vsa_topk"):
|
||||
spec.check_serving_contract(partition="fl2va", od_config=config)
|
||||
|
||||
|
||||
def test_v2_request_contract_accepts_omitted_steps_and_default_shifts():
|
||||
spec = FastH3CheckpointSpec.from_metadata(_fastvideo_metadata())
|
||||
spec.check_request(_sampling(num_inference_steps=None))
|
||||
spec.check_request(_sampling(extra_args={"flow_shift": 10, "audio_flow_shift": 3}, guidance_scale=1))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sampling",
|
||||
[
|
||||
_sampling(num_inference_steps=None),
|
||||
_sampling(timesteps=torch.tensor([0.9, 0.5, 0.0])),
|
||||
_sampling(sigmas=[1.0, 0.5, 0.0]),
|
||||
],
|
||||
)
|
||||
def test_v2_step_execution_requires_pinned_count_and_schedule(sampling):
|
||||
spec = FastH3CheckpointSpec.from_metadata(_fastvideo_metadata())
|
||||
with pytest.raises(OmniClientError, match="step execution"):
|
||||
spec.check_request(sampling, step_execution=True)
|
||||
|
||||
|
||||
def test_v2_step_execution_accepts_only_explicit_pinned_count():
|
||||
spec = FastH3CheckpointSpec.from_metadata(_fastvideo_metadata())
|
||||
spec.check_request(_sampling(num_inference_steps=8), step_execution=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "match"),
|
||||
[
|
||||
({"num_inference_steps": 7}, "num_inference_steps=8"),
|
||||
({"num_inference_steps": 9}, "num_inference_steps=8"),
|
||||
({"num_inference_steps": "8"}, "num_inference_steps=8"),
|
||||
({"extra_args": {"flow_shift": 9}}, "flow_shift=10"),
|
||||
({"extra_args": {"audio_flow_shift": 4}}, "audio_flow_shift=3"),
|
||||
({"extra_args": {"flow_shift": "bad"}}, "flow_shift=10"),
|
||||
({"guidance_scale": 2.0}, "guidance_scale=1"),
|
||||
(
|
||||
{"lora_request": LoRARequest(lora_name="test", lora_int_id=1, lora_path="/tmp/test.safetensors")},
|
||||
"per-request LoRA",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_v2_request_contract_rejects_sampling_drift(kwargs, match):
|
||||
spec = FastH3CheckpointSpec.from_metadata(_fastvideo_metadata())
|
||||
with pytest.raises(OmniClientError, match=match):
|
||||
spec.check_request(_sampling(**kwargs))
|
||||
|
||||
|
||||
def test_resolve_native_vaes_uses_pinned_revision_and_only_vae_patterns(tmp_path, monkeypatch):
|
||||
model_root = tmp_path / "FastH3"
|
||||
model_root.mkdir()
|
||||
revision = "base-revision-123"
|
||||
(model_root / "provenance.json").write_text(
|
||||
json.dumps({"base_model": f"hf://{FASTH3_BASE_MODEL}@{revision}"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
snapshot = tmp_path / "hf-snapshot"
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
def fake_download(**kwargs: object) -> str:
|
||||
calls.update(kwargs)
|
||||
snapshot.mkdir()
|
||||
return str(snapshot)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.diffusion.models.minimax_h3.fasth3_checkpoint.download_weights_from_hf_specific",
|
||||
fake_download,
|
||||
)
|
||||
|
||||
result = FastH3CheckpointSpec.from_metadata(_fastvideo_metadata()).resolve_native_vaes(model_root)
|
||||
|
||||
assert result == snapshot / "FL2VA"
|
||||
assert calls == {
|
||||
"model_name_or_path": FASTH3_BASE_MODEL,
|
||||
"cache_dir": None,
|
||||
"allow_patterns": ["FL2VA/video_vae/**", "FL2VA/audio_vae/**"],
|
||||
"revision": revision,
|
||||
"require_all": True,
|
||||
}
|
||||
assert sorted(path.name for path in model_root.iterdir()) == ["provenance.json"]
|
||||
assert not (model_root / "model_index.json").exists()
|
||||
assert not (model_root / "conversion.json").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"base_model",
|
||||
[
|
||||
"MiniMaxAI/MiniMax-H3@main",
|
||||
"hf://MiniMaxAI/MiniMax-H3@",
|
||||
"hf://Other/MiniMax-H3@revision",
|
||||
"",
|
||||
None,
|
||||
],
|
||||
)
|
||||
def test_resolve_native_vaes_rejects_unpinned_provenance(tmp_path, base_model):
|
||||
model_root = tmp_path / "FastH3"
|
||||
model_root.mkdir()
|
||||
(model_root / "provenance.json").write_text(json.dumps({"base_model": base_model}), encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="provenance"):
|
||||
FastH3CheckpointSpec.from_metadata(_fastvideo_metadata()).resolve_native_vaes(model_root)
|
||||
|
||||
|
||||
def test_modular_local_model_root_uses_text_encoder_without_partition_files(tmp_path):
|
||||
root = tmp_path / "FastH3"
|
||||
(root / "text_encoder").mkdir(parents=True)
|
||||
(root / "text_encoder" / "config.json").write_text("{}", encoding="utf-8")
|
||||
(root / "modular_model_index.json").write_text(
|
||||
json.dumps({"_class_name": "MiniMaxH3ModularPipeline"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert is_minimax_h3_modular(str(root)) is True
|
||||
assert resolve_minimax_h3_model_root(str(root), None, "fl2va") == str(root / "text_encoder")
|
||||
assert not (root / "FL2VA").exists()
|
||||
|
||||
|
||||
def test_modular_hub_model_root_downloads_only_text_encoder(monkeypatch, tmp_path):
|
||||
from vllm_omni.model_executor.models.minimax_h3 import checkpoint as checkpoint_module
|
||||
|
||||
snapshot = tmp_path / "modular-snapshot"
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
def fake_index(model: str, *, revision: str | None = None) -> dict[str, str]:
|
||||
assert model == "FastVideo/FastVideo-FastH3-8-Step-V2"
|
||||
assert revision == "release-revision"
|
||||
return {"_class_name": "MiniMaxH3ModularPipeline"}
|
||||
|
||||
def fake_download(**kwargs: object) -> str:
|
||||
calls.update(kwargs)
|
||||
snapshot.mkdir()
|
||||
return str(snapshot)
|
||||
|
||||
monkeypatch.setattr(checkpoint_module, "get_diffusion_model_index", fake_index)
|
||||
monkeypatch.setattr(checkpoint_module, "download_weights_from_hf_specific", fake_download)
|
||||
|
||||
result = resolve_minimax_h3_model_root(
|
||||
"FastVideo/FastVideo-FastH3-8-Step-V2",
|
||||
"release-revision",
|
||||
"fl2va",
|
||||
)
|
||||
|
||||
assert result == str(snapshot / "text_encoder")
|
||||
assert calls == {
|
||||
"model_name_or_path": "FastVideo/FastVideo-FastH3-8-Step-V2",
|
||||
"cache_dir": None,
|
||||
"allow_patterns": ["text_encoder/**"],
|
||||
"revision": "release-revision",
|
||||
"require_all": True,
|
||||
}
|
||||
|
||||
|
||||
def test_native_hub_model_with_modular_class_keeps_partition_layout(monkeypatch):
|
||||
from vllm_omni.model_executor.models.minimax_h3 import checkpoint as checkpoint_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
checkpoint_module,
|
||||
"get_diffusion_model_index",
|
||||
lambda _model, *, revision=None: {"_class_name": "MiniMaxH3ModularPipeline"},
|
||||
)
|
||||
|
||||
assert checkpoint_module.is_minimax_h3_modular("MiniMaxAI/MiniMax-H3") is False
|
||||
|
||||
|
||||
def test_native_local_model_root_keeps_partition_layout(tmp_path):
|
||||
root = tmp_path / "MiniMax-H3"
|
||||
(root / "FL2VA" / "text_encoder").mkdir(parents=True)
|
||||
(root / "FL2VA" / "text_encoder" / "config.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
assert is_minimax_h3_modular(str(root)) is False
|
||||
assert resolve_minimax_h3_model_root(str(root), None, "fl2va") == str(root / "FL2VA" / "text_encoder")
|
||||
|
||||
|
||||
def test_diffusion_resolver_returns_local_modular_root_without_partition_files(tmp_path):
|
||||
root = tmp_path / "FastH3"
|
||||
root.mkdir()
|
||||
(root / "modular_model_index.json").write_text(
|
||||
json.dumps({"_class_name": "MiniMaxH3ModularPipeline"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert resolve_minimax_h3_diffusion_model_path(str(root), None, "fl2va") == str(root)
|
||||
assert not (root / "FL2VA").exists()
|
||||
assert not (root / "Ref2VA").exists()
|
||||
|
||||
|
||||
def test_diffusion_resolver_hub_modular_downloads_transformer_contract_only(monkeypatch, tmp_path):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as pipeline_module
|
||||
|
||||
snapshot = tmp_path / "modular-snapshot"
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
def fake_modular(_model: str, _revision: str | None = None) -> bool:
|
||||
return True
|
||||
|
||||
def fake_download(**kwargs: object) -> str:
|
||||
calls.update(kwargs)
|
||||
snapshot.mkdir()
|
||||
return str(snapshot)
|
||||
|
||||
monkeypatch.setattr(pipeline_module, "is_minimax_h3_modular", fake_modular)
|
||||
monkeypatch.setattr(pipeline_module, "download_weights_from_hf_specific", fake_download)
|
||||
|
||||
result = resolve_minimax_h3_diffusion_model_path(
|
||||
"FastVideo/FastVideo-FastH3-8-Step-V2", "release-revision", "fl2va"
|
||||
)
|
||||
|
||||
assert result == str(snapshot)
|
||||
assert calls == {
|
||||
"model_name_or_path": "FastVideo/FastVideo-FastH3-8-Step-V2",
|
||||
"cache_dir": None,
|
||||
"allow_patterns": [
|
||||
"modular_model_index.json",
|
||||
"fastvideo_inference.json",
|
||||
"provenance.json",
|
||||
"transformer/**",
|
||||
],
|
||||
"revision": "release-revision",
|
||||
"require_all": True,
|
||||
}
|
||||
patterns = calls["allow_patterns"]
|
||||
assert isinstance(patterns, list)
|
||||
assert all("video_vae" not in pattern and "audio_vae" not in pattern for pattern in patterns)
|
||||
|
||||
|
||||
def test_diffusers_arch_config_normalizes_all_aliases():
|
||||
arch = MiniMaxH3DiTArchConfig.from_mapping(
|
||||
{
|
||||
"num_refiner_layers": 3,
|
||||
"ffn_dim": 12,
|
||||
"in_channels": 4,
|
||||
"audio_in_channels": 6,
|
||||
"freq_dim": 8,
|
||||
"time_embed_hidden_dim": 10,
|
||||
"rope_freq_dim": 5,
|
||||
"patch_size": [1, 2, 2],
|
||||
}
|
||||
)
|
||||
|
||||
assert arch.token_refiner_num_layers == 3
|
||||
assert arch.ffn_hidden_size == 12
|
||||
assert arch.latents_dim == 4
|
||||
assert arch.audio_latents_dim == 6
|
||||
assert arch.timestep_input_dim == 8
|
||||
assert arch.time_embed_hidden_size == 10
|
||||
assert arch.rope_inv_freq_len == 5
|
||||
assert arch.patch_size == (1, 2, 2)
|
||||
|
||||
|
||||
class _WeightTarget(nn.Module):
|
||||
def __init__(self, shape: tuple[int, ...], loader: Callable[..., None]) -> None:
|
||||
super().__init__()
|
||||
self.weight = nn.Parameter(torch.zeros(shape))
|
||||
self.weight.weight_loader = loader
|
||||
|
||||
|
||||
class _PipelineLoadTransformer(nn.Module):
|
||||
def __init__(self, loaded: set[str]) -> None:
|
||||
super().__init__()
|
||||
self.arch = MiniMaxH3DiTArchConfig(num_layers=1)
|
||||
self._loaded = loaded
|
||||
|
||||
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
||||
list(weights)
|
||||
return self._loaded
|
||||
|
||||
def post_load_weights(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_pipeline_load_weights_rejects_missing_fast_h3_compression_gate():
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
nn.Module.__init__(pipeline)
|
||||
pipeline.transformer = _PipelineLoadTransformer({"blocks.0.attn.qkv_proj.weight"})
|
||||
pipeline.video_vae = None
|
||||
pipeline.audio_vae = None
|
||||
pipeline._fasth3 = None
|
||||
pipeline._fasth3_checkpoint = FastH3CheckpointSpec.from_metadata(_fastvideo_metadata())
|
||||
|
||||
with pytest.raises(ValueError, match="missing compression gates"):
|
||||
pipeline.load_weights([("transformer.blocks.0.attn.qkv_proj.weight", torch.zeros(1))])
|
||||
|
||||
|
||||
def _load_fixture(
|
||||
*, diffusers_weights: bool
|
||||
) -> tuple[MiniMaxH3DiTModel, list[tuple[str | None, torch.Tensor]], list[tuple[int, torch.Tensor]]]:
|
||||
model = object.__new__(MiniMaxH3DiTModel)
|
||||
nn.Module.__init__(model)
|
||||
model.arch = MiniMaxH3DiTArchConfig(
|
||||
num_layers=1,
|
||||
hidden_size=3,
|
||||
num_attention_heads=2,
|
||||
attention_head_dim=2,
|
||||
ffn_hidden_size=6,
|
||||
rope_inv_freq_len=2,
|
||||
)
|
||||
model._diffusers_weights = diffusers_weights
|
||||
model._rope_theta = 10000.0
|
||||
qkv_calls: list[tuple[str | None, torch.Tensor]] = []
|
||||
fc1_calls: list[tuple[int, torch.Tensor]] = []
|
||||
|
||||
def qkv_loader(param: nn.Parameter, loaded_weight: torch.Tensor, shard_id: str | None = None) -> None:
|
||||
if shard_id is None:
|
||||
assert loaded_weight.shape == (12, 3)
|
||||
param.data.copy_(loaded_weight)
|
||||
qkv_calls.append((None, loaded_weight.clone()))
|
||||
return
|
||||
assert loaded_weight.shape == (4, 3)
|
||||
assert shard_id in {"q", "k", "v"}
|
||||
offset = {"q": 0, "k": 4, "v": 8}[shard_id]
|
||||
param.data[offset : offset + 4].copy_(loaded_weight)
|
||||
qkv_calls.append((shard_id, loaded_weight.clone()))
|
||||
|
||||
def fc1_loader(param: nn.Parameter, loaded_weight: torch.Tensor, shard_id: int) -> None:
|
||||
assert loaded_weight.shape == (3, 3)
|
||||
param.data[shard_id * 3 : (shard_id + 1) * 3].copy_(loaded_weight)
|
||||
fc1_calls.append((shard_id, loaded_weight.clone()))
|
||||
|
||||
model.blocks = nn.ModuleList([nn.Module()])
|
||||
model.blocks[0].attn = nn.Module()
|
||||
model.blocks[0].attn.qkv_proj = _WeightTarget((12, 3), qkv_loader)
|
||||
model.blocks[0].mlp = nn.Module()
|
||||
model.blocks[0].mlp.fc1 = _WeightTarget((6, 3), fc1_loader)
|
||||
model.rope = nn.Module()
|
||||
model.rope.register_buffer("inv_freq", torch.zeros(2))
|
||||
return model, qkv_calls, fc1_calls
|
||||
|
||||
|
||||
def test_diffusers_load_weights_uses_shape_aware_qkv_and_mlp_slices():
|
||||
model, qkv_calls, fc1_calls = _load_fixture(diffusers_weights=True)
|
||||
q = torch.arange(12, dtype=torch.float32).reshape(4, 3) + 10
|
||||
k = torch.arange(12, dtype=torch.float32).reshape(4, 3) + 100
|
||||
v = torch.arange(12, dtype=torch.float32).reshape(4, 3) + 200
|
||||
mlp = torch.arange(18, dtype=torch.float32).reshape(6, 3) + 300
|
||||
|
||||
loaded = model.load_weights(
|
||||
[
|
||||
("transformer_blocks.0.attn.to_q.weight", q),
|
||||
("transformer_blocks.0.attn.to_k.weight", k),
|
||||
("transformer_blocks.0.attn.to_v.weight", v),
|
||||
("transformer_blocks.0.ff.net.0.proj.weight", mlp),
|
||||
]
|
||||
)
|
||||
|
||||
assert loaded == {
|
||||
"blocks.0.attn.qkv_proj.weight",
|
||||
"blocks.0.mlp.fc1.weight",
|
||||
"rope.inv_freq",
|
||||
}
|
||||
assert [shard_id for shard_id, _ in qkv_calls] == ["q", "k", "v"]
|
||||
assert [shard_id for shard_id, _ in fc1_calls] == [0, 1]
|
||||
torch.testing.assert_close(model.blocks[0].attn.qkv_proj.weight, torch.cat((q, k, v)))
|
||||
torch.testing.assert_close(model.blocks[0].mlp.fc1.weight, torch.cat((mlp[3:], mlp[:3])))
|
||||
torch.testing.assert_close(
|
||||
model.rope.inv_freq,
|
||||
1.0 / (10000.0 ** (torch.arange(0, 4, 2, dtype=torch.float32) / 4)),
|
||||
rtol=0,
|
||||
atol=0,
|
||||
)
|
||||
|
||||
|
||||
def test_native_load_weights_keeps_grouped_qkv_and_gate_first_layouts():
|
||||
model, qkv_calls, fc1_calls = _load_fixture(diffusers_weights=False)
|
||||
grouped_qkv = torch.arange(36, dtype=torch.float32).reshape(12, 3)
|
||||
fc1 = torch.arange(18, dtype=torch.float32).reshape(6, 3)
|
||||
|
||||
loaded = model.load_weights(
|
||||
[
|
||||
("blocks.0.attn.qkv_proj.weight", grouped_qkv),
|
||||
("blocks.0.mlp.fc1.weight", fc1),
|
||||
]
|
||||
)
|
||||
|
||||
expected_qkv = torch.cat(
|
||||
(
|
||||
grouped_qkv[:2],
|
||||
grouped_qkv[6:8],
|
||||
grouped_qkv[2:4],
|
||||
grouped_qkv[8:10],
|
||||
grouped_qkv[4:6],
|
||||
grouped_qkv[10:],
|
||||
)
|
||||
)
|
||||
assert loaded == {"blocks.0.attn.qkv_proj.weight", "blocks.0.mlp.fc1.weight"}
|
||||
assert [shard_id for shard_id, _ in qkv_calls] == [None]
|
||||
assert [shard_id for shard_id, _ in fc1_calls] == [0, 1]
|
||||
torch.testing.assert_close(model.blocks[0].attn.qkv_proj.weight, expected_qkv)
|
||||
torch.testing.assert_close(model.blocks[0].mlp.fc1.weight, fc1)
|
||||
assert torch.equal(model.rope.inv_freq, torch.zeros(2))
|
||||
|
||||
|
||||
def test_diffusers_load_weights_rejects_unknown_name():
|
||||
model, _, _ = _load_fixture(diffusers_weights=True)
|
||||
|
||||
with pytest.raises(ValueError, match="unsupported Diffusers H3 weight"):
|
||||
model.load_weights([("transformer_blocks.0.attn.not_a_projection.weight", torch.zeros(4, 3))])
|
||||
|
||||
|
||||
def test_diffusers_load_weights_rejects_duplicate_source_name():
|
||||
model, _, _ = _load_fixture(diffusers_weights=True)
|
||||
weight = torch.zeros(4, 3)
|
||||
|
||||
with pytest.raises(ValueError, match="duplicate Diffusers H3 weight"):
|
||||
model.load_weights(
|
||||
[
|
||||
("transformer_blocks.0.attn.to_q.weight", weight),
|
||||
("transformer_blocks.0.attn.to_q.weight", weight),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_diffusers_load_weights_rejects_incomplete_qkv_group():
|
||||
model, _, _ = _load_fixture(diffusers_weights=True)
|
||||
|
||||
with pytest.raises(ValueError, match="incomplete Diffusers H3 QKV"):
|
||||
model.load_weights(
|
||||
[
|
||||
("transformer_blocks.0.attn.to_q.weight", torch.zeros(4, 3)),
|
||||
("transformer_blocks.0.attn.to_k.weight", torch.zeros(4, 3)),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_diffusers_load_weights_rejects_missing_compression_gate_parameter():
|
||||
model, _, _ = _load_fixture(diffusers_weights=True)
|
||||
|
||||
with pytest.raises(ValueError, match="has no model parameter"):
|
||||
model.load_weights([("transformer_blocks.0.attn.to_gate_compress.weight", torch.zeros(4, 3))])
|
||||
|
||||
|
||||
def test_resolve_sigma_positions_uses_release_metadata_schedule():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
nn.Module.__init__(pipeline)
|
||||
pipeline._fasth3 = None
|
||||
pipeline._lora_sigma_schedules = {}
|
||||
spec = FastH3CheckpointSpec.from_metadata(_fastvideo_metadata())
|
||||
pipeline._base_schedule_by_partition = {
|
||||
"fl2va": DMD2SigmaSchedule.from_metadata(spec.release_metadata()),
|
||||
}
|
||||
|
||||
positions, steps = pipeline._resolve_sigma_positions("t2va", _sampling())
|
||||
assert positions == FASTH3_V2_BASE_SCHEDULE.base_schedule
|
||||
assert steps == 8 == len(positions) - 1
|
||||
with pytest.raises(OmniClientError, match="must be 8"):
|
||||
pipeline._resolve_sigma_positions("t2va", _sampling(num_inference_steps=9))
|
||||
@@ -0,0 +1,158 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
import torch.multiprocessing as mp
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def _collective_worker(rank, rendezvous, backend):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
from vllm_omni.errors import OmniClientError
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.model_executor.models.minimax_h3.conditioning import MiniMaxH3EncoderMediaInput
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
torch.set_num_threads(1)
|
||||
device = torch.device("cuda", rank) if backend == "nccl" else torch.device("cpu")
|
||||
if backend == "nccl":
|
||||
current_omni_platform.set_device(device)
|
||||
dist.init_process_group(
|
||||
backend, init_method=f"file://{rendezvous}", rank=rank, world_size=2, timeout=timedelta(seconds=45)
|
||||
)
|
||||
try:
|
||||
module._dit_rank_world = lambda: (dist.group.WORLD, rank, 2)
|
||||
|
||||
class VisualCodec:
|
||||
fail = False
|
||||
|
||||
def is_distributed_enabled(self):
|
||||
return True
|
||||
|
||||
def collective(self):
|
||||
value = torch.ones(1, device=device)
|
||||
dist.all_reduce(value)
|
||||
assert value.item() == 2
|
||||
|
||||
def encode_image(self, image):
|
||||
self.collective()
|
||||
assert image.size == (32, 32)
|
||||
return torch.ones(1, 96)
|
||||
|
||||
def encode_video(self, frames):
|
||||
self.collective()
|
||||
assert frames.shape == (4, 32, 32, 3)
|
||||
if self.fail and rank == 1:
|
||||
raise ValueError("codec failed on rank 1")
|
||||
return torch.full((1, 96), 2.0), (1, 2, 2)
|
||||
|
||||
class AudioCodec:
|
||||
def encode_waveform(self, waveform, sample_rate):
|
||||
assert rank == 0
|
||||
assert sample_rate == 32000
|
||||
assert waveform.shape == (64000,)
|
||||
return torch.full((160, 32), 3.0), 80
|
||||
|
||||
model = object.__new__(module.MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(model)
|
||||
model.device = device
|
||||
model.od_config = SimpleNamespace(enable_layerwise_offload=False)
|
||||
model.video_vae = VisualCodec()
|
||||
model.audio_vae = AudioCodec()
|
||||
model.partition = "combined"
|
||||
model.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"})
|
||||
model.text_encoder_tp_size = 2
|
||||
model.text_encoder_group = SimpleNamespace(
|
||||
world_size=2, ranks=[0, 1], rank_in_group=rank, device_group=dist.group.WORLD
|
||||
)
|
||||
model.processor = SimpleNamespace(image_processor=SimpleNamespace(merge_size=2))
|
||||
model.tokenizer = lambda text, **kwargs: {"input_ids": [1, 2, 3]}
|
||||
|
||||
def encode_text(ids, kwargs):
|
||||
assert ids.tolist() == [1, 2, 3]
|
||||
assert not kwargs
|
||||
hidden = torch.ones(3, 5120, device=device, dtype=torch.bfloat16)
|
||||
dist.all_reduce(hidden)
|
||||
return hidden
|
||||
|
||||
model._encode_text_hidden = encode_text
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
height=32, width=32, num_frames=96, extra_args={"task": "t2va", "aspect_ratio": "1:1"}
|
||||
)
|
||||
text_conditioning, window_text = model._prepare_local_conditioning({"prompt": "test"}, sampling)
|
||||
assert window_text is None
|
||||
torch.testing.assert_close(text_conditioning.hidden_states, torch.full_like(text_conditioning.hidden_states, 2))
|
||||
assert text_conditioning.visual_condition is None
|
||||
|
||||
media = (
|
||||
MiniMaxH3EncoderMediaInput(
|
||||
task="ref2va",
|
||||
height=32,
|
||||
width=32,
|
||||
num_frames=107,
|
||||
latent_t=32,
|
||||
audio_t=178,
|
||||
images=(torch.zeros(32, 32, 3, dtype=torch.uint8),),
|
||||
videos=(torch.zeros(4, 32, 32, 3, dtype=torch.uint8),),
|
||||
video_audios=(None,),
|
||||
audios=((torch.zeros(64000), 32000),),
|
||||
)
|
||||
if rank == 0
|
||||
else None
|
||||
)
|
||||
conditioning = model._encode_local_media(media)
|
||||
assert [block["kind"] for block in conditioning.ref_blocks] == ["image", "video", "audio"]
|
||||
torch.testing.assert_close(
|
||||
conditioning.visual_condition.cpu(), torch.cat([torch.ones(1, 96), torch.full((1, 96), 2.0)])
|
||||
)
|
||||
torch.testing.assert_close(conditioning.audio_condition.cpu(), torch.full((160, 32), 3.0))
|
||||
assert conditioning.audio_condition_lengths == (80,)
|
||||
|
||||
model.video_vae.fail = True
|
||||
with pytest.raises(OmniClientError, match="codec failed on rank 1"):
|
||||
model._encode_local_media(media)
|
||||
with pytest.raises(OmniClientError, match="non-empty prompt"):
|
||||
model._prepare_local_conditioning({"prompt": ""}, sampling)
|
||||
model.video_vae.fail = False
|
||||
assert model._encode_local_media(media).visual_condition_shapes == ((1, 2, 2), (1, 2, 2))
|
||||
finally:
|
||||
dist.destroy_process_group()
|
||||
|
||||
|
||||
def _run_collectives(tmp_path, backend):
|
||||
context = mp.spawn(_collective_worker, args=(str(tmp_path / "rendezvous"), backend), nprocs=2, join=False)
|
||||
deadline = time.monotonic() + 150
|
||||
try:
|
||||
while not context.join(timeout=1):
|
||||
if time.monotonic() > deadline:
|
||||
pytest.fail("MiniMax H3 collectives did not finish within 150 seconds")
|
||||
finally:
|
||||
for process in context.processes:
|
||||
if process.is_alive():
|
||||
process.terminate()
|
||||
process.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_two_rank_conditioning_and_errors_gloo(tmp_path):
|
||||
_run_collectives(tmp_path, "gloo")
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.parallel
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("VLLM_TEST_MINIMAX_H3_COLLECTIVES") != "1",
|
||||
reason="set VLLM_TEST_MINIMAX_H3_COLLECTIVES=1 with two reserved GPUs",
|
||||
)
|
||||
def test_two_rank_conditioning_and_errors_nccl(tmp_path):
|
||||
if torch.accelerator.device_count() < 2:
|
||||
pytest.skip("requires two GPUs")
|
||||
_run_collectives(tmp_path, "nccl")
|
||||
@@ -0,0 +1,231 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3.continuation import (
|
||||
diffuse_continuation,
|
||||
plan_continuation_windows,
|
||||
resolve_continuation,
|
||||
)
|
||||
from vllm_omni.diffusion.models.minimax_h3.packed_sequence import minimax_h3_packed_sequence_ref2va_blocks
|
||||
from vllm_omni.errors import OmniClientError
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("vary_prompts", [False, True])
|
||||
@pytest.mark.parametrize("total,window", [(1807, 277), (1807, 243), (124, 277)])
|
||||
def test_continuation_retains_prefix_and_global_stereo_timeline(total, window, vary_prompts):
|
||||
audio_t = round(total * 40 / 24)
|
||||
source = torch.arange(2 * audio_t * 32).reshape(2, audio_t, 32).float()
|
||||
calls = []
|
||||
plan = plan_continuation_windows(total, window, 22)
|
||||
texts = [(torch.full((3 + i, 8), float(i)), torch.ones(3 + i, dtype=torch.long)) for i in range(len(plan))]
|
||||
|
||||
def sample(**kw):
|
||||
calls.append(kw)
|
||||
t = kw["latent_t"]
|
||||
if vary_prompts:
|
||||
assert kw["text_embeddings"] is texts[len(calls) - 1][0]
|
||||
assert kw["text_tags"] is texts[len(calls) - 1][1]
|
||||
assert kw["media_time_origin"] == len(texts[-1][0])
|
||||
# Different constants expose replacement of an old prefix or an
|
||||
# accidental append of the hidden overlap.
|
||||
video = torch.full((1, 24, t, 2, 2), float(len(calls)))
|
||||
audio = kw["locked_audio_rows"].reshape(2, kw["audio_t"], 32).permute(0, 2, 1)
|
||||
if len(calls) > 1:
|
||||
assert torch.all(kw["visual_condition"] == len(calls) - 1)
|
||||
assert kw["ref_blocks"][-1]["kind"] == "latent_guide"
|
||||
return video, audio
|
||||
|
||||
kwargs = dict(
|
||||
num_frames=total,
|
||||
latent_t=(total - 5) // 17 * 5 + 2,
|
||||
latent_h=2,
|
||||
latent_w=2,
|
||||
audio_t=audio_t,
|
||||
locked_audio_rows=source.reshape(-1, 32),
|
||||
)
|
||||
video, audio = diffuse_continuation(
|
||||
sample, kwargs, window_frames=window, overlap_frames=22, text_conditioning=texts if vary_prompts else None
|
||||
)
|
||||
torch.testing.assert_close(audio, source.permute(0, 2, 1))
|
||||
plan = plan_continuation_windows(total, window, 22)
|
||||
assert [call["temporal_offset"] for call in calls] == pytest.approx([part.start * 40 / 24 for part in plan])
|
||||
offset = 0
|
||||
for i, part in enumerate(plan):
|
||||
new_frames = part.end - part.start - part.overlap
|
||||
count = (new_frames - 5) // 17 * 5 + 2 if i == 0 else new_frames // 17 * 5
|
||||
assert torch.all(video[:, :, offset : offset + count] == i + 1)
|
||||
offset += count
|
||||
assert offset == video.shape[2] == kwargs["latent_t"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("temporal_offset", [0.0, 425.0, 221 * 40 / 24])
|
||||
def test_latent_guide_shares_target_clock_and_remains_condition_only(temporal_offset):
|
||||
packed = minimax_h3_packed_sequence_ref2va_blocks(
|
||||
temporal_offset=temporal_offset,
|
||||
text_len=3,
|
||||
latent_t=12,
|
||||
latent_h=4,
|
||||
latent_w=4,
|
||||
audio_t=68,
|
||||
ref_blocks=[
|
||||
{"kind": "image", "latent_h": 4, "latent_w": 4},
|
||||
{"kind": "latent_guide", "latent_t": 7, "latent_h": 4, "latent_w": 4, "ref_audio_t": 37},
|
||||
],
|
||||
)
|
||||
pos = packed["img_position_ids"]
|
||||
visual = packed["img_pos"]
|
||||
target = visual[packed["update_mask"]]
|
||||
guide = visual[~packed["update_mask"]][4:]
|
||||
torch.testing.assert_close(pos[guide], pos[target[:28]])
|
||||
audio = packed["audio_pos"]
|
||||
guide_audio = audio[~packed["audio_update_mask"]].reshape(2, 37)
|
||||
target_audio = audio[packed["audio_update_mask"]].reshape(2, 68)
|
||||
torch.testing.assert_close(pos[guide_audio], pos[target_audio[:, :37]])
|
||||
assert pos[target[0], 0] == pytest.approx(4 + temporal_offset)
|
||||
torch.testing.assert_close(pos[:3, 0], torch.arange(3, dtype=torch.float64))
|
||||
assert torch.all(pos[3:7, 0] == 3) # static reference image stays fixed
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extra,step",
|
||||
[
|
||||
({"long_video_mode": "bad"}, False),
|
||||
({"long_video_mode": "continuation"}, True),
|
||||
({"long_video_mode": "continuation", "continuation_overlap_frames": 23}, False),
|
||||
({"long_video_mode": "continuation", "continuation_window_frames": True}, False),
|
||||
({"long_video_mode": "continuation", "continuation_overlap_frames": 277}, False),
|
||||
],
|
||||
)
|
||||
def test_invalid_continuation_options(extra, step):
|
||||
with pytest.raises(OmniClientError):
|
||||
resolve_continuation(extra, task="ref2va", step_execution=step)
|
||||
|
||||
|
||||
def test_global_offset_moves_temporal_references_but_not_images_or_padding():
|
||||
kwargs = dict(
|
||||
text_len=3,
|
||||
latent_t=12,
|
||||
latent_h=4,
|
||||
latent_w=4,
|
||||
audio_t=68,
|
||||
ref_blocks=[
|
||||
{"kind": "image", "latent_h": 4, "latent_w": 4},
|
||||
{"kind": "audio", "ref_audio_t": 3},
|
||||
{"kind": "video_audio", "ref_audio_t": 2, "latent_t": 2, "latent_h": 4, "latent_w": 4},
|
||||
{"kind": "image", "latent_h": 4, "latent_w": 4},
|
||||
{"kind": "latent_guide", "ref_audio_t": 37, "latent_t": 7, "latent_h": 4, "latent_w": 4},
|
||||
],
|
||||
)
|
||||
base = minimax_h3_packed_sequence_ref2va_blocks(**kwargs)
|
||||
shifted = minimax_h3_packed_sequence_ref2va_blocks(**kwargs, temporal_offset=425.0)
|
||||
moving = base["image_mask"] | base["audio_mask"]
|
||||
moving[3:7] = False # first static image
|
||||
moving[25:29] = False # static image after temporal references
|
||||
delta = shifted["img_position_ids"] - base["img_position_ids"]
|
||||
torch.testing.assert_close(delta[:, 0], moving.double() * 425)
|
||||
assert torch.count_nonzero(delta[:, 1:]) == 0
|
||||
for key, value in base.items():
|
||||
if isinstance(value, torch.Tensor) and key != "img_position_ids":
|
||||
torch.testing.assert_close(shifted[key], value)
|
||||
|
||||
|
||||
def test_long_ref2va_defaults_to_continuation_with_explicit_full_opt_out():
|
||||
assert resolve_continuation({"long_video": True}, task="ref2va", step_execution=False) == (277, 22)
|
||||
assert resolve_continuation({}, task="ref2va", step_execution=False) is None
|
||||
assert (
|
||||
resolve_continuation({"long_video": True, "long_video_mode": "full"}, task="ref2va", step_execution=False)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_different_chunk_prompt_lengths_keep_the_same_media_clock():
|
||||
kwargs = dict(
|
||||
latent_t=7,
|
||||
latent_h=4,
|
||||
latent_w=4,
|
||||
audio_t=37,
|
||||
ref_blocks=[{"kind": "image", "latent_h": 4, "latent_w": 4}],
|
||||
media_time_origin=20,
|
||||
)
|
||||
first = minimax_h3_packed_sequence_ref2va_blocks(text_len=3, **kwargs)
|
||||
second = minimax_h3_packed_sequence_ref2va_blocks(text_len=15, temporal_offset=425, **kwargs)
|
||||
for key, mask in (("img_pos", "update_mask"), ("audio_pos", "audio_update_mask")):
|
||||
a = first["img_position_ids"][first[key][first[mask]]]
|
||||
b = second["img_position_ids"][second[key][second[mask]]]
|
||||
torch.testing.assert_close(b[:, 0] - a[:, 0], torch.full_like(a[:, 0], 425))
|
||||
torch.testing.assert_close(b[:, 1:], a[:, 1:])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prompt_count", [None, 7, 6])
|
||||
def test_request_encodes_only_window_prompts_and_prepares_references_once(monkeypatch, prompt_count):
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.model_executor.models.minimax_h3.conditioning import MiniMaxH3EncoderMediaConditioning
|
||||
|
||||
pipeline = object.__new__(module.MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.load_text_encoder = True
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline.od_config = None
|
||||
pipeline._active_turbo_spec = lambda sampling: None
|
||||
pipeline._has_active_native_lora = lambda sampling: False
|
||||
pipeline._resolve_task = lambda *args, **kwargs: "ref2va"
|
||||
encoded, prepared_inputs, encoded_media = [], [], []
|
||||
prepare = module.prepare_encoder_inputs
|
||||
|
||||
def prepare_once(*args, **kwargs):
|
||||
result = prepare(*args, **kwargs)
|
||||
prepared_inputs.append(result)
|
||||
return result
|
||||
|
||||
def encode_text(prepared):
|
||||
encoded.append(prepared)
|
||||
return torch.full((3, 5120), len(encoded), dtype=torch.bfloat16), torch.zeros(3, dtype=torch.long)
|
||||
|
||||
def encode_media(media):
|
||||
encoded_media.append(media)
|
||||
return MiniMaxH3EncoderMediaConditioning(
|
||||
task=media.task,
|
||||
height=media.height,
|
||||
width=media.width,
|
||||
num_frames=media.num_frames,
|
||||
latent_t=media.latent_t,
|
||||
audio_t=media.audio_t,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(module, "prepare_encoder_inputs", prepare_once)
|
||||
pipeline.encode_prompt = encode_text
|
||||
pipeline._encode_local_media = encode_media
|
||||
pipeline._prepare_encoder_conditioning_inputs = lambda conditioning, sampling: {
|
||||
"text_embeddings": conditioning.hidden_states,
|
||||
"continuation": (277, 22),
|
||||
"num_frames": conditioning.num_frames,
|
||||
"task": conditioning.task,
|
||||
}
|
||||
extra = {"task": "ref2va", "long_video": True, "duration": 75}
|
||||
if prompt_count is not None:
|
||||
extra["continuation_prompts"] = [f"window {i}" for i in range(prompt_count)]
|
||||
sampling = OmniDiffusionSamplingParams(width=64, height=64, fps=24, extra_args=extra)
|
||||
request = {"prompt": "main prompt", "multi_modal_data": {"image": Image.new("RGB", (256, 256))}}
|
||||
if prompt_count == 6:
|
||||
with pytest.raises(OmniClientError, match="exactly 7"):
|
||||
pipeline._prepare_request_inputs(request, sampling)
|
||||
assert not encoded and not encoded_media
|
||||
return
|
||||
context = pipeline._prepare_request_inputs(request, sampling)
|
||||
assert [item.prompt for item in encoded] == extra.get("continuation_prompts", ["main prompt"])
|
||||
assert len(prepared_inputs) == len(encoded_media) == 1
|
||||
assert all(item.media is prepared_inputs[0].media for item in encoded)
|
||||
if prompt_count is not None:
|
||||
texts = context["continuation_text_conditioning"]
|
||||
assert len(texts) == 7
|
||||
assert texts[0][0] is context["text_embeddings"]
|
||||
assert [int(hidden[0, 0]) for hidden, _ in texts] == list(range(1, 8))
|
||||
else:
|
||||
assert "continuation_text_conditioning" not in context
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,133 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline():
|
||||
model = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(model)
|
||||
model.load_text_encoder = True
|
||||
model.load_vae_encoder = True
|
||||
model.device = torch.device("cpu")
|
||||
model.partition = "combined"
|
||||
model.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"})
|
||||
model.default_video_shift = 12.0
|
||||
model.default_audio_shift = 3.0
|
||||
model.od_config = SimpleNamespace(enable_layerwise_offload=False)
|
||||
model._quality_policy = SimpleNamespace(resolve=Mock(return_value=SimpleNamespace(cache_dit=None)))
|
||||
model._cache_dit_runtime = SimpleNamespace(prepare=Mock())
|
||||
model.encode_prompt = Mock(
|
||||
return_value=(torch.ones(2, 5120, dtype=torch.bfloat16), torch.ones(2, dtype=torch.long))
|
||||
)
|
||||
model.video_vae = Mock()
|
||||
model.video_vae.is_distributed_enabled.return_value = False
|
||||
model.video_vae.encode_image.side_effect = lambda image: torch.ones((image.height // 32) * (image.width // 32), 96)
|
||||
model.audio_vae = Mock()
|
||||
model.transformer = torch.nn.Identity()
|
||||
model.diffuse = Mock(return_value=(torch.zeros(1), torch.zeros(1)))
|
||||
model.decode = Mock(return_value=(torch.zeros(1, 3, 1, 32, 32), torch.zeros(1)))
|
||||
return model
|
||||
|
||||
|
||||
def _request(task):
|
||||
prompt: dict[str, Any] = {"prompt": "A person waves."}
|
||||
if task != "t2va":
|
||||
prompt["multi_modal_data"] = {"image": Image.new("RGB", (256, 256))}
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
height=32, width=32, num_frames=96, num_inference_steps=2, extra_args={"task": task, "aspect_ratio": "1:1"}
|
||||
)
|
||||
return SimpleNamespace(prompts=[prompt], sampling_params=sampling)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("task", ["t2va", "fl2va", "ref2va"])
|
||||
def test_single_stage_encodes_without_stage_zero(pipeline, task):
|
||||
output = pipeline.forward(_request(task))
|
||||
pipeline.encode_prompt.assert_called_once()
|
||||
assert pipeline.video_vae.encode_image.call_count == int(task != "t2va")
|
||||
pipeline.diffuse.assert_called_once()
|
||||
assert pipeline.diffuse.call_args.kwargs["task"] == task
|
||||
video, audio = output.output
|
||||
torch.testing.assert_close(video, torch.zeros(1, 1, 32, 32, 3, dtype=torch.uint8))
|
||||
torch.testing.assert_close(audio, pipeline.decode.return_value[1])
|
||||
|
||||
|
||||
def test_mixed_reference_conditioning_matches_stage_zero_handoff(pipeline, monkeypatch):
|
||||
import numpy as np
|
||||
|
||||
from vllm_omni.data_entry_keys import flatten_payload
|
||||
from vllm_omni.model_executor.models.minimax_h3 import encoder_processing as processing
|
||||
from vllm_omni.model_executor.models.minimax_h3.conditioning import (
|
||||
MiniMaxH3EncoderConditioning,
|
||||
MiniMaxH3TextConditioning,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minimax_h3.encoder import MiniMaxH3Encoder
|
||||
from vllm_omni.model_executor.stage_input_processors.minimax_h3 import encoder2diffusion, prepare_encoder_prompt
|
||||
|
||||
frames = np.zeros((48, 32, 32, 3), dtype=np.uint8)
|
||||
decode_frames = Mock(return_value=frames)
|
||||
sampler = Mock(wraps=processing.sample_reference_video_frames)
|
||||
monkeypatch.setattr(processing, "load_video_frames", decode_frames)
|
||||
monkeypatch.setattr(processing, "sample_reference_video_frames", sampler)
|
||||
monkeypatch.setattr(
|
||||
processing,
|
||||
"prepare_reference_videos",
|
||||
Mock(
|
||||
return_value=[{"prepared_path": "prepared.mp4", "original_path": "original.mp4", "input_has_audio": True}]
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(processing, "load_video_audio", Mock(return_value=(torch.full((64000,), 3.0), 32000)))
|
||||
pipeline.video_vae.encode_video.return_value = (torch.full((1, 96), 2.0), (1, 2, 2))
|
||||
|
||||
def encode_audio(waveform, sample_rate):
|
||||
length = round(waveform.shape[-1] / sample_rate * 40)
|
||||
return torch.full((length * 2, 32), float(waveform[0])), length
|
||||
|
||||
pipeline.audio_vae.encode_waveform.side_effect = encode_audio
|
||||
request = _request("ref2va")
|
||||
request.prompts[0]["multi_modal_data"].update(video="original.mp4", audio=(torch.full((192000,), 4.0), 32000))
|
||||
pipeline.forward(request)
|
||||
local_kwargs = pipeline.diffuse.call_args.kwargs
|
||||
|
||||
transformed = prepare_encoder_prompt(request.prompts[0], [request.sampling_params])
|
||||
stage_zero = object.__new__(MiniMaxH3Encoder)
|
||||
torch.nn.Module.__init__(stage_zero)
|
||||
stage_zero._component_leader = True
|
||||
stage_zero.video_vae = pipeline.video_vae
|
||||
stage_zero.audio_vae = pipeline.audio_vae
|
||||
media = stage_zero._encode_media(stage_zero._media_input(transformed["additional_information"]))
|
||||
text = MiniMaxH3TextConditioning(*pipeline.encode_prompt.return_value)
|
||||
payload = MiniMaxH3EncoderConditioning.from_components(text, media).to_omni_payload()
|
||||
source = SimpleNamespace(finished=True, outputs=[SimpleNamespace(multimodal_output=flatten_payload(payload))])
|
||||
bridged = encoder2diffusion([source], request.prompts[0])
|
||||
pipeline.load_text_encoder = False
|
||||
pipeline.load_vae_encoder = False
|
||||
pipeline.encode_prompt.reset_mock()
|
||||
pipeline.forward(SimpleNamespace(prompts=[bridged], sampling_params=request.sampling_params))
|
||||
external_kwargs = pipeline.diffuse.call_args.kwargs
|
||||
for key, value in local_kwargs.items():
|
||||
if isinstance(value, torch.Tensor):
|
||||
torch.testing.assert_close(value, external_kwargs[key])
|
||||
else:
|
||||
assert value == external_kwargs[key]
|
||||
pipeline.encode_prompt.assert_not_called()
|
||||
assert decode_frames.call_count == sampler.call_count == 2
|
||||
assert all(call.kwargs["decoded_frames"] is frames for call in sampler.call_args_list)
|
||||
assert [block["kind"] for block in local_kwargs["ref_blocks"]] == ["image", "video_audio", "audio"]
|
||||
assert local_kwargs["audio_condition_lengths"] == [80, round(107 / 24 * 40)]
|
||||
assert all(
|
||||
call.args[0].shape[-1] == round(107 / 24 * 32000)
|
||||
for call in pipeline.audio_vae.encode_waveform.call_args_list[1::2]
|
||||
)
|
||||
@@ -262,7 +262,10 @@ def test_a_vsa_variant_is_recognised_by_its_compression_gates(tmp_path):
|
||||
|
||||
def test_a_tensor_naming_no_h3_parameter_is_an_error(tmp_path):
|
||||
path = tmp_path / "fasth3" / "adapter_model.safetensors"
|
||||
_write_adapter(path, tensors={"transformer_blocks.0.attn.norm_q.lora_A.weight": torch.ones((_RANK, _HIDDEN))})
|
||||
_write_adapter(
|
||||
path,
|
||||
tensors={"transformer_blocks.0.attn.not_a_projection.lora_A.weight": torch.ones((_RANK, _HIDDEN))},
|
||||
)
|
||||
# Dropping it silently would load a model that is not the distilled student.
|
||||
with pytest.raises(FastH3AdapterError, match="name no known"):
|
||||
_load(path.parent)
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Pipeline-level MiniMax H3 latent initialization tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def test_prepare_edit_video_clones_the_last_frame_with_lossless_rgb(monkeypatch, tmp_path):
|
||||
from vllm_omni.model_executor.models.minimax_h3 import reference_video
|
||||
|
||||
source = tmp_path / "source.webm"
|
||||
commands: list[list[str]] = []
|
||||
monkeypatch.setattr(
|
||||
reference_video,
|
||||
"_probe_video",
|
||||
lambda _path: {"frame_count": 12, "audio_codecs": ("opus",)},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
reference_video.subprocess,
|
||||
"run",
|
||||
lambda command, **_kwargs: commands.append(command),
|
||||
)
|
||||
|
||||
prepared = reference_video.prepare_edit_video(
|
||||
source,
|
||||
target_width=448,
|
||||
target_height=256,
|
||||
target_frame_count=73,
|
||||
workdir=str(tmp_path / "prepared"),
|
||||
)
|
||||
|
||||
assert prepared["input_has_audio"] is True
|
||||
assert prepared["frame_count"] == 73
|
||||
command = commands[0]
|
||||
assert command[command.index("-vf") + 1].endswith("tpad=stop_mode=clone:stop=-1")
|
||||
assert command[command.index("-c:v") + 1] == "libx264rgb"
|
||||
assert command[command.index("-pix_fmt") + 1] == "rgb24"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source_audio", [None, "override.wav"])
|
||||
def test_encoder_preparation_routes_edit_sources_without_qwen_inputs(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
source_audio,
|
||||
):
|
||||
from vllm_omni.model_executor.models.minimax_h3 import encoder_processing as processing
|
||||
|
||||
latent_t, audio_t = 7, 37
|
||||
video_clean = torch.full((42, 96), 3.0)
|
||||
audio_source = torch.zeros(4, 32)
|
||||
audio_source[:, 0] = torch.tensor([1.0, 2.0, 11.0, 12.0])
|
||||
waveform = torch.zeros(3_200)
|
||||
frames = torch.zeros(22, 64, 96, 3, dtype=torch.uint8).numpy()
|
||||
calls: dict[str, Any] = {}
|
||||
|
||||
def prepare_video(value, **kwargs):
|
||||
calls["prepare"] = (value, kwargs)
|
||||
return {
|
||||
"original_path": "source.mov",
|
||||
"prepared_path": str(tmp_path / "prepared.mp4"),
|
||||
"input_has_audio": True,
|
||||
}
|
||||
|
||||
def load_video_audio(value, **kwargs):
|
||||
calls["video_audio"] = (value, kwargs)
|
||||
return waveform, 32_000
|
||||
|
||||
def load_audio_file(value):
|
||||
calls["audio_file"] = value
|
||||
return waveform, 32_000
|
||||
|
||||
class VideoVAE:
|
||||
def encode_video(self, value):
|
||||
calls["video_encode_shape"] = value.shape
|
||||
return video_clean.clone(), (latent_t, 4, 6)
|
||||
|
||||
class AudioVAE:
|
||||
def encode_waveform(self, value, sample_rate):
|
||||
calls["audio_encode"] = (value.shape, sample_rate)
|
||||
return audio_source.clone(), 2
|
||||
|
||||
monkeypatch.setattr(processing, "resolve_minimax_h3_shape", lambda *_args: (64, 96, 22, latent_t, audio_t))
|
||||
monkeypatch.setattr(processing, "prepare_edit_video", prepare_video)
|
||||
monkeypatch.setattr(processing, "load_video_frames", lambda _path: frames)
|
||||
monkeypatch.setattr(processing, "load_video_audio", load_video_audio)
|
||||
monkeypatch.setattr(processing, "load_audio_file", load_audio_file)
|
||||
|
||||
prepared = processing.prepare_encoder_inputs(
|
||||
{
|
||||
"prompt": "edit the clip",
|
||||
"multi_modal_data": {
|
||||
"source_video": "source.mov",
|
||||
"source_audio": source_audio,
|
||||
"video_noise_mask": torch.zeros(1, 1, latent_t, 4, 6),
|
||||
"audio_noise_mask": torch.tensor([0.0, 0.5, *([0.0] * (audio_t - 2))]).reshape(1, 1, -1),
|
||||
},
|
||||
},
|
||||
SimpleNamespace(extra_args={"task": "t2va"}),
|
||||
)
|
||||
media = type(prepared.media).from_mm_tensors(
|
||||
prepared.media.to_mm_tensors(),
|
||||
prepared.media.to_metadata(),
|
||||
)
|
||||
conditioning = processing.encode_media(
|
||||
media,
|
||||
video_vae=VideoVAE(),
|
||||
audio_vae=AudioVAE(),
|
||||
emit_conditioning=True,
|
||||
)
|
||||
|
||||
assert prepared.images == []
|
||||
assert prepared.qwen_videos == []
|
||||
assert prepared.condition_labels == []
|
||||
assert prepared.media.images == ()
|
||||
assert prepared.media.videos == ()
|
||||
assert calls["prepare"][0] == "source.mov"
|
||||
assert calls["prepare"][1]["target_width"] == 96
|
||||
assert calls["prepare"][1]["target_height"] == 64
|
||||
assert calls["prepare"][1]["target_frame_count"] == 22
|
||||
if source_audio is None:
|
||||
assert calls["video_audio"] == (
|
||||
"source.mov",
|
||||
{"duration_seconds": pytest.approx(22 / 24)},
|
||||
)
|
||||
assert "audio_file" not in calls
|
||||
else:
|
||||
assert calls["audio_file"] == "override.wav"
|
||||
assert "video_audio" not in calls
|
||||
|
||||
assert conditioning is not None
|
||||
torch.testing.assert_close(conditioning.video_edit_clean_rows, video_clean)
|
||||
assert conditioning.video_edit_mask.shape == (latent_t, 4, 6)
|
||||
torch.testing.assert_close(conditioning.video_edit_mask, torch.zeros(latent_t, 4, 6))
|
||||
assert conditioning.audio_edit_source_t == 2
|
||||
assert conditioning.audio_edit_mask.shape == (2, audio_t)
|
||||
torch.testing.assert_close(
|
||||
conditioning.audio_edit_clean_rows[:, 0],
|
||||
torch.tensor([1.0, 2.0, *([0.0] * (audio_t - 2)), 11.0, 12.0, *([0.0] * (audio_t - 2))]),
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
conditioning.audio_edit_mask,
|
||||
torch.tensor([0.0, 0.5, *([0.0] * (audio_t - 2))]).repeat(2, 1),
|
||||
)
|
||||
|
||||
from vllm_omni.model_executor.models.minimax_h3.conditioning import (
|
||||
MiniMaxH3EncoderConditioning,
|
||||
MiniMaxH3TextConditioning,
|
||||
)
|
||||
|
||||
combined = MiniMaxH3EncoderConditioning.from_components(
|
||||
MiniMaxH3TextConditioning(
|
||||
torch.zeros(1, 5120, dtype=torch.bfloat16),
|
||||
torch.ones(1, dtype=torch.int64),
|
||||
),
|
||||
conditioning,
|
||||
)
|
||||
round_trip = MiniMaxH3EncoderConditioning.from_omni_payload(combined.to_omni_payload())
|
||||
torch.testing.assert_close(round_trip.video_edit_clean_rows, video_clean)
|
||||
torch.testing.assert_close(round_trip.video_edit_mask, torch.zeros(latent_t, 4, 6))
|
||||
torch.testing.assert_close(round_trip.audio_edit_clean_rows, conditioning.audio_edit_clean_rows)
|
||||
torch.testing.assert_close(
|
||||
round_trip.audio_edit_mask,
|
||||
torch.tensor([0.0, 0.5, *([0.0] * (audio_t - 2))]).repeat(2, 1),
|
||||
)
|
||||
assert round_trip.audio_edit_source_t == 2
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline.default_video_shift = 12.0
|
||||
pipeline.default_audio_shift = 3.0
|
||||
pipeline.od_config = SimpleNamespace(step_execution=False)
|
||||
pipeline._fasth3 = None
|
||||
pipeline._active_turbo_spec = lambda _sampling: None
|
||||
pipeline._has_active_native_lora = lambda _sampling: False
|
||||
pipeline._resolve_task = lambda task, **_kwargs: task
|
||||
pipeline._resolve_sigma_positions = lambda _task, _sampling: (None, 3)
|
||||
pipeline._quality_policy = SimpleNamespace(resolve=lambda **_kwargs: SimpleNamespace(cache_dit=None))
|
||||
pipeline._cache_dit_runtime = SimpleNamespace(prepare=lambda _plan: None)
|
||||
context = pipeline._prepare_encoder_conditioning_inputs(
|
||||
round_trip,
|
||||
SimpleNamespace(extra_args={}, quality=None, seed=17, num_outputs_per_prompt=1),
|
||||
)
|
||||
torch.testing.assert_close(context["video_edit_clean_rows"], video_clean)
|
||||
torch.testing.assert_close(context["video_edit_mask_rows"], torch.zeros(42))
|
||||
assert context["video_edit_restore_mask_rows"].shape == (42, 96)
|
||||
torch.testing.assert_close(
|
||||
context["audio_edit_mask_rows"],
|
||||
torch.tensor([0.0, 0.5, *([1.0] * (audio_t - 2))]).repeat(2),
|
||||
)
|
||||
torch.testing.assert_close(context["audio_edit_mask_rows"], context["audio_edit_restore_mask_rows"])
|
||||
|
||||
|
||||
def _build_inputs(*, video_mask: torch.Tensor, audio_mask: torch.Tensor):
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
latent_t, latent_h, latent_w, audio_t = 2, 4, 6, 3
|
||||
video_clean = torch.linspace(-1.0, 1.0, latent_t * (latent_h // 2) * (latent_w // 2) * 96).reshape(-1, 96)
|
||||
audio_clean = torch.linspace(-0.5, 0.5, audio_t * 2 * 32).reshape(-1, 32)
|
||||
inputs = pipeline._build_denoise_inputs(
|
||||
task="t2va",
|
||||
text_embeddings=torch.zeros(2, 4),
|
||||
text_tags=torch.ones(2, dtype=torch.long),
|
||||
seed=17,
|
||||
latent_t=latent_t,
|
||||
latent_h=latent_h,
|
||||
latent_w=latent_w,
|
||||
audio_t=audio_t,
|
||||
num_frames=22,
|
||||
num_steps=3,
|
||||
video_shift=12.0,
|
||||
audio_shift=3.0,
|
||||
base_schedule=None,
|
||||
visual_condition=None,
|
||||
visual_condition_shape=None,
|
||||
audio_condition=None,
|
||||
ref_audio_t=None,
|
||||
video_edit_clean_rows=video_clean,
|
||||
video_edit_mask_rows=video_mask,
|
||||
video_edit_restore_mask_rows=video_mask,
|
||||
audio_edit_clean_rows=audio_clean,
|
||||
audio_edit_mask_rows=audio_mask,
|
||||
audio_edit_restore_mask_rows=audio_mask,
|
||||
)
|
||||
return pipeline, inputs, video_clean, audio_clean
|
||||
|
||||
|
||||
def test_build_denoise_inputs_uses_h3_source_anchors():
|
||||
pipeline, inputs, video_clean, audio_clean = _build_inputs(
|
||||
video_mask=torch.zeros(12),
|
||||
audio_mask=torch.zeros(6),
|
||||
)
|
||||
video_edit = inputs["video_edit"]
|
||||
audio_edit = inputs["audio_edit"]
|
||||
assert video_edit is not None and audio_edit is not None
|
||||
initial_video, _ = pipeline._initial_noise(
|
||||
seed=17,
|
||||
latent_t=2,
|
||||
latent_h=4,
|
||||
latent_w=6,
|
||||
audio_t=3,
|
||||
)
|
||||
torch.testing.assert_close(video_edit.clean_rows, video_clean)
|
||||
torch.testing.assert_close(video_edit.anchor_rows, 0.999 * video_clean + 0.001 * initial_video)
|
||||
torch.testing.assert_close(audio_edit.clean_rows, audio_clean)
|
||||
torch.testing.assert_close(audio_edit.anchor_rows, audio_clean)
|
||||
@@ -0,0 +1,81 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Focused CPU tests for MiniMax H3 latent-mask editing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def test_video_full_grid_quantization_and_cell_level_restore():
|
||||
from vllm_omni.diffusion.models.minimax_h3.latent_mask import minimax_h3_video_edit_masks
|
||||
|
||||
shape = dict(latent_t=2, latent_h=4, latent_w=6)
|
||||
full = torch.arange(48, dtype=torch.float32).reshape(2, 4, 6) / 48
|
||||
cells = full.reshape(2, 2, 2, 3, 2).permute(0, 1, 3, 2, 4)
|
||||
parsed = minimax_h3_video_edit_masks(full, **shape)
|
||||
expected_model = torch.ceil(cells.amax(dim=(3, 4)).reshape(-1) * 256) / 256
|
||||
expected_restore = cells.unsqueeze(3).expand(2, 2, 3, 24, 2, 2).reshape(12, 96)
|
||||
torch.testing.assert_close(parsed.model_mask_rows, expected_model)
|
||||
torch.testing.assert_close(parsed.restore_mask_rows, expected_restore)
|
||||
|
||||
|
||||
def test_audio_grid_uses_channel_major_order_and_preserves_raw_values():
|
||||
from vllm_omni.diffusion.models.minimax_h3.latent_mask import minimax_h3_audio_edit_masks
|
||||
|
||||
grid = torch.tensor([[0.0, 0.1, 1.0], [0.25, 0.5, 0.75]])
|
||||
expected = grid.flatten()
|
||||
parsed = minimax_h3_audio_edit_masks(grid, audio_t=3)
|
||||
torch.testing.assert_close(parsed.model_mask_rows, torch.ceil(expected * 256) / 256)
|
||||
torch.testing.assert_close(parsed.restore_mask_rows, expected)
|
||||
|
||||
|
||||
def test_zero_fractional_one_semantics_and_cell_level_restore():
|
||||
from vllm_omni.diffusion.models.minimax_h3.latent_mask import (
|
||||
MiniMaxH3LatentEdit,
|
||||
minimax_h3_video_edit_masks,
|
||||
)
|
||||
|
||||
clean = torch.full((3, 2), 10.0)
|
||||
anchor = torch.tensor([[8.0, 8.0], [6.0, 6.0], [4.0, 4.0]])
|
||||
state = torch.tensor([[2.0, 2.0], [4.0, 4.0], [6.0, 6.0]])
|
||||
velocity = torch.full((3, 2), 2.0)
|
||||
mask = torch.tensor([0.0, 0.5, 1.0])
|
||||
edit = MiniMaxH3LatentEdit.from_rows(clean, anchor, mask, mask)
|
||||
assert edit is not None
|
||||
|
||||
model_rows, target_timesteps = edit.prepare(state, 0.25, 0.999, sigma=0.75)
|
||||
torch.testing.assert_close(model_rows, torch.lerp(anchor, state, mask[:, None]))
|
||||
torch.testing.assert_close(
|
||||
target_timesteps,
|
||||
torch.tensor([0.999, 0.625, 0.25]),
|
||||
)
|
||||
predicted = model_rows + 0.75 * velocity * mask[:, None]
|
||||
torch.testing.assert_close(edit.x0(model_rows, velocity, 0.25), torch.lerp(clean, predicted, mask[:, None]))
|
||||
|
||||
# A single active cell drives the full model token, while raw restoration
|
||||
# preserves the other three cells independently in every packed channel.
|
||||
parsed = minimax_h3_video_edit_masks(
|
||||
torch.tensor([[[1.0, 0.0], [0.0, 0.0]]]),
|
||||
latent_t=1,
|
||||
latent_h=2,
|
||||
latent_w=2,
|
||||
)
|
||||
restore = torch.tensor([1.0, 0.0, 0.0, 0.0]).repeat(24).reshape(1, 96)
|
||||
torch.testing.assert_close(parsed.model_mask_rows, torch.ones(1))
|
||||
torch.testing.assert_close(parsed.restore_mask_rows, restore)
|
||||
|
||||
cell_edit = MiniMaxH3LatentEdit.from_rows(
|
||||
torch.full((1, 96), 10.0),
|
||||
torch.full((1, 96), 8.0),
|
||||
parsed.model_mask_rows,
|
||||
parsed.restore_mask_rows,
|
||||
)
|
||||
assert cell_edit is not None
|
||||
cell_state = torch.full((1, 96), 2.0)
|
||||
cell_velocity = torch.full((1, 96), 4.0)
|
||||
expected = torch.where(restore.bool(), cell_state + 0.75 * cell_velocity, cell_edit.clean_rows)
|
||||
torch.testing.assert_close(cell_edit.x0(cell_state, cell_velocity, 0.25), expected)
|
||||
@@ -0,0 +1,143 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Duration limits apply before encoding and to transported encoder payloads."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.errors import OmniClientError
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.model_executor.models.minimax_h3.encoder_processing import (
|
||||
prepare_encoder_inputs,
|
||||
resolve_minimax_h3_shape,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minimax_h3.long_video import validate_encoded_frame_limit
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode,limit", [("full", 30), ("continuation", 300), (None, 300)])
|
||||
@pytest.mark.parametrize("input_kind", ["duration", "duration_seconds", "target", "num_frames"])
|
||||
def test_long_video_limits_cover_all_duration_inputs(mode, limit, input_kind):
|
||||
extra: dict[str, Any] = {"long_video": True}
|
||||
if mode is not None:
|
||||
extra["long_video_mode"] = mode
|
||||
sampling = OmniDiffusionSamplingParams(width=64, height=64, fps=24, extra_args=extra)
|
||||
|
||||
def set_duration(seconds):
|
||||
if input_kind == "target":
|
||||
extra["target"] = {"duration_seconds": seconds}
|
||||
elif input_kind == "num_frames":
|
||||
sampling.num_frames = seconds * 24
|
||||
else:
|
||||
extra[input_kind] = seconds
|
||||
|
||||
set_duration(limit)
|
||||
_, _, frames, _, _ = resolve_minimax_h3_shape("ref2va", sampling, None)
|
||||
assert frames >= limit * 24
|
||||
validate_encoded_frame_limit(extra, "ref2va", frames)
|
||||
with pytest.raises(OmniClientError, match="request limit"):
|
||||
validate_encoded_frame_limit(extra, "ref2va", frames + 17)
|
||||
set_duration(limit + 1)
|
||||
with pytest.raises(OmniClientError, match="duration"):
|
||||
resolve_minimax_h3_shape("ref2va", sampling, None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("task", ["t2va", "fl2va", "ref2va"])
|
||||
def test_full_mode_rejects_300_seconds_and_accepts_comparison(task):
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
width=64,
|
||||
height=64,
|
||||
fps=24,
|
||||
extra_args={"long_video": True, "long_video_mode": "full", "duration": 300, "aspect_ratio": "1:1"},
|
||||
)
|
||||
image = Image.new("RGB", (64, 64)) if task == "fl2va" else None
|
||||
with pytest.raises(OmniClientError, match="30"):
|
||||
resolve_minimax_h3_shape(task, sampling, image)
|
||||
sampling.extra_args["duration"] = 20
|
||||
assert resolve_minimax_h3_shape(task, sampling, image)[2] == 481
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["full", "continuation"])
|
||||
def test_mode_alone_does_not_bypass_long_video_opt_in(mode):
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
width=64,
|
||||
height=64,
|
||||
extra_args={"long_video_mode": mode, "duration": 20},
|
||||
)
|
||||
with pytest.raises(OmniClientError, match="15"):
|
||||
resolve_minimax_h3_shape("ref2va", sampling, None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["video", "audio"])
|
||||
def test_continuation_edit_rejected_before_loading_source(kind):
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
width=64,
|
||||
height=64,
|
||||
extra_args={"long_video": True, "task": "ref2va", "duration": 20},
|
||||
)
|
||||
with pytest.raises(OmniClientError, match="continuation does not support latent-mask editing"):
|
||||
prepare_encoder_inputs(
|
||||
{
|
||||
"prompt": "test",
|
||||
"multi_modal_data": {
|
||||
"image": Image.new("RGB", (64, 64)),
|
||||
f"source_{kind}": "must-not-be-loaded",
|
||||
f"{kind}_noise_mask": 0.5,
|
||||
},
|
||||
},
|
||||
sampling,
|
||||
)
|
||||
|
||||
|
||||
def test_locked_audio_rejects_audio_edit_before_loading_source():
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
width=64,
|
||||
height=64,
|
||||
extra_args={"task": "t2va", "aspect_ratio": "1:1", "audio_mode": "lock_source"},
|
||||
)
|
||||
with pytest.raises(OmniClientError, match="cannot be combined"):
|
||||
prepare_encoder_inputs(
|
||||
{
|
||||
"prompt": "test",
|
||||
"multi_modal_data": {
|
||||
"audio": (torch.zeros(8000), 8000),
|
||||
"source_audio": "must-not-be-loaded",
|
||||
"audio_noise_mask": 0.5,
|
||||
},
|
||||
},
|
||||
sampling,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["video", "audio"])
|
||||
def test_external_encoder_cannot_bypass_continuation_edit_validation(kind):
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = SimpleNamespace(step_execution=False)
|
||||
conditioning = SimpleNamespace(
|
||||
task="ref2va",
|
||||
num_frames=481,
|
||||
video_edit_clean_rows=torch.zeros(1) if kind == "video" else None,
|
||||
audio_edit_clean_rows=torch.zeros(1) if kind == "audio" else None,
|
||||
)
|
||||
sampling = OmniDiffusionSamplingParams(extra_args={"long_video": True})
|
||||
with pytest.raises(OmniClientError, match="continuation does not support latent-mask editing"):
|
||||
pipeline._prepare_encoder_conditioning_inputs(conditioning, sampling)
|
||||
|
||||
|
||||
def test_external_encoder_cannot_bypass_full_duration_limit():
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = SimpleNamespace(step_execution=False)
|
||||
sampling = OmniDiffusionSamplingParams(extra_args={"long_video": True, "long_video_mode": "full"})
|
||||
with pytest.raises(OmniClientError, match="30-second request limit"):
|
||||
pipeline._prepare_encoder_conditioning_inputs(SimpleNamespace(task="ref2va", num_frames=7213), sampling)
|
||||
@@ -6,10 +6,11 @@ import torch
|
||||
from vllm.triton_utils import HAS_TRITON
|
||||
|
||||
from tests.helpers.mark import hardware_marks
|
||||
from vllm_omni.diffusion.attention.ops.minimax_h3_modulation import (
|
||||
from vllm_omni.diffusion.layers.indexed_modulation import (
|
||||
_MAX_1D_GRID_SIZE,
|
||||
_iter_row_chunks,
|
||||
_launch_row_chunks,
|
||||
_use_hopper_bf16_affine_semantics,
|
||||
indexed_gate,
|
||||
indexed_gate_rms_norm_scale_shift,
|
||||
indexed_scale_shift_,
|
||||
@@ -190,3 +191,55 @@ def test_fused_modulation_preserves_bf16_residual_boundary() -> None:
|
||||
)
|
||||
|
||||
assert torch.equal(modulated_out, expected)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@pytest.mark.skipif(
|
||||
torch.cuda.is_available() and torch.cuda.get_device_capability()[0] != 9,
|
||||
reason="SM90-specific precision path",
|
||||
)
|
||||
@pytest.mark.skipif(not HAS_TRITON, reason="Triton required")
|
||||
def test_hopper_fused_modulation_matches_pytorch_reference() -> None:
|
||||
torch.manual_seed(42)
|
||||
rows, hidden_size, conditions = 128, 3072, 4
|
||||
residual = torch.randn(rows, hidden_size, device="cuda", dtype=torch.bfloat16)
|
||||
gate = torch.randn(conditions, hidden_size, device="cuda", dtype=torch.bfloat16)
|
||||
branch = torch.randn(rows, hidden_size, device="cuda", dtype=torch.bfloat16)
|
||||
weight = torch.randn(hidden_size, device="cuda", dtype=torch.bfloat16)
|
||||
shift = torch.randn(conditions, hidden_size, device="cuda", dtype=torch.bfloat16)
|
||||
scale = torch.randn(conditions, hidden_size, device="cuda", dtype=torch.bfloat16)
|
||||
indices = torch.arange(rows, device="cuda") % conditions
|
||||
eps = 1e-6
|
||||
|
||||
reference_residual = (residual + gate.index_select(0, indices) * branch).to(torch.bfloat16)
|
||||
normalized = reference_residual.float()
|
||||
variance = normalized.pow(2).mean(-1, keepdim=True)
|
||||
normalized = (weight.float() * (normalized * torch.rsqrt(variance + eps))).to(torch.bfloat16)
|
||||
reference_modulated = (normalized * (1.0 + scale.index_select(0, indices)) + shift.index_select(0, indices)).to(
|
||||
torch.bfloat16
|
||||
)
|
||||
|
||||
actual_residual, actual_modulated = indexed_gate_rms_norm_scale_shift(
|
||||
residual,
|
||||
gate,
|
||||
branch,
|
||||
weight,
|
||||
shift,
|
||||
scale,
|
||||
indices,
|
||||
eps,
|
||||
)
|
||||
|
||||
torch.testing.assert_close(actual_residual, reference_residual, atol=5e-2, rtol=5e-2)
|
||||
torch.testing.assert_close(actual_modulated, reference_modulated, atol=5e-2, rtol=5e-2)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
def test_hopper_precision_dispatch_is_architecture_scoped(monkeypatch) -> None:
|
||||
tensor = torch.empty(0, device="cuda")
|
||||
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda _device: (9, 0))
|
||||
assert _use_hopper_bf16_affine_semantics(tensor)
|
||||
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda _device: (10, 3))
|
||||
assert not _use_hopper_bf16_affine_semantics(tensor)
|
||||
@@ -1,82 +1,282 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from contextlib import contextmanager
|
||||
import weakref
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def test_h3_profiler_targets_cover_reference_encoders_and_vae_decoders():
|
||||
def test_h3_full_pipeline_profiler_includes_local_encoding():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
targets = MiniMaxH3Pipeline._PROFILER_TARGETS
|
||||
assert "_encode_visual_conditions" in targets
|
||||
assert "_encode_reference_audio_conditions" in targets
|
||||
assert "video_vae.decode_latent" in targets
|
||||
assert "audio_vae.decode_latent" in targets
|
||||
assert "_encode_video_conditions" not in targets
|
||||
assert "_encode_video_audio_conditions" not in targets
|
||||
assert "_encode_audio_conditions" not in targets
|
||||
assert targets == [
|
||||
"encode_prompt",
|
||||
"_encode_local_media",
|
||||
"diffuse",
|
||||
"decode",
|
||||
"video_vae.decode_latent",
|
||||
"audio_vae.decode_latent",
|
||||
"prepare_encode",
|
||||
"denoise_step",
|
||||
"post_decode",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("distributed", [False, True])
|
||||
def test_manual_component_offload_wins_over_requested_model_offload(distributed):
|
||||
def test_decoded_output_is_unchanged_without_active_model_offload():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=True,
|
||||
enable_layerwise_offload=not distributed,
|
||||
enable_distributed_layerwise_offload=distributed,
|
||||
)
|
||||
pipeline._model_cpu_offload_modules = []
|
||||
pipeline.text_encoder = Mock()
|
||||
expected = torch.ones(2, 3)
|
||||
pipeline.text_encoder.encode_ids.return_value = expected
|
||||
component = Mock()
|
||||
output = Mock()
|
||||
|
||||
actual = pipeline._encode_text_hidden(torch.tensor([1, 2]), {})
|
||||
with pipeline._component_on_device(component):
|
||||
component.load_to_device.assert_called_once_with()
|
||||
component.offload_to_cpu.assert_not_called()
|
||||
|
||||
assert actual is expected
|
||||
pipeline.text_encoder.load_to_device.assert_called_once_with()
|
||||
pipeline.text_encoder.offload_to_cpu.assert_called_once_with()
|
||||
component.offload_to_cpu.assert_called_once_with()
|
||||
assert pipeline._offload_model_cpu_stage_output(output) is output
|
||||
output.cpu.assert_not_called()
|
||||
|
||||
|
||||
def test_requested_model_offload_without_backend_uses_resident_components():
|
||||
def test_reply_rank_copies_decoded_output_to_host(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=True,
|
||||
enable_layerwise_offload=False,
|
||||
enable_distributed_layerwise_offload=False,
|
||||
pipeline._model_cpu_offload_modules = [Mock()]
|
||||
monkeypatch.setattr(pipeline, "_is_output_owner_rank", lambda: True)
|
||||
output = Mock()
|
||||
host_output = Mock()
|
||||
output.cpu.return_value = host_output
|
||||
|
||||
assert pipeline._offload_model_cpu_stage_output(output) is host_output
|
||||
output.cpu.assert_called_once_with()
|
||||
|
||||
|
||||
def test_non_reply_rank_drops_decoded_output_storage(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline._model_cpu_offload_modules = [Mock()]
|
||||
monkeypatch.setattr(pipeline, "_is_output_owner_rank", lambda: False)
|
||||
output = torch.ones((2, 3), dtype=torch.float16)
|
||||
|
||||
placeholder = pipeline._offload_model_cpu_stage_output(output)
|
||||
|
||||
assert placeholder.device.type == "meta"
|
||||
assert placeholder.shape == output.shape
|
||||
assert placeholder.dtype == output.dtype
|
||||
|
||||
|
||||
def test_decode_releases_only_audio_before_return(monkeypatch):
|
||||
"""Video is released by the callers of ``decode`` -- after quantization."""
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
|
||||
video = torch.ones((1, 1, 4, 4))
|
||||
audio = torch.ones((1, 8))
|
||||
pipeline = SimpleNamespace(
|
||||
device=torch.device("cpu"),
|
||||
video_vae=Mock(decode_latent=Mock(return_value=video)),
|
||||
audio_vae=Mock(decode_latent=Mock(return_value=audio)),
|
||||
_component_on_device=lambda component: nullcontext(),
|
||||
_offload_model_cpu_stage_output=Mock(side_effect=lambda value: value),
|
||||
_release_stage_cache=Mock(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module.current_omni_platform,
|
||||
"create_autocast_context",
|
||||
lambda **kwargs: nullcontext(),
|
||||
)
|
||||
pipeline._model_cpu_offload_modules = []
|
||||
pipeline.text_encoder = Mock()
|
||||
expected = torch.ones(2, 3)
|
||||
pipeline.text_encoder.encode_ids.return_value = expected
|
||||
|
||||
actual = pipeline._encode_text_hidden(torch.tensor([1, 2]), {})
|
||||
actual_video, actual_audio = MiniMaxH3Pipeline.decode(
|
||||
pipeline,
|
||||
torch.zeros(1),
|
||||
torch.zeros(1),
|
||||
height=2,
|
||||
width=3,
|
||||
)
|
||||
|
||||
assert actual is expected
|
||||
pipeline.text_encoder.load_to_device.assert_called_once_with()
|
||||
pipeline.text_encoder.encode_ids.assert_called_once()
|
||||
assert actual_video.shape[-2:] == (2, 3)
|
||||
assert actual_audio is audio
|
||||
calls = pipeline._offload_model_cpu_stage_output.call_args_list
|
||||
assert len(calls) == 1
|
||||
assert calls[0].args[0] is audio
|
||||
|
||||
|
||||
def test_h3_model_cpu_offload_registers_direct_vae_stages(monkeypatch):
|
||||
def _recording_release(events, releases):
|
||||
"""Stand-in for ``_offload_model_cpu_stage_output`` that records what it got."""
|
||||
|
||||
def release(value):
|
||||
events.append("release")
|
||||
releases.append(value)
|
||||
return value
|
||||
|
||||
return release
|
||||
|
||||
|
||||
def test_forward_releases_video_after_quantization_and_before_next_seed(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
# MiniMaxH3VideoVAE.decode_latent ends in frames.float(), so this is what
|
||||
# the release hook would see if it ran before the quantizer.
|
||||
decoded_dtype = torch.float32
|
||||
events: list[str] = []
|
||||
releases: list[torch.Tensor] = []
|
||||
|
||||
def diffuse(**kwargs):
|
||||
events.append(f"diffuse:{kwargs['seed']}")
|
||||
return torch.zeros(1), torch.zeros(1)
|
||||
|
||||
def decode(video_latent, audio_latent, *, height, width):
|
||||
events.append("decode")
|
||||
return (
|
||||
torch.zeros((1, 3, 2, height, width), dtype=decoded_dtype),
|
||||
torch.zeros((1, 8)),
|
||||
)
|
||||
|
||||
# ``forward`` indexes ``preencode_mp4`` (#7018): this test drives the
|
||||
# tensor-decode branch, which is the one the release hook wraps.
|
||||
context = {"num_outputs": 2, "seed": 41, "height": 4, "width": 4, "preencode_mp4": False}
|
||||
pipeline = SimpleNamespace(
|
||||
od_config=SimpleNamespace(),
|
||||
_extract_prompt=lambda raw: ("a prompt", {}),
|
||||
_extract_text_conditioning=lambda raw: None,
|
||||
_extract_prepared_reference_videos=lambda raw: None,
|
||||
_prepare_request_inputs=lambda *args, **kwargs: context,
|
||||
_denoise_kwargs=lambda ctx: {},
|
||||
diffuse=diffuse,
|
||||
decode=decode,
|
||||
_offload_model_cpu_stage_output=_recording_release(events, releases),
|
||||
_release_stage_cache=lambda: None,
|
||||
)
|
||||
request = SimpleNamespace(prompts=["a prompt"], sampling_params=SimpleNamespace())
|
||||
|
||||
output = MiniMaxH3Pipeline.forward(pipeline, request)
|
||||
|
||||
# The release must see the quantized frames, not the decoded floats.
|
||||
assert len(releases) == 2
|
||||
assert [tensor.dtype for tensor in releases] == [torch.uint8, torch.uint8]
|
||||
assert decoded_dtype is not torch.uint8
|
||||
# ... and it must still happen before the next seed reloads the DiT.
|
||||
assert events == ["diffuse:41", "decode", "release", "diffuse:42", "decode", "release"]
|
||||
assert output.output[0].dtype is torch.uint8
|
||||
|
||||
|
||||
def test_forward_drops_the_decoded_video_before_the_next_seed_diffuses(monkeypatch):
|
||||
"""Releasing the copy is not enough if a local still pins the original.
|
||||
|
||||
``_offload_model_cpu_stage_output`` hands back a *new* tensor, so the
|
||||
decoded frames stay alive for as long as anything still references them.
|
||||
The whole point of this branch is that they are gone before the next seed
|
||||
reloads the DiT, and ``diffuse`` is where that reload happens.
|
||||
"""
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
decoded_refs: list[weakref.ref] = []
|
||||
alive_at_diffuse: list[int] = []
|
||||
|
||||
def diffuse(**kwargs):
|
||||
alive_at_diffuse.append(sum(1 for ref in decoded_refs if ref() is not None))
|
||||
return torch.zeros(1), torch.zeros(1)
|
||||
|
||||
def decode(video_latent, audio_latent, *, height, width):
|
||||
video = torch.zeros((1, 3, 2, height, width), dtype=torch.float32)
|
||||
decoded_refs.append(weakref.ref(video))
|
||||
return video, torch.zeros((1, 8))
|
||||
|
||||
# ``forward`` indexes ``preencode_mp4`` (#7018): this test drives the
|
||||
# tensor-decode branch, which is the one the release hook wraps.
|
||||
context = {"num_outputs": 2, "seed": 41, "height": 4, "width": 4, "preencode_mp4": False}
|
||||
pipeline = SimpleNamespace(
|
||||
od_config=SimpleNamespace(),
|
||||
_extract_prompt=lambda raw: ("a prompt", {}),
|
||||
_extract_text_conditioning=lambda raw: None,
|
||||
_extract_prepared_reference_videos=lambda raw: None,
|
||||
_prepare_request_inputs=lambda *args, **kwargs: context,
|
||||
_denoise_kwargs=lambda ctx: {},
|
||||
diffuse=diffuse,
|
||||
decode=decode,
|
||||
_offload_model_cpu_stage_output=lambda value: value.clone(),
|
||||
_release_stage_cache=lambda: None,
|
||||
)
|
||||
request = SimpleNamespace(prompts=["a prompt"], sampling_params=SimpleNamespace())
|
||||
|
||||
MiniMaxH3Pipeline.forward(pipeline, request)
|
||||
|
||||
assert len(decoded_refs) == 2
|
||||
# Seed 1's diffuse sees nothing decoded yet; seed 2's must not see seed 1's
|
||||
# decoded frames still resident.
|
||||
assert alive_at_diffuse == [0, 0]
|
||||
|
||||
|
||||
def test_post_decode_releases_video_after_quantization(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
|
||||
events: list[str] = []
|
||||
releases: list[torch.Tensor] = []
|
||||
|
||||
def decode(video_latent, audio_latent, *, height, width):
|
||||
events.append("decode")
|
||||
return (
|
||||
torch.zeros((1, 3, 2, height, width), dtype=torch.float16),
|
||||
torch.zeros((1, 8)),
|
||||
)
|
||||
|
||||
pipeline = SimpleNamespace(
|
||||
od_config=SimpleNamespace(),
|
||||
_unpack_denoised_rows=lambda *args, **kwargs: (torch.zeros(1), torch.zeros(1)),
|
||||
decode=decode,
|
||||
_offload_model_cpu_stage_output=_recording_release(events, releases),
|
||||
_release_stage_cache=lambda: None,
|
||||
)
|
||||
state = SimpleNamespace(
|
||||
latents=torch.zeros(1),
|
||||
extra={
|
||||
module._STEP_BRANCH: object(),
|
||||
module._STEP_AUDIO_ROWS: torch.zeros(1),
|
||||
module._STEP_SHAPE: {
|
||||
"latent_t": 1,
|
||||
"latent_h": 1,
|
||||
"latent_w": 1,
|
||||
"audio_t": 1,
|
||||
"height": 4,
|
||||
"width": 4,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
output = MiniMaxH3Pipeline.post_decode(pipeline, state)
|
||||
|
||||
assert events == ["decode", "release"]
|
||||
assert len(releases) == 1
|
||||
assert releases[0].dtype is torch.uint8
|
||||
assert output.output[0] is releases[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("available", "initialized", "rank", "expected"),
|
||||
[(False, False, 7, True), (True, False, 7, True), (True, True, 0, True), (True, True, 1, False)],
|
||||
)
|
||||
def test_output_owner_rank_matches_executor_contract(monkeypatch, available, initialized, rank, expected):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
|
||||
monkeypatch.setattr(module.dist, "is_available", lambda: available)
|
||||
monkeypatch.setattr(module.dist, "is_initialized", lambda: initialized)
|
||||
monkeypatch.setattr(module.dist, "get_rank", lambda: rank)
|
||||
|
||||
assert MiniMaxH3Pipeline._is_output_owner_rank() is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("load_text_encoder", [True, False])
|
||||
def test_h3_model_cpu_offload_registers_direct_vae_stages(monkeypatch, load_text_encoder):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
|
||||
@@ -84,9 +284,11 @@ def test_h3_model_cpu_offload_registers_direct_vae_stages(monkeypatch):
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.transformer = torch.nn.Linear(2, 2)
|
||||
pipeline.transformers_ref = torch.nn.Linear(2, 2)
|
||||
pipeline.text_encoder = torch.nn.Linear(2, 2)
|
||||
pipeline.video_vae = torch.nn.Linear(2, 2)
|
||||
pipeline.audio_vae = torch.nn.Linear(2, 2)
|
||||
pipeline._encoder_modules = ["text_encoder"] if load_text_encoder else []
|
||||
if load_text_encoder:
|
||||
pipeline.text_encoder = torch.nn.Linear(2, 2)
|
||||
apply_offload = Mock()
|
||||
remove_offload = Mock()
|
||||
monkeypatch.setattr(module, "apply_sequential_offload", apply_offload)
|
||||
@@ -99,7 +301,7 @@ def test_h3_model_cpu_offload_registers_direct_vae_stages(monkeypatch):
|
||||
)
|
||||
|
||||
dits = [pipeline.transformer, pipeline.transformers_ref]
|
||||
stages = [pipeline.text_encoder, pipeline.video_vae, pipeline.audio_vae]
|
||||
stages = [*([pipeline.text_encoder] if load_text_encoder else []), pipeline.video_vae, pipeline.audio_vae]
|
||||
apply_offload.assert_called_once_with(
|
||||
dit_modules=dits,
|
||||
encoder_modules=stages,
|
||||
@@ -114,6 +316,46 @@ def test_h3_model_cpu_offload_registers_direct_vae_stages(monkeypatch):
|
||||
remove_offload.assert_called_once_with([*dits, *stages])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("decode_fails", [False, True])
|
||||
def test_h3_model_cpu_offload_scopes_direct_vae_call(monkeypatch, decode_fails):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
component = torch.nn.Linear(2, 2)
|
||||
events: list[tuple[Any, ...]] = []
|
||||
|
||||
@contextmanager
|
||||
def record_component(value):
|
||||
events.append(("activate", value))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
events.append(("offload", value))
|
||||
|
||||
monkeypatch.setattr(module, "sequential_offload_component", record_component)
|
||||
pipeline._model_cpu_offload_modules = [component]
|
||||
|
||||
def decode():
|
||||
with pipeline._component_on_device(component):
|
||||
events.append(("decode", component))
|
||||
if decode_fails:
|
||||
raise RuntimeError("decode failed")
|
||||
|
||||
if decode_fails:
|
||||
with pytest.raises(RuntimeError, match="decode failed"):
|
||||
decode()
|
||||
else:
|
||||
decode()
|
||||
|
||||
assert events == [
|
||||
("activate", component),
|
||||
("decode", component),
|
||||
("offload", component),
|
||||
]
|
||||
|
||||
|
||||
def test_h3_model_cpu_offload_keeps_unselected_vaes_resident(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
@@ -174,44 +416,20 @@ def test_h3_model_cpu_offload_rejects_unloaded_selected_encoder(monkeypatch):
|
||||
apply_offload.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("decode_fails", [False, True])
|
||||
def test_h3_model_cpu_offload_scopes_direct_vae_call(monkeypatch, decode_fails):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
def _encode_media(pipeline, **kwargs):
|
||||
from vllm_omni.model_executor.models.minimax_h3.conditioning import MiniMaxH3EncoderMediaInput
|
||||
from vllm_omni.model_executor.models.minimax_h3.encoder_processing import encode_media
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
component = torch.nn.Linear(2, 2)
|
||||
events: list[tuple[Any, ...]] = []
|
||||
|
||||
@contextmanager
|
||||
def record_component(value):
|
||||
events.append(("activate", value))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
events.append(("offload", value))
|
||||
|
||||
monkeypatch.setattr(module, "sequential_offload_component", record_component)
|
||||
pipeline._model_cpu_offload_modules = [component]
|
||||
|
||||
def decode():
|
||||
with pipeline._component_on_device(component):
|
||||
events.append(("decode", component))
|
||||
if decode_fails:
|
||||
raise RuntimeError("decode failed")
|
||||
|
||||
if decode_fails:
|
||||
with pytest.raises(RuntimeError, match="decode failed"):
|
||||
decode()
|
||||
else:
|
||||
decode()
|
||||
|
||||
assert events == [
|
||||
("activate", component),
|
||||
("decode", component),
|
||||
("offload", component),
|
||||
]
|
||||
media = MiniMaxH3EncoderMediaInput(
|
||||
task="ref2va", height=32, width=32, num_frames=124, latent_t=31, audio_t=208, **kwargs
|
||||
)
|
||||
return encode_media(
|
||||
media,
|
||||
video_vae=getattr(pipeline, "video_vae", None),
|
||||
audio_vae=getattr(pipeline, "audio_vae", None),
|
||||
emit_conditioning=True,
|
||||
component_scope=pipeline._component_on_device,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encode_fails", [False, True])
|
||||
@@ -221,7 +439,6 @@ def test_h3_model_cpu_offload_batches_visual_reference_scope(monkeypatch, encode
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline.video_vae = Mock()
|
||||
pipeline._model_cpu_offload_modules = [pipeline.video_vae]
|
||||
events = []
|
||||
@@ -242,17 +459,15 @@ def test_h3_model_cpu_offload_batches_visual_reference_scope(monkeypatch, encode
|
||||
|
||||
pipeline.video_vae.encode_image.side_effect = encode_image
|
||||
monkeypatch.setattr(module, "sequential_offload_component", record_component)
|
||||
monkeypatch.setattr(module, "_dit_rank_world", lambda: (None, 0, 1))
|
||||
monkeypatch.setattr(module, "_broadcast_tensor", lambda value, **kwargs: value)
|
||||
images = [Image.new("RGB", (16, 16)), Image.new("RGB", (32, 16))]
|
||||
images = (torch.zeros(16, 16, 3, dtype=torch.uint8), torch.zeros(16, 32, 3, dtype=torch.uint8))
|
||||
|
||||
if encode_fails:
|
||||
with pytest.raises(RuntimeError, match="encode failed"):
|
||||
pipeline._encode_visual_conditions(images, None, video_count=0)
|
||||
_encode_media(pipeline, images=images)
|
||||
else:
|
||||
rows, shapes = pipeline._encode_visual_conditions(images, None, video_count=0)
|
||||
assert rows.shape == (3, 4)
|
||||
assert shapes == [(1, 1, 1), (1, 1, 2)]
|
||||
result = _encode_media(pipeline, images=images)
|
||||
assert result.visual_condition.shape == (3, 4)
|
||||
assert result.visual_condition_shapes == ((1, 1, 1), (1, 1, 2))
|
||||
|
||||
assert events == [
|
||||
("activate", pipeline.video_vae),
|
||||
@@ -268,9 +483,7 @@ def test_h3_model_cpu_offload_shares_image_and_video_scope(monkeypatch):
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline.video_vae = Mock()
|
||||
pipeline.video_vae.is_distributed_enabled.return_value = False
|
||||
pipeline._model_cpu_offload_modules = [pipeline.video_vae]
|
||||
events = []
|
||||
|
||||
@@ -287,31 +500,26 @@ def test_h3_model_cpu_offload_shares_image_and_video_scope(monkeypatch):
|
||||
return torch.ones(1, 4)
|
||||
|
||||
def encode_video(frames):
|
||||
events.append(("video", frames))
|
||||
events.append(("video", frames.shape))
|
||||
return torch.ones(3, 4), (3, 2, 2)
|
||||
|
||||
pipeline.video_vae.encode_image.side_effect = encode_image
|
||||
pipeline.video_vae.encode_video.side_effect = encode_video
|
||||
monkeypatch.setattr(module, "sequential_offload_component", record_component)
|
||||
monkeypatch.setattr(module, "_dit_rank_world", lambda: (None, 0, 1))
|
||||
monkeypatch.setattr(module, "_broadcast_tensor", lambda value, **kwargs: value)
|
||||
monkeypatch.setattr(module, "load_video_frames", lambda path: f"frames:{path}")
|
||||
images = [Image.new("RGB", (16, 16)), Image.new("RGB", (32, 16))]
|
||||
prepared_videos = [{"prepared_path": "reference.mp4"}]
|
||||
|
||||
rows, shapes = pipeline._encode_visual_conditions(
|
||||
images,
|
||||
prepared_videos,
|
||||
video_count=1,
|
||||
result = _encode_media(
|
||||
pipeline,
|
||||
images=(torch.zeros(16, 16, 3, dtype=torch.uint8), torch.zeros(16, 32, 3, dtype=torch.uint8)),
|
||||
videos=(torch.zeros(12, 32, 32, 3, dtype=torch.uint8),),
|
||||
video_audios=(None,),
|
||||
)
|
||||
|
||||
assert rows.shape == (5, 4)
|
||||
assert shapes == [(1, 1, 1), (1, 1, 2), (3, 2, 2)]
|
||||
assert result.visual_condition.shape == (5, 4)
|
||||
assert result.visual_condition_shapes == ((1, 1, 1), (1, 1, 2), (3, 2, 2))
|
||||
assert events == [
|
||||
("activate", pipeline.video_vae),
|
||||
("image", (16, 16)),
|
||||
("image", (32, 16)),
|
||||
("video", "frames:reference.mp4"),
|
||||
("video", (12, 32, 32, 3)),
|
||||
("offload", pipeline.video_vae),
|
||||
]
|
||||
|
||||
@@ -325,7 +533,7 @@ def test_h3_model_cpu_offload_shares_embedded_and_standalone_audio_scope(monkeyp
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.audio_vae = Mock()
|
||||
pipeline._model_cpu_offload_modules = [pipeline.audio_vae]
|
||||
events: list[tuple[Any, ...]] = []
|
||||
events: list[tuple] = []
|
||||
|
||||
@contextmanager
|
||||
def record_component(value):
|
||||
@@ -335,26 +543,20 @@ def test_h3_model_cpu_offload_shares_embedded_and_standalone_audio_scope(monkeyp
|
||||
finally:
|
||||
events.append(("offload", value))
|
||||
|
||||
def encode_embedded(*args, **kwargs):
|
||||
events.append(("embedded",))
|
||||
def encode_waveform(waveform, sample_rate):
|
||||
events.append(("audio", int(waveform[0]), sample_rate))
|
||||
if encode_fails:
|
||||
raise RuntimeError("audio encode failed")
|
||||
return torch.ones(1, 2), [1]
|
||||
return torch.ones(80, 32), 80
|
||||
|
||||
def encode_standalone(*args, **kwargs):
|
||||
events.append(("standalone",))
|
||||
return torch.ones(1, 2), [1]
|
||||
|
||||
pipeline._encode_video_audio_conditions_resident = encode_embedded
|
||||
pipeline._encode_audio_conditions_resident = encode_standalone
|
||||
pipeline.audio_vae.encode_waveform.side_effect = encode_waveform
|
||||
monkeypatch.setattr(module, "sequential_offload_component", record_component)
|
||||
|
||||
def call():
|
||||
return pipeline._encode_reference_audio_conditions(
|
||||
[{"input_has_audio": True}],
|
||||
has_audio=[True],
|
||||
standalone_audios=[(torch.ones(1), 16_000)],
|
||||
max_duration_seconds=1.0,
|
||||
return _encode_media(
|
||||
pipeline,
|
||||
video_audios=(),
|
||||
audios=((torch.ones(32_000), 16_000),),
|
||||
)
|
||||
|
||||
if encode_fails:
|
||||
@@ -362,10 +564,61 @@ def test_h3_model_cpu_offload_shares_embedded_and_standalone_audio_scope(monkeyp
|
||||
call()
|
||||
else:
|
||||
result = call()
|
||||
assert result[0].shape == (1, 2)
|
||||
assert result[2].shape == (1, 2)
|
||||
assert result.audio_condition.shape == (80, 32)
|
||||
assert result.audio_condition_lengths == (80,)
|
||||
assert events[1:-1] == [("audio", 1, 16_000)]
|
||||
|
||||
assert events[0] == ("activate", pipeline.audio_vae)
|
||||
assert events[-1] == ("offload", pipeline.audio_vae)
|
||||
assert events.count(("activate", pipeline.audio_vae)) == 1
|
||||
assert events.count(("offload", pipeline.audio_vae)) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("distributed", [False, True])
|
||||
def test_manual_component_offload_wins_over_requested_model_offload(distributed):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=True,
|
||||
enable_layerwise_offload=not distributed,
|
||||
enable_distributed_layerwise_offload=distributed,
|
||||
)
|
||||
pipeline._model_cpu_offload_modules = []
|
||||
pipeline.text_encoder = Mock()
|
||||
expected = torch.ones(2, 3)
|
||||
pipeline.text_encoder.encode_ids.return_value = expected
|
||||
component = Mock()
|
||||
|
||||
actual = pipeline._encode_text_hidden(torch.tensor([1, 2]), {})
|
||||
with pipeline._component_on_device(component):
|
||||
component.load_to_device.assert_called_once_with()
|
||||
component.offload_to_cpu.assert_not_called()
|
||||
|
||||
assert actual is expected
|
||||
pipeline.text_encoder.load_to_device.assert_called_once_with()
|
||||
pipeline.text_encoder.offload_to_cpu.assert_called_once_with()
|
||||
component.offload_to_cpu.assert_called_once_with()
|
||||
|
||||
|
||||
def test_requested_model_offload_without_backend_uses_resident_components():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=True,
|
||||
enable_layerwise_offload=False,
|
||||
enable_distributed_layerwise_offload=False,
|
||||
)
|
||||
pipeline._model_cpu_offload_modules = []
|
||||
pipeline.text_encoder = Mock()
|
||||
expected = torch.ones(2, 3)
|
||||
pipeline.text_encoder.encode_ids.return_value = expected
|
||||
|
||||
actual = pipeline._encode_text_hidden(torch.tensor([1, 2]), {})
|
||||
|
||||
assert actual is expected
|
||||
pipeline.text_encoder.load_to_device.assert_called_once_with()
|
||||
pipeline.text_encoder.encode_ids.assert_called_once()
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
@@ -190,3 +190,90 @@ def test_condition_noise_accepts_a_reference_video_longer_than_the_target():
|
||||
noise_aug=0.5,
|
||||
)
|
||||
assert result.shape == rows.shape
|
||||
|
||||
|
||||
def test_explicit_seq_len_pins_one_shape_across_prompt_lengths():
|
||||
"""Prompts of different token counts must land on the same packed length.
|
||||
|
||||
Without the pin they fall into different 64-row buckets, and each bucket is
|
||||
a packed shape the compiled transformer has not seen before.
|
||||
"""
|
||||
from vllm_omni.diffusion.models.minimax_h3.packed_sequence import (
|
||||
minimax_h3_packed_sequence,
|
||||
)
|
||||
|
||||
common = dict(
|
||||
latent_t=2,
|
||||
latent_h=4,
|
||||
latent_w=6,
|
||||
audio_t=3,
|
||||
include_keyframe_cond=False,
|
||||
)
|
||||
# used = text_len + 18 rows: 22 -> bucket 64; 118 -> bucket 128.
|
||||
short = minimax_h3_packed_sequence(text_len=4, **common)
|
||||
long = minimax_h3_packed_sequence(text_len=100, **common)
|
||||
assert int(short["seq_len"]) == 64
|
||||
assert int(long["seq_len"]) == 128
|
||||
|
||||
short_pinned = minimax_h3_packed_sequence(text_len=4, seq_len=192, **common)
|
||||
long_pinned = minimax_h3_packed_sequence(text_len=100, seq_len=192, **common)
|
||||
assert int(short_pinned["seq_len"]) == int(long_pinned["seq_len"]) == 192
|
||||
# The pin only adds padding rows; the used prefix is untouched.
|
||||
assert int(short_pinned["cu_seqlens"][1]) == int(short["cu_seqlens"][1]) == 22
|
||||
assert int(long_pinned["cu_seqlens"][1]) == int(long["cu_seqlens"][1]) == 118
|
||||
assert short_pinned["img_pos"].tolist() == short["img_pos"].tolist()
|
||||
|
||||
|
||||
def test_the_ref2va_packer_pins_one_shape_across_prompt_lengths_too():
|
||||
"""Both packers share one resolver, so both honour the pin."""
|
||||
from vllm_omni.diffusion.models.minimax_h3.packed_sequence import (
|
||||
minimax_h3_packed_sequence_ref2va_blocks,
|
||||
)
|
||||
|
||||
common = dict(
|
||||
latent_t=2,
|
||||
latent_h=4,
|
||||
latent_w=6,
|
||||
audio_t=3,
|
||||
ref_blocks=[{"kind": "audio", "ref_audio_t": 1}],
|
||||
)
|
||||
short = minimax_h3_packed_sequence_ref2va_blocks(text_len=4, **common)
|
||||
long = minimax_h3_packed_sequence_ref2va_blocks(text_len=100, **common)
|
||||
assert int(short["seq_len"]) != int(long["seq_len"])
|
||||
|
||||
short_pinned = minimax_h3_packed_sequence_ref2va_blocks(text_len=4, seq_len=192, **common)
|
||||
long_pinned = minimax_h3_packed_sequence_ref2va_blocks(text_len=100, seq_len=192, **common)
|
||||
assert int(short_pinned["seq_len"]) == int(long_pinned["seq_len"]) == 192
|
||||
assert int(short_pinned["cu_seqlens"][1]) == int(short["cu_seqlens"][1])
|
||||
assert int(long_pinned["cu_seqlens"][1]) == int(long["cu_seqlens"][1])
|
||||
|
||||
|
||||
def test_explicit_seq_len_below_used_rows_is_a_client_error():
|
||||
"""Both packers reject an under-sized pin as a 4xx, not a server error."""
|
||||
from vllm_omni.diffusion.models.minimax_h3.packed_sequence import (
|
||||
minimax_h3_packed_sequence,
|
||||
minimax_h3_packed_sequence_ref2va_blocks,
|
||||
)
|
||||
from vllm_omni.errors import OmniClientError
|
||||
|
||||
with pytest.raises(OmniClientError, match="used rows"):
|
||||
minimax_h3_packed_sequence(
|
||||
text_len=4,
|
||||
latent_t=2,
|
||||
latent_h=4,
|
||||
latent_w=6,
|
||||
audio_t=3,
|
||||
include_keyframe_cond=False,
|
||||
seq_len=8,
|
||||
)
|
||||
|
||||
with pytest.raises(OmniClientError, match="used rows"):
|
||||
minimax_h3_packed_sequence_ref2va_blocks(
|
||||
text_len=4,
|
||||
latent_t=2,
|
||||
latent_h=4,
|
||||
latent_w=6,
|
||||
audio_t=3,
|
||||
ref_blocks=[{"kind": "audio", "ref_audio_t": 1}],
|
||||
seq_len=8,
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
@@ -41,7 +42,7 @@ class _FakeAttention(nn.Module):
|
||||
super().__init__()
|
||||
|
||||
|
||||
def _small_od_config():
|
||||
def _small_od_config(*, class_name=None):
|
||||
arch = {
|
||||
"num_layers": 1,
|
||||
"token_refiner_num_layers": 1,
|
||||
@@ -60,6 +61,8 @@ def _small_od_config():
|
||||
"final_adaln_out_features": 2 * 8,
|
||||
"rope_inv_freq_len": 2,
|
||||
}
|
||||
if class_name is not None:
|
||||
arch["_class_name"] = class_name
|
||||
return SimpleNamespace(
|
||||
tf_model_config=arch,
|
||||
parallel_config=SimpleNamespace(ulysses_degree=1),
|
||||
@@ -133,6 +136,24 @@ def test_fp8_scope_and_prefix_propagation(monkeypatch):
|
||||
)
|
||||
|
||||
|
||||
def test_explicit_native_weight_format_overrides_diffusers_class_name(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import minimax_h3_transformer as h3
|
||||
|
||||
monkeypatch.setattr(h3, "ColumnParallelLinear", _FakeLinear)
|
||||
monkeypatch.setattr(h3, "MergedColumnParallelLinear", _FakeLinear)
|
||||
monkeypatch.setattr(h3, "QKVParallelLinear", _FakeLinear)
|
||||
monkeypatch.setattr(h3, "RowParallelLinear", _FakeLinear)
|
||||
monkeypatch.setattr(h3, "Attention", _FakeAttention)
|
||||
monkeypatch.setattr(h3, "get_tensor_model_parallel_world_size", lambda: 1)
|
||||
|
||||
model = h3.MiniMaxH3DiTModel(
|
||||
_small_od_config(class_name="MiniMaxH3Transformer3DModel"),
|
||||
diffusers_weights=False,
|
||||
)
|
||||
|
||||
assert model._diffusers_weights is False
|
||||
|
||||
|
||||
class _WeightTarget(nn.Module):
|
||||
def __init__(self, loader):
|
||||
super().__init__()
|
||||
|
||||
@@ -175,7 +175,7 @@ def _generate_joint_output(omni, config: QualityTestConfig):
|
||||
pytest.param(
|
||||
_QUALITY_CONFIG,
|
||||
id=_QUALITY_CONFIG.id,
|
||||
marks=hardware_marks(res={"cuda": _QUALITY_CONFIG.gpu}, num_cards=2),
|
||||
marks=hardware_marks(res={"cuda": ["H100", "B200"]}, num_cards=2),
|
||||
)
|
||||
],
|
||||
)
|
||||
@@ -186,10 +186,15 @@ def test_minimax_h3_quantization_quality(config: QualityTestConfig):
|
||||
common_kwargs = {
|
||||
"model": model_ref,
|
||||
"enforce_eager": True,
|
||||
"tensor_parallel_size": 2,
|
||||
# The fused BF16 baseline only fits on H100-80GB with encoder TP.
|
||||
# This test loads the direct FL2VA diffusion pipeline (not the
|
||||
# disaggregated two-stage MiniMax-H3 pipeline), so configure both the
|
||||
# DiT world and the Qwen3-VL encoder TP explicitly.
|
||||
"tensor_parallel_size": 2,
|
||||
"text_encoder_tp_size": 2,
|
||||
"vae_use_tiling": True,
|
||||
# MiniMax H3's VAE is implemented by code shipped with the checkpoint.
|
||||
"trust_remote_code": True,
|
||||
}
|
||||
|
||||
omni_bl = Omni(**common_kwargs)
|
||||
|
||||
@@ -27,6 +27,9 @@ class _SegmentMeanModel:
|
||||
assignment shows up as a numeric difference instead of passing silently.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.calls: list[dict[str, torch.Tensor]] = []
|
||||
|
||||
def __call__(self, **kwargs):
|
||||
x = kwargs["x"][0]
|
||||
audio_x = kwargs["audio_x"][0]
|
||||
@@ -45,6 +48,14 @@ class _SegmentMeanModel:
|
||||
pooled_audio[start:stop] = audio_x[start:stop].mean(dim=0, keepdim=True)
|
||||
|
||||
row_timesteps = kwargs["unique_timesteps"][kwargs["inverse_indices"]].unsqueeze(-1)
|
||||
self.calls.append(
|
||||
{
|
||||
"video_rows": x[img_pos].clone(),
|
||||
"audio_rows": audio_x[audio_pos].clone(),
|
||||
"video_timesteps": row_timesteps[img_pos, 0].clone(),
|
||||
"audio_timesteps": row_timesteps[audio_pos, 0].clone(),
|
||||
}
|
||||
)
|
||||
video = (pooled_video + 0.5 * x + row_timesteps)[img_pos]
|
||||
audio = (pooled_audio + 0.5 * audio_x + row_timesteps)[audio_pos]
|
||||
if not kwargs.get("skip_mask_out_condition", False):
|
||||
@@ -110,6 +121,8 @@ def _step_pipeline(model, *, packed_batch_supported: bool = True):
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
pipeline.load_text_encoder = False
|
||||
pipeline.load_vae_encoder = False
|
||||
pipeline.transformer = model
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline._transformer_for_task = lambda task: model
|
||||
@@ -154,9 +167,77 @@ def test_step_execution_matches_request_mode_denoise_loop():
|
||||
torch.testing.assert_close(state.extra[mod._STEP_AUDIO_ROWS], reference_audio)
|
||||
|
||||
|
||||
def test_batched_step_execution_matches_independent_requests():
|
||||
def test_step_execution_matches_request_mode_with_latent_edits():
|
||||
"""Masked model rows, row timesteps, and scheduler math match both paths."""
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
|
||||
from vllm_omni.diffusion.models.minimax_h3.denoise_loop import minimax_h3_denoise_loop
|
||||
from vllm_omni.diffusion.models.minimax_h3.latent_mask import MiniMaxH3LatentEdit
|
||||
|
||||
model = _SegmentMeanModel()
|
||||
branch, video_rows, audio_rows = _make_branch(text_len=9, latent_t=2, latent_h=4, latent_w=6, audio_t=3, seed=51)
|
||||
sigmas_video = _sigmas(5, 12.0)
|
||||
sigmas_audio = _sigmas(5, 3.0)
|
||||
video_clean = torch.linspace(-1.0, 1.0, video_rows.numel()).reshape_as(video_rows)
|
||||
audio_clean = torch.linspace(1.0, -1.0, audio_rows.numel()).reshape_as(audio_rows)
|
||||
video_mask = torch.linspace(0.0, 1.0, video_rows.shape[0])
|
||||
video_edit = MiniMaxH3LatentEdit.from_rows(
|
||||
video_clean,
|
||||
0.999 * video_clean + 0.001 * video_rows,
|
||||
video_mask,
|
||||
video_mask,
|
||||
)
|
||||
audio_mask = torch.tensor([0.0, 0.2, 0.4, 0.6, 0.8, 1.0])
|
||||
audio_edit = MiniMaxH3LatentEdit.from_rows(
|
||||
audio_clean,
|
||||
audio_clean,
|
||||
audio_mask,
|
||||
audio_mask,
|
||||
)
|
||||
|
||||
reference_video, reference_audio = minimax_h3_denoise_loop(
|
||||
model=model,
|
||||
positive=branch,
|
||||
initial_video_rows=video_rows,
|
||||
initial_audio_rows=audio_rows,
|
||||
keyframe_cond_rows=None,
|
||||
video_edit=video_edit,
|
||||
audio_edit=audio_edit,
|
||||
sigmas_video=sigmas_video,
|
||||
sigmas_audio=sigmas_audio,
|
||||
device=torch.device("cpu"),
|
||||
)
|
||||
request_first_call = model.calls[0]
|
||||
torch.testing.assert_close(request_first_call["video_rows"], video_edit.model_rows(video_rows))
|
||||
torch.testing.assert_close(request_first_call["audio_rows"], audio_edit.model_rows(audio_rows))
|
||||
torch.testing.assert_close(
|
||||
request_first_call["video_timesteps"],
|
||||
video_edit.target_timesteps(1.0 - sigmas_video[0], 0.999, sigma=sigmas_video[0]),
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
request_first_call["audio_timesteps"],
|
||||
audio_edit.target_timesteps(1.0 - sigmas_audio[0], 1.0, sigma=sigmas_audio[0]),
|
||||
)
|
||||
model.calls.clear()
|
||||
|
||||
state = _make_state("req-edit", model, branch, video_rows, audio_rows, sigmas_video, sigmas_audio)
|
||||
state.extra[mod._STEP_VIDEO_EDIT] = video_edit
|
||||
state.extra[mod._STEP_AUDIO_EDIT] = audio_edit
|
||||
pipeline = _step_pipeline(model)
|
||||
while not state.denoise_completed:
|
||||
velocity = pipeline.denoise_step(SimpleNamespace(states=(state,)), states=[state])
|
||||
pipeline.step_scheduler(state, velocity)
|
||||
|
||||
for name, expected in request_first_call.items():
|
||||
torch.testing.assert_close(model.calls[0][name], expected)
|
||||
torch.testing.assert_close(state.latents, reference_video)
|
||||
torch.testing.assert_close(state.extra[mod._STEP_AUDIO_ROWS], reference_audio)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lock_audio", [False, True])
|
||||
def test_batched_step_execution_matches_independent_requests(lock_audio):
|
||||
"""Two co-batched requests must land where they would have landed alone."""
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
|
||||
from vllm_omni.diffusion.models.minimax_h3.latent_mask import MiniMaxH3LatentEdit
|
||||
|
||||
model = _SegmentMeanModel()
|
||||
specs = [
|
||||
@@ -169,20 +250,40 @@ def test_batched_step_execution_matches_independent_requests():
|
||||
|
||||
pipeline = _step_pipeline(model)
|
||||
|
||||
def make_state(request_id: str, index: int):
|
||||
branch, video_rows, audio_rows = _make_branch(**specs[index])
|
||||
sigmas_video, sigmas_audio = schedules[index]
|
||||
state = _make_state(request_id, model, branch, video_rows, audio_rows, sigmas_video, sigmas_audio)
|
||||
video_mask = torch.linspace(0.1 * index, 0.8 + 0.1 * index, video_rows.shape[0])
|
||||
video_edit = MiniMaxH3LatentEdit.from_rows(
|
||||
torch.full_like(video_rows, 10.0 + index),
|
||||
torch.full_like(video_rows, 20.0 + index),
|
||||
video_mask,
|
||||
video_mask,
|
||||
)
|
||||
audio_mask = torch.linspace(0.2 - 0.1 * index, 1.0 - 0.1 * index, audio_rows.shape[0])
|
||||
audio_edit = MiniMaxH3LatentEdit.from_rows(
|
||||
torch.full_like(audio_rows, 30.0 + index),
|
||||
torch.full_like(audio_rows, 40.0 + index),
|
||||
audio_mask,
|
||||
audio_mask,
|
||||
)
|
||||
state.extra[mod._STEP_VIDEO_EDIT] = video_edit
|
||||
if lock_audio and index == 0:
|
||||
branch.locked_audio_rows = audio_rows.clone()
|
||||
else:
|
||||
state.extra[mod._STEP_AUDIO_EDIT] = audio_edit
|
||||
return state
|
||||
|
||||
alone: list[tuple[torch.Tensor, torch.Tensor]] = []
|
||||
for spec, (sigmas_video, sigmas_audio) in zip(specs, schedules):
|
||||
branch, video_rows, audio_rows = _make_branch(**spec)
|
||||
state = _make_state("solo", model, branch, video_rows, audio_rows, sigmas_video, sigmas_audio)
|
||||
for index in range(len(specs)):
|
||||
state = make_state("solo", index)
|
||||
while not state.denoise_completed:
|
||||
pipeline.step_scheduler(state, pipeline.denoise_step(SimpleNamespace(states=(state,)), states=[state]))
|
||||
alone.append((state.latents, state.extra[mod._STEP_AUDIO_ROWS]))
|
||||
|
||||
states = []
|
||||
for index, (spec, (sigmas_video, sigmas_audio)) in enumerate(zip(specs, schedules)):
|
||||
branch, video_rows, audio_rows = _make_branch(**spec)
|
||||
if index == 0:
|
||||
assert branch.used_len == branch.seq_len
|
||||
states.append(_make_state(f"req-{index}", model, branch, video_rows, audio_rows, sigmas_video, sigmas_audio))
|
||||
states = [make_state(f"req-{index}", index) for index in range(len(specs))]
|
||||
assert states[0].extra[mod._STEP_BRANCH].used_len == states[0].extra[mod._STEP_BRANCH].seq_len
|
||||
|
||||
active = list(states)
|
||||
while active:
|
||||
@@ -284,15 +385,20 @@ def test_mixed_step_batch_leaves_gated_attention_dense():
|
||||
assert recorder.denoise_timestep is None
|
||||
|
||||
|
||||
def test_prepare_encode_seeds_runner_visible_state(monkeypatch):
|
||||
@pytest.mark.parametrize("batch_frames", [1, 33])
|
||||
def test_prepare_encode_seeds_runner_visible_state(monkeypatch, batch_frames):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
|
||||
|
||||
branch, video_rows, audio_rows = _make_branch(text_len=9, latent_t=2, latent_h=4, latent_w=6, audio_t=3, seed=8)
|
||||
video_edit = object()
|
||||
audio_edit = object()
|
||||
sigmas_video = _sigmas(6, 12.0)
|
||||
sigmas_audio = _sigmas(6, 3.0)
|
||||
context = {
|
||||
"height": 96,
|
||||
"width": 64,
|
||||
"preencode_mp4": True,
|
||||
"preencode_batch_frames": batch_frames,
|
||||
"latent_t": 2,
|
||||
"latent_h": 4,
|
||||
"latent_w": 6,
|
||||
@@ -301,8 +407,17 @@ def test_prepare_encode_seeds_runner_visible_state(monkeypatch):
|
||||
}
|
||||
|
||||
pipeline = _step_pipeline(_SegmentMeanModel())
|
||||
monkeypatch.setattr(mod.MiniMaxH3Pipeline, "_extract_prompt", staticmethod(lambda _: ("a prompt", {})))
|
||||
monkeypatch.setattr(mod.MiniMaxH3Pipeline, "_prepare_request_inputs", lambda self, **_: context)
|
||||
conditioning = object()
|
||||
monkeypatch.setattr(
|
||||
mod.MiniMaxH3Pipeline,
|
||||
"_extract_encoder_conditioning",
|
||||
staticmethod(lambda _: conditioning),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mod.MiniMaxH3Pipeline,
|
||||
"_prepare_encoder_conditioning_inputs",
|
||||
lambda self, value, sampling: context,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mod.MiniMaxH3Pipeline,
|
||||
"_build_denoise_inputs",
|
||||
@@ -312,6 +427,8 @@ def test_prepare_encode_seeds_runner_visible_state(monkeypatch):
|
||||
"audio_rows": audio_rows,
|
||||
"cond_anchor": None,
|
||||
"audio_anchor": None,
|
||||
"video_edit": video_edit,
|
||||
"audio_edit": audio_edit,
|
||||
"sigmas_video": sigmas_video,
|
||||
"sigmas_audio": sigmas_audio,
|
||||
},
|
||||
@@ -333,8 +450,22 @@ def test_prepare_encode_seeds_runner_visible_state(monkeypatch):
|
||||
assert state.do_true_cfg is False
|
||||
torch.testing.assert_close(state.current_timestep, torch.tensor(1.0 - sigmas_video[0]))
|
||||
assert state.extra[mod._STEP_BRANCH] is branch
|
||||
assert state.extra[mod._STEP_VIDEO_EDIT] is video_edit
|
||||
assert state.extra[mod._STEP_AUDIO_EDIT] is audio_edit
|
||||
assert state.extra[mod._STEP_SHAPE]["height"] == 96
|
||||
|
||||
pipeline.od_config = SimpleNamespace()
|
||||
monkeypatch.setattr(pipeline, "_unpack_denoised_rows", lambda *args, **kwargs: (torch.zeros(1), torch.zeros(1)))
|
||||
calls = []
|
||||
|
||||
def decode_to_mp4(*args, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return b"mp4"
|
||||
|
||||
monkeypatch.setattr(pipeline, "decode_to_mp4", decode_to_mp4)
|
||||
assert pipeline.post_decode(state).output == (b"mp4", None)
|
||||
assert calls[0]["batch_frames"] == batch_frames
|
||||
|
||||
|
||||
def test_prepare_encode_rejects_request_mode_only_features():
|
||||
"""Multi-output and DLO have no representation in the step contract."""
|
||||
@@ -426,48 +557,31 @@ def test_packed_batch_rejects_backends_that_cannot_isolate_requests(attention):
|
||||
assert MiniMaxH3Pipeline._packed_batch_supported(_FakeTransformer([attention])) is False
|
||||
|
||||
|
||||
def test_broadcast_rank0_exception_single_rank_reraises():
|
||||
"""Single-rank execution has no group; the helper just reraises."""
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
|
||||
_broadcast_rank0_exception,
|
||||
def test_locked_driving_audio_is_clean_and_unchanged_during_denoising():
|
||||
from vllm_omni.diffusion.models.minimax_h3.denoise_loop import minimax_h3_denoise_loop
|
||||
|
||||
branch, video, audio = _make_branch(text_len=3, latent_t=2, latent_h=2, latent_w=2, audio_t=3, seed=7)
|
||||
branch.locked_audio_rows = audio.clone()
|
||||
seen = []
|
||||
|
||||
def model(**kwargs):
|
||||
positions = kwargs["audio_pos_info"]["position_ids"]
|
||||
times = kwargs["unique_timesteps"][kwargs["inverse_indices"]]
|
||||
torch.testing.assert_close(times[positions], torch.ones_like(times[positions]))
|
||||
torch.testing.assert_close(kwargs["audio_x"][0, positions], audio)
|
||||
seen.append(True)
|
||||
return torch.ones_like(video), torch.ones_like(audio)
|
||||
|
||||
result_video, result_audio = minimax_h3_denoise_loop(
|
||||
model=model,
|
||||
positive=branch,
|
||||
initial_video_rows=video,
|
||||
initial_audio_rows=audio,
|
||||
keyframe_cond_rows=None,
|
||||
sigmas_video=[1.0, 0.5, 0.0],
|
||||
sigmas_audio=[1.0, 0.25, 0.0],
|
||||
device=torch.device("cpu"),
|
||||
)
|
||||
from vllm_omni.errors import OmniClientError
|
||||
|
||||
_broadcast_rank0_exception(None)
|
||||
with pytest.raises(OmniClientError, match="bad ref"):
|
||||
_broadcast_rank0_exception(OmniClientError("bad ref"))
|
||||
|
||||
|
||||
def test_broadcast_rank0_exception_propagates_to_non_zero_ranks(monkeypatch):
|
||||
"""A rank-0 error becomes a matching client error on every other DiT rank."""
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
|
||||
from vllm_omni.errors import OmniClientError
|
||||
|
||||
def fake_rank_world(rank):
|
||||
return lambda: (object(), rank, 4)
|
||||
|
||||
def make_broadcast(rank0_payload):
|
||||
def fake_broadcast(payload_list, *, src, group):
|
||||
payload_list[0] = rank0_payload
|
||||
|
||||
return fake_broadcast
|
||||
|
||||
monkeypatch.setattr(mod, "_dit_rank_world", fake_rank_world(0))
|
||||
err = OmniClientError("invalid reference-video file", status_code=422, error_type="UnprocessableEntityError")
|
||||
rank0_payload = {
|
||||
"type": type(err).__name__,
|
||||
"message": str(err),
|
||||
"status_code": err.status_code,
|
||||
"error_type": err.error_type,
|
||||
}
|
||||
monkeypatch.setattr(mod.dist, "broadcast_object_list", make_broadcast(rank0_payload))
|
||||
with pytest.raises(OmniClientError) as rank0_info:
|
||||
mod._broadcast_rank0_exception(err)
|
||||
assert rank0_info.value is err
|
||||
|
||||
monkeypatch.setattr(mod, "_dit_rank_world", fake_rank_world(2))
|
||||
with pytest.raises(OmniClientError) as rank2_info:
|
||||
mod._broadcast_rank0_exception(None)
|
||||
assert rank2_info.value.status_code == 422
|
||||
assert rank2_info.value.error_type == "UnprocessableEntityError"
|
||||
assert "invalid reference-video file" in str(rank2_info.value)
|
||||
assert len(seen) == 2
|
||||
torch.testing.assert_close(result_audio, audio)
|
||||
assert not torch.equal(result_video, video)
|
||||
@@ -0,0 +1,139 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""CPU tests for in-place decode revert and stage-boundary cache release.
|
||||
|
||||
``_revert_decoded_inplace`` must be numerically identical to the checkpoint's
|
||||
``revert_tensor`` (denormalize → clamp → rearrange) with zero whole-video
|
||||
copies, and ``_release_stage_cache`` must force the allocator-cache release
|
||||
(or fall back to a plain empty_cache when no component cache exists).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 as pipeline_module
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3.vae import MiniMaxH3VideoVAE
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
DENORM_MEAN = (-2.11628412, -2.03571429, -1.80444444) # -imagenet_mean/std
|
||||
DENORM_STD = (4.36681223, 4.46428571, 4.44444444) # 1/imagenet_std
|
||||
|
||||
|
||||
class _FakeDenormalize:
|
||||
def __init__(self, mean, std):
|
||||
self.mean = mean
|
||||
self.std = std
|
||||
|
||||
|
||||
class _FakeProcessor:
|
||||
def __init__(self):
|
||||
self.transform_rev = _FakeDenormalize(DENORM_MEAN, DENORM_STD)
|
||||
self.use_3d_conv = True
|
||||
self.revert_calls = 0
|
||||
|
||||
def revert_tensor(self, tensor):
|
||||
self.revert_calls += 1
|
||||
return _legacy_revert(tensor)
|
||||
|
||||
|
||||
class _FakeCheckpointModel:
|
||||
def __init__(self):
|
||||
self.processor = _FakeProcessor()
|
||||
|
||||
|
||||
def _vae(model=None):
|
||||
vae = object.__new__(MiniMaxH3VideoVAE)
|
||||
vae.model = model if model is not None else _FakeCheckpointModel()
|
||||
return vae
|
||||
|
||||
|
||||
def _legacy_revert(tensor: torch.Tensor) -> torch.Tensor:
|
||||
"""The checkpoint's revert_tensor, op for op, on the CPU (B == 1)."""
|
||||
if tensor.ndim == 4:
|
||||
tensor = tensor.unsqueeze(2)
|
||||
assert tensor.ndim == 5
|
||||
# Normalize runs on the (b t) c h w rearrangement; at B == 1 that is a
|
||||
# view of (B, C, T, H, W), so the per-channel broadcast is equivalent.
|
||||
mean = torch.tensor(DENORM_MEAN).view(1, 3, 1, 1, 1)
|
||||
std = torch.tensor(DENORM_STD).view(1, 3, 1, 1, 1)
|
||||
tensor = (tensor - mean) / std
|
||||
return tensor.clamp(0, 1)
|
||||
|
||||
|
||||
def test_revert_inplace_matches_legacy_bitwise():
|
||||
decoded = torch.randn(1, 3, 7, 6, 8).clamp_(-3, 3)
|
||||
expected = _legacy_revert(decoded.clone())
|
||||
out = _vae()._revert_decoded_inplace(decoded.clone())
|
||||
assert out.shape == expected.shape == (1, 3, 7, 6, 8)
|
||||
assert torch.equal(out, expected)
|
||||
|
||||
|
||||
def test_revert_inplace_mutates_without_extra_copies():
|
||||
decoded = torch.randn(1, 3, 5, 4, 4)
|
||||
out = _vae()._revert_decoded_inplace(decoded)
|
||||
assert out is decoded # in-place: no whole-video copy materializes
|
||||
assert out.is_contiguous()
|
||||
|
||||
|
||||
def test_revert_inplace_unsqueezes_4d_input():
|
||||
# 4-D decoded tensors follow the (B, C, H, W) image contract: unsqueeze(2)
|
||||
# lands T=1, matching the checkpoint's revert_tensor branch.
|
||||
decoded = torch.randn(1, 3, 6, 8)
|
||||
expected = _legacy_revert(decoded.clone())
|
||||
out = _vae()._revert_decoded_inplace(decoded.clone())
|
||||
assert torch.equal(out, expected)
|
||||
|
||||
|
||||
def test_revert_inplace_falls_back_without_contract():
|
||||
vae = _vae()
|
||||
vae.model.processor.transform_rev = type("Empty", (), {})()
|
||||
decoded = torch.randn(1, 3, 5, 4, 4)
|
||||
out = vae._revert_decoded_inplace(decoded)
|
||||
assert vae.model.processor.revert_calls == 1
|
||||
assert torch.equal(out, _legacy_revert(decoded))
|
||||
|
||||
|
||||
def test_revert_inplace_falls_back_on_bad_arity():
|
||||
vae = _vae()
|
||||
vae.model.processor.transform_rev = _FakeDenormalize((0.5, 0.5), (1.0, 1.0, 1.0))
|
||||
vae._revert_decoded_inplace(torch.randn(1, 3, 5, 4, 4))
|
||||
assert vae.model.processor.revert_calls == 1
|
||||
|
||||
|
||||
class _RecordingCache:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def release_if_needed(self, *, force=False):
|
||||
self.calls.append(force)
|
||||
return True
|
||||
|
||||
|
||||
def _pipeline():
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
pipeline._dlo_component_cache = None
|
||||
return pipeline
|
||||
|
||||
|
||||
def test_stage_cache_release_forces_component_cache():
|
||||
pipeline = _pipeline()
|
||||
cache = _RecordingCache()
|
||||
pipeline._dlo_component_cache = cache
|
||||
pipeline._release_stage_cache()
|
||||
assert cache.calls == [True]
|
||||
|
||||
|
||||
def test_stage_cache_release_falls_back_to_empty_cache(monkeypatch):
|
||||
released = []
|
||||
monkeypatch.setattr(
|
||||
pipeline_module.current_omni_platform,
|
||||
"empty_cache",
|
||||
lambda: released.append(True),
|
||||
raising=True,
|
||||
)
|
||||
pipeline = _pipeline()
|
||||
pipeline._release_stage_cache()
|
||||
assert released == [True]
|
||||
@@ -0,0 +1,243 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""CPU tests for the streaming uint8 preparation in the MiniMax-H3 video VAE.
|
||||
|
||||
``_stream_prepare_video_tensor`` must be numerically identical to the
|
||||
checkpoint's whole-video numpy path (``convert_numpy_to_tensor`` →
|
||||
``transform_tensor`` → ``transpose``) while padding on the host, so the
|
||||
device-side copies never materialize.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3.vae import MiniMaxH3VideoVAE
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
IMAGENET_MEAN = (0.485, 0.456, 0.406)
|
||||
IMAGENET_STD = (0.229, 0.224, 0.225)
|
||||
CLIP_LENGTH = 4
|
||||
|
||||
|
||||
class _FakeNormalize:
|
||||
def __init__(self, mean, std):
|
||||
self.mean = mean
|
||||
self.std = std
|
||||
|
||||
|
||||
class _FakeProcessor:
|
||||
# Read by the stream prep via getattr(..., False); annotation only so
|
||||
# tests can attach it per case.
|
||||
isolated_last_frame: bool
|
||||
|
||||
def __init__(self):
|
||||
self.transform = _FakeNormalize(IMAGENET_MEAN, IMAGENET_STD)
|
||||
|
||||
|
||||
class _FakeCheckpointModel:
|
||||
def __init__(self, **overrides):
|
||||
self.clip_length = CLIP_LENGTH
|
||||
self.isolated_first_frame = False
|
||||
self.frame_pre_padding = 0
|
||||
self.processor = _FakeProcessor()
|
||||
for key, value in overrides.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
|
||||
def _vae(model=None):
|
||||
vae = object.__new__(MiniMaxH3VideoVAE)
|
||||
vae.model = model if model is not None else _FakeCheckpointModel()
|
||||
return vae
|
||||
|
||||
|
||||
def _legacy_reference(frames: np.ndarray) -> torch.Tensor:
|
||||
"""The checkpoint's numpy path, op for op, on the CPU.
|
||||
|
||||
convert_numpy_to_tensor: astype(fp32) → permute → ÷255
|
||||
transform_tensor: Normalize = (x - mean) / std
|
||||
encode_videos: transpose(0, 1)
|
||||
"""
|
||||
tensor = torch.from_numpy(frames.astype(np.float32)).permute(0, 3, 1, 2)
|
||||
tensor = tensor / 255.0
|
||||
mean = torch.tensor(IMAGENET_MEAN).view(1, 3, 1, 1)
|
||||
std = torch.tensor(IMAGENET_STD).view(1, 3, 1, 1)
|
||||
tensor = (tensor - mean) / std
|
||||
return tensor.transpose(0, 1).contiguous()
|
||||
|
||||
|
||||
def _video(num_frames, height=6, width=8, seed=0):
|
||||
rng = np.random.default_rng(seed)
|
||||
return rng.integers(0, 256, size=(num_frames, height, width, 3), dtype=np.uint8)
|
||||
|
||||
|
||||
def _pad_frames(frames: np.ndarray, pad: int) -> np.ndarray:
|
||||
if not pad:
|
||||
return frames
|
||||
return np.concatenate([frames, np.repeat(frames[-1:], pad, axis=0)])
|
||||
|
||||
|
||||
def test_stream_prep_matches_legacy_path_bitwise():
|
||||
frames = _video(10)
|
||||
out = _vae()._stream_prepare_video_tensor(frames, torch.device("cpu"))
|
||||
assert out is not None
|
||||
assert out.shape == (3, 12, 6, 8) # pad = (0 - 10) % 4 == 2
|
||||
assert out.dtype == torch.float32
|
||||
assert torch.equal(out, _legacy_reference(_pad_frames(frames, 2)))
|
||||
|
||||
|
||||
def test_stream_prep_isolated_first_frame_without_last_frame_skips_pad():
|
||||
# Asymmetric checkpoint (isolated first frame, no isolated last frame):
|
||||
# trim-stable lengths are k*clip + 0, which never satisfy the
|
||||
# encode_temporal offset of 1, so the remote re-pads through its own
|
||||
# whole-video cat regardless. Padding here would only upload frames the
|
||||
# remote trim discards, so the legacy frame count is kept.
|
||||
model = _FakeCheckpointModel(isolated_first_frame=True)
|
||||
frames = _video(10)
|
||||
out = _vae(model)._stream_prepare_video_tensor(frames, torch.device("cpu"))
|
||||
assert out is not None
|
||||
assert out.shape[1] == 10
|
||||
assert torch.equal(out, _legacy_reference(frames))
|
||||
|
||||
|
||||
def test_stream_prep_symmetric_isolated_frames_pad_to_trim_stable():
|
||||
# isolated first + last frames: offset == tail % clip == 1, so padding
|
||||
# to the next trim-stable length makes the remote trim a no-op and keeps
|
||||
# encode_temporal from re-padding: T=10, clip=4, tail=1 → 3*4+1 == 13.
|
||||
model = _FakeCheckpointModel(isolated_first_frame=True)
|
||||
model.processor.isolated_last_frame = True
|
||||
frames = _video(10)
|
||||
out = _vae(model)._stream_prepare_video_tensor(frames, torch.device("cpu"))
|
||||
assert out is not None
|
||||
assert out.shape[1] == 13
|
||||
assert out.shape[1] % 4 == 1
|
||||
assert torch.equal(out, _legacy_reference(_pad_frames(frames, 3)))
|
||||
|
||||
|
||||
def test_stream_prep_uses_processor_align_when_available():
|
||||
calls = []
|
||||
|
||||
class _AlignedProcessor(_FakeProcessor):
|
||||
def align_video_length(self, video_length, mode="pad", granularity="chunk"):
|
||||
calls.append((video_length, mode, granularity))
|
||||
return 5
|
||||
|
||||
model = _FakeCheckpointModel()
|
||||
model.processor = _AlignedProcessor()
|
||||
frames = _video(10)
|
||||
out = _vae(model)._stream_prepare_video_tensor(frames, torch.device("cpu"))
|
||||
assert out is not None
|
||||
assert calls == [(10, "pad", "chunk")]
|
||||
assert out.shape[1] == 15
|
||||
assert torch.equal(out, _legacy_reference(_pad_frames(frames, 5)))
|
||||
|
||||
|
||||
def test_stream_prep_pads_with_last_frame():
|
||||
frames = _video(2) # pad = (0 - 2) % 4 == 2
|
||||
out = _vae()._stream_prepare_video_tensor(frames, torch.device("cpu"))
|
||||
padded = np.concatenate([frames, np.repeat(frames[-1:], 2, axis=0)])
|
||||
assert torch.equal(out, _legacy_reference(padded))
|
||||
|
||||
|
||||
def test_stream_prep_aligned_input_needs_no_pad():
|
||||
frames = _video(8) # 8 % 4 == 0
|
||||
out = _vae()._stream_prepare_video_tensor(frames, torch.device("cpu"))
|
||||
assert out.shape[1] == 8
|
||||
assert torch.equal(out, _legacy_reference(frames))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"frames",
|
||||
[
|
||||
_video(5).astype(np.float32), # not uint8
|
||||
_video(5)[:, :, :, :1], # single channel
|
||||
np.zeros((5, 6, 8, 4), dtype=np.uint8), # four channels
|
||||
np.zeros((5, 6, 8), dtype=np.uint8), # missing channel axis
|
||||
np.zeros((0, 6, 8, 3), dtype=np.uint8), # empty
|
||||
],
|
||||
)
|
||||
def test_stream_prep_rejects_unsupported_inputs(frames):
|
||||
assert _vae()._stream_prepare_video_tensor(frames, torch.device("cpu")) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
_FakeCheckpointModel(clip_length=None),
|
||||
_FakeCheckpointModel(clip_length=0),
|
||||
type("NoProcessor", (), {"clip_length": CLIP_LENGTH})(),
|
||||
type(
|
||||
"NoTransform",
|
||||
(),
|
||||
{"clip_length": CLIP_LENGTH, "processor": type("P", (), {})()},
|
||||
)(),
|
||||
],
|
||||
)
|
||||
def test_stream_prep_rejects_missing_checkpoint_contract(model):
|
||||
frames = _video(5)
|
||||
assert _vae(model)._stream_prepare_video_tensor(frames, torch.device("cpu")) is None
|
||||
|
||||
|
||||
def test_stream_prep_rejects_bad_norm_arity():
|
||||
model = _FakeCheckpointModel()
|
||||
model.processor.transform = _FakeNormalize((0.5, 0.5), IMAGENET_STD)
|
||||
assert _vae(model)._stream_prepare_video_tensor(_video(5), torch.device("cpu")) is None
|
||||
|
||||
|
||||
class _RecordingModel(_FakeCheckpointModel):
|
||||
"""Feeds encode_video a well-shaped latent and records its input."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.received = None
|
||||
self.latent = torch.randn(1, 4, 3, 4, 4) # (B, C, T', H', W'), even H/W
|
||||
|
||||
def encode_videos(self, videos, use_fp16_latent=False, **kwargs):
|
||||
self.received = videos
|
||||
assert use_fp16_latent is True
|
||||
return [self.latent]
|
||||
|
||||
|
||||
def _vae_for_encode_video():
|
||||
vae = object.__new__(MiniMaxH3VideoVAE)
|
||||
vae.model = _RecordingModel()
|
||||
vae.device_module = torch
|
||||
vae.config_dict = {
|
||||
"latent_channels": 4,
|
||||
"latents_mean": [0.0] * 4,
|
||||
"latents_std": [1.0] * 4,
|
||||
}
|
||||
# A plain tensor (not nn.Parameter): nn.Module.__setattr__ refuses
|
||||
# Parameters on instances built via object.__new__.
|
||||
vae._dummy_parameter = torch.zeros(1, dtype=torch.float32)
|
||||
vae.parameters = lambda: iter([vae._dummy_parameter])
|
||||
return vae
|
||||
|
||||
|
||||
def test_encode_video_streams_uint8_numpy(monkeypatch):
|
||||
monkeypatch.delenv("VLLM_OMNI_VAE_ENCODE_LEGACY_PREP", raising=False)
|
||||
vae = _vae_for_encode_video()
|
||||
rows, shape = vae.encode_video(_video(10))
|
||||
assert isinstance(vae.model.received, list)
|
||||
assert isinstance(vae.model.received[0], torch.Tensor)
|
||||
assert vae.model.received[0].shape == (3, 12, 6, 8)
|
||||
assert rows.shape[0] == shape[0] * shape[1] * shape[2] // 4 # patchify rows
|
||||
|
||||
|
||||
def test_encode_video_legacy_env_escapes_to_checkpoint_path(monkeypatch):
|
||||
monkeypatch.setenv("VLLM_OMNI_VAE_ENCODE_LEGACY_PREP", "1")
|
||||
vae = _vae_for_encode_video()
|
||||
frames = _video(10)
|
||||
vae.encode_video(frames)
|
||||
assert vae.model.received is frames # untouched numpy, checkpoint converts
|
||||
|
||||
|
||||
def test_encode_video_falls_back_when_preparation_unsupported(monkeypatch):
|
||||
monkeypatch.delenv("VLLM_OMNI_VAE_ENCODE_LEGACY_PREP", raising=False)
|
||||
vae = _vae_for_encode_video()
|
||||
vae.model.clip_length = None # contract not discoverable
|
||||
frames = _video(10)
|
||||
vae.encode_video(frames)
|
||||
assert vae.model.received is frames
|
||||
@@ -0,0 +1,473 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""CPU tests for the pinned tiled-VAE gather buffer in the MiniMax-H3 video VAE.
|
||||
|
||||
The checkpoint's ``_all_gather_tiled_results`` allocates its gather output on
|
||||
every call, so with a per-request ``empty_cache()`` the collective lands on a
|
||||
new device address every request and XCCL's per-address registrations pile up.
|
||||
On XPU the adapter replaces that method with an equal-shape gather into a
|
||||
buffer it keeps alive. These tests pin the properties that make it a fix
|
||||
rather than a rewrite: the address is stable, the tiles come back
|
||||
bit-identical and in the same order, the caller owns what it gets back, and no
|
||||
other device is touched.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import types
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module
|
||||
from vllm_omni.diffusion.models.minimax_h3.vae import MiniMaxH3VideoVAE
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
TILE = (1, 3, 2, 4, 4)
|
||||
|
||||
|
||||
class _CheckpointGatherSentinel:
|
||||
"""Stands in for the checkpoint's own bound method.
|
||||
|
||||
Identity is the point: a test asserts this exact object is still installed
|
||||
on the devices the adapter must leave alone.
|
||||
"""
|
||||
|
||||
def __call__(self, tasks, num_tiles): # pragma: no cover - never invoked
|
||||
raise AssertionError("the checkpoint gather must not run in these tests")
|
||||
|
||||
|
||||
class _FakeCheckpointModel:
|
||||
def __init__(self):
|
||||
self._all_gather_tiled_results = _CheckpointGatherSentinel()
|
||||
|
||||
|
||||
def _local_tiles(all_tiles, num_tiles, rank, sp_size):
|
||||
"""One rank's round-robin share, as ``klvae.py:275`` computes it."""
|
||||
return [all_tiles[index] for index in range(rank, num_tiles, sp_size)]
|
||||
|
||||
|
||||
def _reference_gather(all_tiles, num_tiles, sp_size):
|
||||
"""The checkpoint's variable-shape list gather, reimplemented verbatim.
|
||||
|
||||
Written from the control flow of ``_all_gather_tiled_results`` in the
|
||||
checkpoint's ``klvae.py`` (lines 252-273) -- stack each rank's share, gather
|
||||
the list, walk it back into global tile order -- so that it is an
|
||||
independent oracle rather than a restatement of the code under test.
|
||||
"""
|
||||
gathered = [torch.stack(_local_tiles(all_tiles, num_tiles, rank, sp_size), dim=0) for rank in range(sp_size)]
|
||||
results = [None] * num_tiles
|
||||
for rank, rank_tensors in enumerate(gathered):
|
||||
for k in range(rank_tensors.shape[0]):
|
||||
global_index = k * sp_size + rank
|
||||
if global_index >= num_tiles:
|
||||
break
|
||||
results[global_index] = rank_tensors[k]
|
||||
return results
|
||||
|
||||
|
||||
def _peer_stacks(all_tiles, num_tiles, sp_size):
|
||||
"""What every rank hands to the collective, padded the way the adapter pads."""
|
||||
max_tasks = -(-num_tiles // sp_size)
|
||||
stacks = []
|
||||
for rank in range(sp_size):
|
||||
share = _local_tiles(all_tiles, num_tiles, rank, sp_size)
|
||||
stacked = share[0].new_zeros((max_tasks, *share[0].shape))
|
||||
stacked[: len(share)] = torch.stack(share, dim=0)
|
||||
stacks.append(stacked)
|
||||
return stacks
|
||||
|
||||
|
||||
def _tiles(num_tiles, seed=0, dtype=torch.float32):
|
||||
generator = torch.Generator().manual_seed(seed)
|
||||
return [torch.rand(TILE, generator=generator).to(dtype) for _ in range(num_tiles)]
|
||||
|
||||
|
||||
def _workspace_bytes(num_tiles, sp_size, dtype=torch.float32):
|
||||
"""Bytes one gather needs: ``sp_size`` copies of the padded local stack."""
|
||||
max_tasks = -(-num_tiles // sp_size)
|
||||
element_size = torch.empty(0, dtype=dtype).element_size()
|
||||
numel = 1
|
||||
for dim in TILE:
|
||||
numel *= dim
|
||||
return sp_size * max_tasks * numel * element_size
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_collective(monkeypatch):
|
||||
"""Replace the collective with a single-process stand-in.
|
||||
|
||||
``all_gather_into_tensor`` is the only distributed call on this path, so
|
||||
filling the output buffer from a caller-supplied peer list exercises the
|
||||
real padding, keying, addressing and unpacking code without a process
|
||||
group.
|
||||
"""
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
def fake_all_gather_into_tensor(output, input_tensor, group=None, async_op=False):
|
||||
peers = calls[-1]["peers"]
|
||||
assert output.shape == (len(peers), *input_tensor.shape)
|
||||
calls[-1]["sent"] = input_tensor.clone()
|
||||
calls[-1]["buf_ptr"] = output.data_ptr()
|
||||
for rank, peer in enumerate(peers):
|
||||
output[rank].copy_(peer)
|
||||
|
||||
monkeypatch.setattr(dist, "all_gather_into_tensor", fake_all_gather_into_tensor)
|
||||
return calls
|
||||
|
||||
|
||||
class _Records(logging.Handler):
|
||||
"""vllm's logger sets ``propagate=False``, so caplog never sees these."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(level=logging.DEBUG)
|
||||
self.messages = []
|
||||
self.records = []
|
||||
|
||||
def emit(self, record):
|
||||
self.messages.append(record.getMessage())
|
||||
self.records.append((record.levelno, record.getMessage()))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gather_log():
|
||||
handler = _Records()
|
||||
logger = vae_module.logger
|
||||
previous = logger.level
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
try:
|
||||
yield handler
|
||||
finally:
|
||||
logger.removeHandler(handler)
|
||||
logger.setLevel(previous)
|
||||
|
||||
|
||||
def _vae(sp_size, sp_rank=0):
|
||||
"""An adapter instance with the override installed, no checkpoint needed."""
|
||||
state = {
|
||||
"sp_size": sp_size,
|
||||
"sp_rank": sp_rank,
|
||||
"sp_enabled": sp_size > 1,
|
||||
"sp_process_group": object(),
|
||||
}
|
||||
module = types.ModuleType("fake_gather_ckpt.parallel")
|
||||
module.get_parallel_state = lambda: state # type: ignore[attr-defined]
|
||||
sys.modules.setdefault("fake_gather_ckpt", types.ModuleType("fake_gather_ckpt"))
|
||||
sys.modules["fake_gather_ckpt.parallel"] = module
|
||||
|
||||
vae = object.__new__(MiniMaxH3VideoVAE)
|
||||
vae.remote = type("Remote", (), {"__module__": "fake_gather_ckpt.klvae"})()
|
||||
vae.model = _FakeCheckpointModel()
|
||||
vae._device_target = torch.device("xpu")
|
||||
vae._tile_gather_workspace = None
|
||||
vae._tile_gather_stats = {"hits": 0, "allocs": 0, "workspace_bytes": 0}
|
||||
vae._checkpoint_tile_gather = None
|
||||
vae._install_persistent_tile_gather()
|
||||
return vae
|
||||
|
||||
|
||||
def _run(vae, calls, all_tiles, num_tiles, sp_size, sp_rank):
|
||||
calls.append({"peers": _peer_stacks(all_tiles, num_tiles, sp_size)})
|
||||
return vae.model._all_gather_tiled_results(_local_tiles(all_tiles, num_tiles, sp_rank, sp_size), num_tiles)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The address is stable across calls. This is what the accumulation is about:
|
||||
# drop the workspace and every call registers a new receive address. And the
|
||||
# workspace is one grow-only block, so varying request geometries cannot keep
|
||||
# adding resident buffers the way a per-geometry cache would.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_landing_buffer_address_is_stable_across_calls(fake_collective):
|
||||
vae = _vae(4)
|
||||
pointers = []
|
||||
for request in range(3):
|
||||
_run(vae, fake_collective, _tiles(8, seed=request), 8, 4, 0)
|
||||
pointers.append(fake_collective[-1]["buf_ptr"])
|
||||
|
||||
assert len(set(pointers)) == 1, f"gather buffer moved across calls: {pointers}"
|
||||
assert vae._tile_gather_stats == {
|
||||
"hits": 2,
|
||||
"allocs": 1,
|
||||
"workspace_bytes": _workspace_bytes(8, 4),
|
||||
}
|
||||
assert vae._tile_gather_workspace.numel() == _workspace_bytes(8, 4)
|
||||
assert vae._tile_gather_workspace.dtype is torch.uint8
|
||||
|
||||
|
||||
def test_distinct_shapes_share_the_one_workspace(fake_collective):
|
||||
"""One decode gathers several temporal clips, and the head/tail clips differ."""
|
||||
vae = _vae(4)
|
||||
_run(vae, fake_collective, _tiles(8), 8, 4, 0)
|
||||
first = fake_collective[-1]["buf_ptr"]
|
||||
_run(vae, fake_collective, _tiles(12), 12, 4, 0)
|
||||
second = fake_collective[-1]["buf_ptr"]
|
||||
_run(vae, fake_collective, _tiles(8, seed=5), 8, 4, 0)
|
||||
third = fake_collective[-1]["buf_ptr"]
|
||||
|
||||
# The larger geometry did not fit, so the block was replaced -- not added to.
|
||||
assert second == third
|
||||
assert vae._tile_gather_stats["allocs"] == 2
|
||||
assert vae._tile_gather_stats["hits"] == 1
|
||||
assert vae._tile_gather_stats["workspace_bytes"] == _workspace_bytes(12, 4)
|
||||
# ``first`` is deliberately not compared: the replaced block is freed, so
|
||||
# the allocator is free to hand its address back for the larger one.
|
||||
assert isinstance(first, int)
|
||||
|
||||
|
||||
def test_workspace_is_bounded_across_geometries(fake_collective):
|
||||
"""Alternating geometries must not each retain a buffer of their own."""
|
||||
sp_size = 4
|
||||
large, small, largest = 12, 8, 20
|
||||
vae = _vae(sp_size)
|
||||
|
||||
pointers = []
|
||||
for round_index in range(3):
|
||||
for num_tiles in (large, small):
|
||||
_run(vae, fake_collective, _tiles(num_tiles, seed=round_index), num_tiles, sp_size, 0)
|
||||
pointers.append(fake_collective[-1]["buf_ptr"])
|
||||
|
||||
assert len(pointers) == 6
|
||||
assert len(set(pointers)) == 1, f"workspace moved across geometries: {pointers}"
|
||||
assert vae._tile_gather_stats["allocs"] == 1
|
||||
assert vae._tile_gather_stats["workspace_bytes"] == _workspace_bytes(large, sp_size)
|
||||
assert vae._tile_gather_workspace.numel() == _workspace_bytes(large, sp_size)
|
||||
|
||||
# A geometry that does not fit grows the single block; it does not add one.
|
||||
_run(vae, fake_collective, _tiles(largest), largest, sp_size, 0)
|
||||
grown = fake_collective[-1]["buf_ptr"]
|
||||
assert vae._tile_gather_stats["allocs"] == 2
|
||||
assert vae._tile_gather_stats["workspace_bytes"] == _workspace_bytes(largest, sp_size)
|
||||
assert vae._tile_gather_workspace.numel() == _workspace_bytes(largest, sp_size)
|
||||
|
||||
# ...and the geometries seen before it now fit, so nothing reallocates again.
|
||||
for num_tiles in (large, small):
|
||||
_run(vae, fake_collective, _tiles(num_tiles, seed=7), num_tiles, sp_size, 0)
|
||||
assert fake_collective[-1]["buf_ptr"] == grown
|
||||
assert vae._tile_gather_stats["allocs"] == 2
|
||||
assert vae._tile_gather_stats["workspace_bytes"] == _workspace_bytes(largest, sp_size)
|
||||
|
||||
|
||||
def test_workspace_grows_only_upward(fake_collective):
|
||||
"""Resident bytes track the largest geometry seen, not the sum of them."""
|
||||
sp_size = 4
|
||||
small, large = 8, 12
|
||||
vae = _vae(sp_size)
|
||||
|
||||
_run(vae, fake_collective, _tiles(small), small, sp_size, 0)
|
||||
assert vae._tile_gather_stats["workspace_bytes"] == _workspace_bytes(small, sp_size)
|
||||
|
||||
_run(vae, fake_collective, _tiles(large), large, sp_size, 0)
|
||||
|
||||
assert vae._tile_gather_stats["allocs"] == 2
|
||||
assert vae._tile_gather_stats["hits"] == 0
|
||||
assert vae._tile_gather_stats["workspace_bytes"] == _workspace_bytes(large, sp_size)
|
||||
assert vae._tile_gather_stats["workspace_bytes"] < _workspace_bytes(small, sp_size) + _workspace_bytes(
|
||||
large, sp_size
|
||||
)
|
||||
assert vae._tile_gather_workspace.numel() == _workspace_bytes(large, sp_size)
|
||||
|
||||
|
||||
def test_mixed_dtype_shares_one_workspace(fake_collective):
|
||||
"""The workspace is bytes, so a narrower dtype reuses it instead of adding one."""
|
||||
sp_size, num_tiles = 4, 8
|
||||
vae = _vae(sp_size)
|
||||
fp32_bytes = _workspace_bytes(num_tiles, sp_size, dtype=torch.float32)
|
||||
|
||||
pointers = []
|
||||
for round_index in range(2):
|
||||
for dtype in (torch.float32, torch.bfloat16):
|
||||
tiles = _tiles(num_tiles, seed=round_index, dtype=dtype)
|
||||
produced = _run(vae, fake_collective, tiles, num_tiles, sp_size, 0)
|
||||
pointers.append(fake_collective[-1]["buf_ptr"])
|
||||
assert all(tile.dtype is dtype for tile in produced)
|
||||
for index, tile in enumerate(produced):
|
||||
assert torch.equal(tile, tiles[index])
|
||||
|
||||
assert len(set(pointers)) == 1, f"workspace moved across dtypes: {pointers}"
|
||||
assert vae._tile_gather_stats["allocs"] == 1
|
||||
assert vae._tile_gather_stats["hits"] == 3
|
||||
assert vae._tile_gather_stats["workspace_bytes"] == fp32_bytes
|
||||
assert vae._tile_gather_workspace.numel() == fp32_bytes
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Equivalence with the checkpoint's own gather, padded branch included. A
|
||||
# reordered tile grid is silent at runtime, so order is asserted as well.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_tiles", [5, 8, 9, 28])
|
||||
@pytest.mark.parametrize("sp_rank", [0, 3])
|
||||
def test_gathered_tiles_match_the_checkpoint_gather(fake_collective, num_tiles, sp_rank):
|
||||
sp_size = 4
|
||||
all_tiles = _tiles(num_tiles, seed=num_tiles)
|
||||
vae = _vae(sp_size, sp_rank=sp_rank)
|
||||
|
||||
produced = _run(vae, fake_collective, all_tiles, num_tiles, sp_size, sp_rank)
|
||||
expected = _reference_gather(all_tiles, num_tiles, sp_size)
|
||||
|
||||
assert len(produced) == num_tiles
|
||||
assert all(tile is not None for tile in produced)
|
||||
for index, (got, want) in enumerate(zip(produced, expected)):
|
||||
assert torch.equal(got, want), f"tile {index} differs from the checkpoint gather"
|
||||
# ...and it is the tile that belongs at that index, not a permutation that
|
||||
# happens to agree with an equally permuted oracle.
|
||||
for index, tile in enumerate(produced):
|
||||
assert torch.equal(tile, all_tiles[index])
|
||||
|
||||
|
||||
def test_the_padded_branch_is_actually_exercised(fake_collective):
|
||||
"""9 tiles over 4 ranks: ranks 1-3 are one short, so the padding runs."""
|
||||
sp_size, num_tiles = 4, 9
|
||||
all_tiles = _tiles(num_tiles, seed=9)
|
||||
assert len(_local_tiles(all_tiles, num_tiles, 1, sp_size)) < -(-num_tiles // sp_size)
|
||||
|
||||
vae = _vae(sp_size, sp_rank=1)
|
||||
produced = _run(vae, fake_collective, all_tiles, num_tiles, sp_size, 1)
|
||||
|
||||
sent = fake_collective[-1]["sent"]
|
||||
assert sent.shape[0] == 3
|
||||
assert torch.equal(sent[2], torch.zeros_like(sent[2]))
|
||||
for index, tile in enumerate(produced):
|
||||
assert torch.equal(tile, all_tiles[index])
|
||||
|
||||
|
||||
def test_an_empty_local_share_still_raises(fake_collective):
|
||||
"""A rank with no tiles fails loudly, exactly as the checkpoint does."""
|
||||
vae = _vae(4)
|
||||
with pytest.raises(ValueError, match="empty tasks on sp rank"):
|
||||
vae.model._all_gather_tiled_results([], 8)
|
||||
|
||||
|
||||
def test_more_tiles_than_round_robin_allows_is_refused(fake_collective):
|
||||
"""The unpacking arithmetic is only valid for the checkpoint's ownership."""
|
||||
vae = _vae(4)
|
||||
with pytest.raises(ValueError, match="round-robin"):
|
||||
vae.model._all_gather_tiled_results(_tiles(5), 8)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Ownership: the buffer is overwritten by the next gather, so returned tiles
|
||||
# must not alias it.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_returned_tiles_do_not_alias_the_pinned_buffer(fake_collective):
|
||||
sp_size, num_tiles = 4, 8
|
||||
first_tiles = _tiles(num_tiles, seed=1)
|
||||
vae = _vae(sp_size)
|
||||
produced = _run(vae, fake_collective, first_tiles, num_tiles, sp_size, 0)
|
||||
|
||||
buffer_storage = vae._tile_gather_workspace.untyped_storage().data_ptr()
|
||||
for tile in produced:
|
||||
assert tile.untyped_storage().data_ptr() != buffer_storage
|
||||
|
||||
# The next gather reuses that memory; the first call's tiles must survive it.
|
||||
_run(vae, fake_collective, _tiles(num_tiles, seed=2), num_tiles, sp_size, 0)
|
||||
for index, tile in enumerate(produced):
|
||||
assert torch.equal(tile, first_tiles[index])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Non-XPU behaviour is unchanged: the checkpoint's own method stays installed.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _construct(monkeypatch, device, model):
|
||||
"""Run the real ``__init__`` against a stub checkpoint component."""
|
||||
|
||||
class _Remote(torch.nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.weight = torch.nn.Parameter(torch.zeros(1))
|
||||
self.model = model
|
||||
|
||||
monkeypatch.setattr(
|
||||
vae_module,
|
||||
"_load_component_config",
|
||||
lambda path: {"latent_channels": 1, "latents_mean": [0.0], "latents_std": [1.0]},
|
||||
)
|
||||
# **kwargs: the loader gained a trust_remote_code argument at some point,
|
||||
# and this stub does not care either way.
|
||||
monkeypatch.setattr(vae_module, "_load_remote_component", lambda path, config, **kwargs: _Remote())
|
||||
# The adapter only stores this handle; resolving it for real needs an
|
||||
# accelerator, which a CPU test runner does not have.
|
||||
monkeypatch.setattr(torch, "get_device_module", lambda *args, **kwargs: types.SimpleNamespace())
|
||||
return MiniMaxH3VideoVAE("unused", device=torch.device(device))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("device", ["cpu", "meta"])
|
||||
def test_non_xpu_devices_keep_the_checkpoint_gather(monkeypatch, device):
|
||||
model = _FakeCheckpointModel()
|
||||
original = model._all_gather_tiled_results
|
||||
|
||||
vae = _construct(monkeypatch, device, model)
|
||||
|
||||
assert vae._tile_gather_reuse_enabled() is False
|
||||
assert vae.model._all_gather_tiled_results is original
|
||||
assert vae._checkpoint_tile_gather is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device", "enabled"),
|
||||
[("xpu", True), ("cpu", False), ("cuda", False), ("meta", False)],
|
||||
)
|
||||
def test_only_xpu_asks_for_the_pinned_buffer(device, enabled):
|
||||
vae = object.__new__(MiniMaxH3VideoVAE)
|
||||
vae._device_target = torch.device(device)
|
||||
assert vae._tile_gather_reuse_enabled() is enabled
|
||||
|
||||
|
||||
def test_construction_installs_the_override_when_the_device_asks_for_it(monkeypatch):
|
||||
"""Close the loop: ``__init__`` must consult the predicate, not ignore it."""
|
||||
monkeypatch.setattr(MiniMaxH3VideoVAE, "_tile_gather_reuse_enabled", lambda self: True)
|
||||
model = _FakeCheckpointModel()
|
||||
original = model._all_gather_tiled_results
|
||||
|
||||
vae = _construct(monkeypatch, "cpu", model)
|
||||
|
||||
assert vae.model._all_gather_tiled_results is not original
|
||||
assert vae._checkpoint_tile_gather is original
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The decision-point receipt carries quantities, not just presence: a line
|
||||
# that only says "installed" cannot tell reuse from reallocation.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_receipt_line_reports_hits_allocs_and_the_address(fake_collective, gather_log):
|
||||
vae = _vae(4)
|
||||
install = [
|
||||
(level, line) for level, line in gather_log.records if "persistent tile gather installed device=xpu" in line
|
||||
]
|
||||
# The one-shot install line is what an operator needs at info level.
|
||||
assert install and all(level == logging.INFO for level, _ in install)
|
||||
gather_log.messages.clear()
|
||||
gather_log.records.clear()
|
||||
|
||||
_run(vae, fake_collective, _tiles(8), 8, 4, 0)
|
||||
first_ptr = fake_collective[-1]["buf_ptr"]
|
||||
_run(vae, fake_collective, _tiles(8, seed=3), 8, 4, 0)
|
||||
|
||||
records = [(level, line) for level, line in gather_log.records if "[H3_VAE_GATHER] reuse=" in line]
|
||||
# The per-gather line fires once per decoder tile batch, so it is debug.
|
||||
assert all(level == logging.DEBUG for level, _ in records)
|
||||
lines = [line for _, line in records]
|
||||
assert len(lines) == 2
|
||||
assert "reuse=alloc" in lines[0] and "hits=0 allocs=1" in lines[0]
|
||||
assert "reuse=hit" in lines[1] and "hits=1 allocs=1" in lines[1]
|
||||
# Resident bytes are reported, not just the bytes this one gather needed.
|
||||
needed = _workspace_bytes(8, 4) / (1024.0 * 1024.0)
|
||||
for line in lines:
|
||||
assert f"need_mib={needed:.2f}" in line
|
||||
assert f"workspace_mib={needed:.2f}" in line
|
||||
assert f"buf_ptr=0x{first_ptr:x}" in lines[0]
|
||||
assert f"buf_ptr=0x{first_ptr:x}" in lines[1]
|
||||
@@ -198,7 +198,7 @@ def test_video_vae_installs_exact_optimizations(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
vae_module,
|
||||
"_load_remote_component",
|
||||
lambda _path, _config: FakeRemote(),
|
||||
lambda _path, _config, **_kwargs: FakeRemote(),
|
||||
)
|
||||
install = Mock(return_value=True)
|
||||
monkeypatch.setattr(vae_module, "install_h3_vae_optimizations", install)
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""CPU tests for the split encoder/decoder residency of the MiniMax-H3 VAE.
|
||||
|
||||
The encode half (CNN encoder + quant_conv) and the decode half
|
||||
(post_quant_conv + ViT decoder) stage independently: loading one half must
|
||||
rebind only its own parameters while the other half stays on its pinned
|
||||
CPU master.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3.vae import (
|
||||
MiniMaxH3VideoVAE,
|
||||
_VideoVAEPartProxy,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
class _FakeRemoteModel(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.encoder = nn.Linear(8, 8)
|
||||
self.quant_conv = nn.Linear(4, 4)
|
||||
self.post_quant_conv = nn.Linear(4, 4)
|
||||
self.decoder = nn.Linear(16, 16)
|
||||
|
||||
|
||||
class _FakeRemote(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.model = _FakeRemoteModel()
|
||||
|
||||
|
||||
def _vae() -> MiniMaxH3VideoVAE:
|
||||
vae = object.__new__(MiniMaxH3VideoVAE)
|
||||
nn.Module.__init__(vae) # bare instance: enable Module attribute assignment
|
||||
vae._device_target = torch.device("cpu")
|
||||
vae.remote = _FakeRemote()
|
||||
vae.model = vae.remote.model
|
||||
vae._stager = None
|
||||
vae._encoder_stager = None
|
||||
vae._decoder_stager = None
|
||||
vae._build_residency_stagers(torch.device("cpu"))
|
||||
vae.encoder_component = _VideoVAEPartProxy(vae, "encoder")
|
||||
vae.decoder_component = _VideoVAEPartProxy(vae, "decoder")
|
||||
return vae
|
||||
|
||||
|
||||
def _ptr(param: torch.nn.Parameter) -> int:
|
||||
return param.untyped_storage().data_ptr()
|
||||
|
||||
|
||||
def test_split_stagers_snapshot_disjoint_halves():
|
||||
vae = _vae()
|
||||
enc_master = _ptr(vae.model.encoder.weight)
|
||||
dec_master = _ptr(vae.model.decoder.weight)
|
||||
pqc_master = _ptr(vae.model.post_quant_conv.weight)
|
||||
assert enc_master != dec_master
|
||||
|
||||
vae._load_part_to_device("encoder")
|
||||
# The encoder half is rebound to freshly allocated staging storage...
|
||||
assert _ptr(vae.model.encoder.weight) != enc_master
|
||||
assert _ptr(vae.model.quant_conv.weight) != enc_master
|
||||
# ...while every decode-half tensor stays on its own CPU master.
|
||||
assert _ptr(vae.model.decoder.weight) == dec_master
|
||||
assert _ptr(vae.model.post_quant_conv.weight) == pqc_master
|
||||
|
||||
vae._offload_part_to_cpu("encoder")
|
||||
assert _ptr(vae.model.encoder.weight) == enc_master
|
||||
|
||||
|
||||
def test_part_load_is_idempotent_and_independent():
|
||||
vae = _vae()
|
||||
vae._load_part_to_device("decoder")
|
||||
staged_ptr = _ptr(vae.model.decoder.weight)
|
||||
vae._load_part_to_device("decoder")
|
||||
assert _ptr(vae.model.decoder.weight) == staged_ptr
|
||||
vae._offload_part_to_cpu("decoder")
|
||||
|
||||
|
||||
def test_whole_component_load_and_offload_cover_both_halves():
|
||||
vae = _vae()
|
||||
enc_master = _ptr(vae.model.encoder.weight)
|
||||
dec_master = _ptr(vae.model.decoder.weight)
|
||||
vae.load_to_device()
|
||||
assert _ptr(vae.model.encoder.weight) != enc_master
|
||||
assert _ptr(vae.model.decoder.weight) != dec_master
|
||||
vae.offload_to_cpu()
|
||||
assert _ptr(vae.model.encoder.weight) == enc_master
|
||||
assert _ptr(vae.model.decoder.weight) == dec_master
|
||||
|
||||
|
||||
def test_part_values_survive_a_load_offload_cycle():
|
||||
vae = _vae()
|
||||
expected = vae.model.encoder.weight.detach().clone()
|
||||
vae._load_part_to_device("encoder")
|
||||
assert torch.equal(vae.model.encoder.weight.detach(), expected)
|
||||
vae._offload_part_to_cpu("encoder")
|
||||
assert torch.equal(vae.model.encoder.weight.detach(), expected)
|
||||
|
||||
|
||||
def test_proxy_routes_to_the_matching_half():
|
||||
vae = _vae()
|
||||
enc_master = _ptr(vae.model.encoder.weight)
|
||||
dec_master = _ptr(vae.model.decoder.weight)
|
||||
vae.encoder_component.load_to_device()
|
||||
assert _ptr(vae.model.encoder.weight) != enc_master
|
||||
assert _ptr(vae.model.decoder.weight) == dec_master
|
||||
vae.encoder_component.offload_to_cpu()
|
||||
assert _ptr(vae.model.encoder.weight) == enc_master
|
||||
|
||||
|
||||
def test_proxy_does_not_register_the_adapter_as_submodule():
|
||||
vae = _vae()
|
||||
proxy = vae.encoder_component
|
||||
assert proxy._vae is vae
|
||||
# The back-reference must not recurse through module traversal.
|
||||
assert all(child is not vae for child in proxy.children())
|
||||
assert len(list(proxy.parameters())) == 0
|
||||
|
||||
|
||||
def test_component_on_device_unwraps_proxy_for_sequential_offload(monkeypatch):
|
||||
"""Model-level CPU offload hooks the real video_vae, never the part
|
||||
proxies: _component_on_device must hand the hooked module to the
|
||||
sequential context, or _get_sequential_offload_hook raises on the
|
||||
hook-less proxy on the first encode/decode of every request."""
|
||||
from contextlib import contextmanager
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as pipeline_module
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
vae = _vae()
|
||||
seen: list[nn.Module] = []
|
||||
|
||||
@contextmanager
|
||||
def fake_sequential_offload_component(component):
|
||||
seen.append(component)
|
||||
yield
|
||||
|
||||
monkeypatch.setattr(pipeline_module, "sequential_offload_component", fake_sequential_offload_component)
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
nn.Module.__init__(pipeline)
|
||||
pipeline._model_cpu_offload_modules = [vae]
|
||||
|
||||
with pipeline._component_on_device(vae.encoder_component):
|
||||
pass
|
||||
with pipeline._component_on_device(vae.decoder_component):
|
||||
pass
|
||||
# A plain component passes through unchanged.
|
||||
with pipeline._component_on_device(vae):
|
||||
pass
|
||||
# Mocks auto-create any attribute, so the unwrap must be a type check:
|
||||
# a Mock component must reach the sequential context as itself.
|
||||
mock_vae = Mock()
|
||||
pipeline._model_cpu_offload_modules = [mock_vae]
|
||||
with pipeline._component_on_device(mock_vae):
|
||||
pass
|
||||
|
||||
assert seen == [vae, vae, vae, mock_vae]
|
||||
|
||||
|
||||
def test_sequential_offload_target_is_the_hooked_module():
|
||||
from vllm_omni.diffusion.offloader.sequential_backend import _get_sequential_offload_hook
|
||||
|
||||
vae = _vae()
|
||||
proxy = vae.encoder_component
|
||||
# The pre-fix failure mode: the proxy carries no hook registry.
|
||||
with pytest.raises(RuntimeError, match="no sequential offload hook"):
|
||||
_get_sequential_offload_hook(proxy)
|
||||
assert proxy.sequential_offload_target is vae
|
||||
|
||||
|
||||
def test_cache_retention_forwarded_to_both_stagers():
|
||||
vae = _vae()
|
||||
cache = object()
|
||||
vae.set_omni_component_cache(cache)
|
||||
assert vae._encoder_stager.cache_retention is cache
|
||||
assert vae._decoder_stager.cache_retention is cache
|
||||
|
||||
|
||||
def test_unknown_part_raises():
|
||||
vae = _vae()
|
||||
with pytest.raises(ValueError, match="unknown video VAE part"):
|
||||
vae._part_stager("middle")
|
||||
|
||||
|
||||
def test_missing_checkpoint_structure_falls_back_to_whole_stager():
|
||||
vae = object.__new__(MiniMaxH3VideoVAE)
|
||||
nn.Module.__init__(vae)
|
||||
vae._device_target = torch.device("cpu")
|
||||
|
||||
class BareRemote(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.model = nn.Linear(4, 4) # no encoder/decoder submodules
|
||||
|
||||
vae.remote = BareRemote()
|
||||
vae.model = vae.remote.model
|
||||
vae._stager = None
|
||||
vae._encoder_stager = None
|
||||
vae._decoder_stager = None
|
||||
vae._build_residency_stagers(torch.device("cpu"))
|
||||
assert vae._stager is not None
|
||||
assert vae._encoder_stager is None
|
||||
assert vae._decoder_stager is None
|
||||
# Part loads fall back to the whole-module stager.
|
||||
vae._load_part_to_device("decoder")
|
||||
assert vae._stager.loaded
|
||||
vae._offload_part_to_cpu("decoder")
|
||||
assert not vae._stager.loaded
|
||||
@@ -0,0 +1,335 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""CPU tests for the temporal VAE forks and the uint8 output transfer.
|
||||
|
||||
The encode fork must feed bit-identical frames to the checkpoint's chunk
|
||||
loop (padding moves from a whole-video cat into the tail chunk), the decode
|
||||
fork must produce the same video as the legacy fp32-buffer chain
|
||||
(revert -> clamp -> *255 -> round -> uint8), and the output transfer must
|
||||
match the previous ``.to(uint8, contiguous)`` conversion.
|
||||
"""
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
|
||||
_prepare_minimax_h3_video_output,
|
||||
)
|
||||
from vllm_omni.diffusion.models.minimax_h3.vae_temporal import (
|
||||
_decode_temporal_streaming_uint8,
|
||||
_encode_temporal_tail_pad,
|
||||
install_temporal_stream_patches,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
CLIP = 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# encode_temporal fork
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _EncodeModel:
|
||||
"""Minimal stand-in implementing the checkpoint's encode_temporal."""
|
||||
|
||||
# Attached per test case as capability probes for the install checks;
|
||||
# annotations only, the fork must keep working when they are absent.
|
||||
encode_temporal: Any
|
||||
_decode_temporal_streaming: Any
|
||||
processor: Any
|
||||
|
||||
def __init__(self, *, isolated_first=False, isolated_last=False, key_frame=False, frame_drop=0):
|
||||
self.clip_length = CLIP
|
||||
self.isolated_first_frame = isolated_first
|
||||
self.frame_pre_padding = 0
|
||||
self.isolated_key_frame = key_frame
|
||||
self.token_drop = 0
|
||||
self.isolated_last_frame = isolated_last
|
||||
self.frame_drop = frame_drop
|
||||
self.encoded = []
|
||||
|
||||
def _adaptive_encode(self, clip):
|
||||
self.encoded.append(clip.clone())
|
||||
return clip # identity: z_list entries are the clips themselves
|
||||
|
||||
def reference_encode_temporal(self, x):
|
||||
"""The checkpoint's original: whole-video pad, then chunk."""
|
||||
offset_frame = 1 if self.isolated_first_frame and self.frame_pre_padding == 0 else 0
|
||||
if x.shape[2] % self.clip_length != offset_frame:
|
||||
pad_size = (offset_frame - x.shape[2]) % self.clip_length
|
||||
pad_frames = x[:, :, -1:].repeat(1, 1, pad_size, 1, 1)
|
||||
x = torch.cat([x, pad_frames], dim=2)
|
||||
num_chunks = (x.shape[2] - offset_frame) // self.clip_length
|
||||
z_list = []
|
||||
for i in range(num_chunks):
|
||||
start_idx = i * self.clip_length + offset_frame
|
||||
end_idx = (i + 1) * self.clip_length + offset_frame
|
||||
clip_x = x[:, :, start_idx:end_idx, :, :]
|
||||
if self.isolated_key_frame:
|
||||
key_frame = clip_x[:, :, :1, :, :]
|
||||
z_key = self._adaptive_encode(key_frame)
|
||||
if clip_x.shape[2] > 1:
|
||||
z_video = self._adaptive_encode(clip_x[:, :, 1:, :, :])
|
||||
z = torch.cat([z_key, z_video], dim=2)
|
||||
else:
|
||||
z = z_key
|
||||
else:
|
||||
z = self._adaptive_encode(clip_x)
|
||||
z_list.append(z)
|
||||
z = torch.cat(z_list, dim=2)
|
||||
if self.token_drop > 0:
|
||||
z = z[:, :, : -self.token_drop]
|
||||
if self.isolated_first_frame:
|
||||
z_first = self._adaptive_encode(x[:, :, :1, :, :])
|
||||
z = torch.cat([z_first, z], dim=2)
|
||||
if self.isolated_last_frame:
|
||||
frame_num = x.shape[2]
|
||||
last_idx = frame_num - self.frame_drop + offset_frame
|
||||
z_last = self._adaptive_encode(x[:, :, last_idx : last_idx + 1, :, :])
|
||||
z = torch.cat([z, z_last], dim=2)
|
||||
return z
|
||||
|
||||
|
||||
def _video_x(t, c=3, h=4, w=4):
|
||||
return torch.arange(t * c * h * w, dtype=torch.float32).reshape(1, c, t, h, w) / 255.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_frames", [8, 10, 11, 13])
|
||||
@pytest.mark.parametrize(
|
||||
"flags",
|
||||
[
|
||||
{},
|
||||
{"isolated_first": True},
|
||||
{"isolated_last": True},
|
||||
{"isolated_first": True, "isolated_last": True},
|
||||
{"key_frame": True},
|
||||
],
|
||||
)
|
||||
def test_encode_fork_matches_whole_video_pad_bitwise(num_frames, flags):
|
||||
x = _video_x(num_frames)
|
||||
ref_model = _EncodeModel(**flags)
|
||||
expected = ref_model.reference_encode_temporal(x.clone())
|
||||
# Empty-slice encodes (out-of-range isolated-last indexes are no-ops in
|
||||
# the checkpoint) carry no content; compare only real calls.
|
||||
ref_calls = [c.clone() for c in ref_model.encoded if c.shape[2] > 0]
|
||||
|
||||
fork_model = _EncodeModel(**flags)
|
||||
out = _encode_temporal_tail_pad(fork_model, x.clone())
|
||||
|
||||
assert out.shape == expected.shape
|
||||
assert torch.equal(out, expected)
|
||||
assert len(fork_model.encoded) == len(ref_calls)
|
||||
for got, want in zip(fork_model.encoded, ref_calls):
|
||||
assert torch.equal(got, want)
|
||||
|
||||
|
||||
def test_encode_fork_avoids_whole_video_pad_for_misaligned_input():
|
||||
# 375 frames with an asymmetric config (isolated last frame only):
|
||||
# the checkpoint always pads (375 % 17 != 0); the fork must encode the
|
||||
# tail chunk with an in-chunk pad instead of concatenating the video.
|
||||
model = _EncodeModel(isolated_last=True, frame_drop=16)
|
||||
model.clip_length = 17
|
||||
x = _video_x(375, c=1, h=2, w=2)
|
||||
_encode_temporal_tail_pad(model, x)
|
||||
# 375 % 17 == 1 → pad 16 inside the tail chunk; the isolated last frame
|
||||
# resolves to the final real frame (pad frames replicate it).
|
||||
assert len(model.encoded) == (375 // 17) + 1 + 1
|
||||
assert torch.equal(model.encoded[-1].flatten(), x[:, :, -1:, :, :].flatten())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _decode_temporal_streaming uint8 fork
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DENORM_MEAN = (-2.11628412, -2.03571429, -1.80444444)
|
||||
DENORM_STD = (4.36681223, 4.46428571, 4.44444444)
|
||||
|
||||
|
||||
class _FakeDenorm:
|
||||
def __init__(self):
|
||||
self.mean = DENORM_MEAN
|
||||
self.std = DENORM_STD
|
||||
|
||||
|
||||
class _FakeProcessor:
|
||||
def __init__(self):
|
||||
self.transform_rev = _FakeDenorm()
|
||||
|
||||
|
||||
class _DecodeModel:
|
||||
"""Minimal stand-in for the checkpoint's streaming decode contract."""
|
||||
|
||||
def __init__(self, *, token_drop=0, frame_overlap=0):
|
||||
self.tokens_chunk_size = 2
|
||||
self.vae_ratio_t = 1
|
||||
self.token_overlap = 1 if token_drop else 0
|
||||
self.token_drop = token_drop
|
||||
self.frame_pre_padding = 0
|
||||
self.frame_overlap = frame_overlap
|
||||
self.processor = _FakeProcessor()
|
||||
|
||||
def _adaptive_decode(self, clip_z):
|
||||
# Deterministic pseudo-decode: map latent tokens to frames 1:1.
|
||||
return torch.sin(clip_z * 3.0)
|
||||
|
||||
def blend(self, a, b, overlap, dim=-3):
|
||||
return (a + b) / 2.0
|
||||
|
||||
def _decode_temporal_pad_frames(self, z, pad_tokens):
|
||||
return int(pad_tokens)
|
||||
|
||||
def _decode_temporal_output_frame_plan(self, z, z_head, z_tail, num_chunks, pad_tokens):
|
||||
total = num_chunks * self.tokens_chunk_size * self.vae_ratio_t
|
||||
if z_head is not None:
|
||||
total += 1
|
||||
if z_tail is not None:
|
||||
total += 1
|
||||
if self.token_drop > 0:
|
||||
total += self.frame_overlap
|
||||
return int(total), int(pad_tokens), int(total - pad_tokens)
|
||||
|
||||
def legacy_streaming(self, z, z_head, z_tail, num_chunks, pad_tokens, temporal_cat_dtype):
|
||||
"""The checkpoint's original: accumulate in the cat dtype, quantize later."""
|
||||
total_frames, _, output_frames = self._decode_temporal_output_frame_plan(
|
||||
z, z_head, z_tail, num_chunks, pad_tokens
|
||||
)
|
||||
chunk_dec = self.tokens_chunk_size * self.vae_ratio_t
|
||||
split_count = int(self.token_drop > 0) + 1
|
||||
dec = None
|
||||
dec_overlap = None
|
||||
write_pos = 0
|
||||
|
||||
def write_part(part):
|
||||
nonlocal dec, write_pos
|
||||
if dec is None:
|
||||
out_shape = list(part.shape)
|
||||
out_shape[2] = output_frames
|
||||
dec = torch.empty(out_shape, dtype=part.dtype)
|
||||
n = min(part.shape[2], dec.shape[2] - write_pos)
|
||||
if n > 0:
|
||||
dec[:, :, write_pos : write_pos + n].copy_(part[:, :, :n])
|
||||
write_pos += n
|
||||
|
||||
for i in range(num_chunks):
|
||||
t_start = i * self.tokens_chunk_size
|
||||
t_end = t_start + self.tokens_chunk_size + self.token_overlap
|
||||
clip_z = z[:, :, t_start:t_end]
|
||||
clip_dec = self._adaptive_decode(clip_z)
|
||||
if temporal_cat_dtype is not None and clip_dec.dtype != temporal_cat_dtype:
|
||||
clip_dec = clip_dec.to(temporal_cat_dtype)
|
||||
for j in range(split_count):
|
||||
chunk = clip_dec[:, :, j * chunk_dec : min(j * chunk_dec + chunk_dec, clip_dec.shape[2])]
|
||||
if j == 0:
|
||||
if dec_overlap is not None:
|
||||
chunk = self.blend(dec_overlap, chunk, self.frame_overlap, dim=-3)
|
||||
dec_overlap = None
|
||||
write_part(chunk)
|
||||
else:
|
||||
dec_overlap = chunk.contiguous()
|
||||
if i == num_chunks - 1 and dec_overlap is not None:
|
||||
write_part(dec_overlap)
|
||||
dec_overlap = None
|
||||
# Legacy post-decode chain: revert in place in the decoded dtype, then
|
||||
# the adapter's ``frames.float()`` before the pipeline's quantizer.
|
||||
assert dec is not None
|
||||
mean = torch.tensor(DENORM_MEAN, dtype=dec.dtype).view(1, 3, 1, 1, 1)
|
||||
std = torch.tensor(DENORM_STD, dtype=dec.dtype).view(1, 3, 1, 1, 1)
|
||||
dec.sub_(mean).div_(std).clamp_(0.0, 1.0)
|
||||
dec = dec.float()
|
||||
dec.mul_(255.0).round_()
|
||||
return dec.to(torch.uint8)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token_drop,frame_overlap", [(0, 0), (1, 1)])
|
||||
@pytest.mark.parametrize("temporal_cat_dtype", [None, torch.float16, torch.bfloat16])
|
||||
def test_decode_fork_matches_legacy_chain_bitwise(token_drop, frame_overlap, temporal_cat_dtype):
|
||||
# The quantizer must see FP32 no matter the cat dtype: the legacy adapter
|
||||
# returned frames.float() before the pipeline multiplied and rounded, so
|
||||
# an fp16/bf16 cat dtype only narrows denormalization, never the rounding.
|
||||
torch.manual_seed(0)
|
||||
tokens = 2 * 2 + (1 if token_drop else 0) # num_chunks * tokens_chunk_size
|
||||
z = torch.randn(1, 3, tokens, 4, 4)
|
||||
legacy = _DecodeModel(token_drop=token_drop, frame_overlap=frame_overlap)
|
||||
expected = legacy.legacy_streaming(z.clone(), None, None, 2, 0, temporal_cat_dtype)
|
||||
|
||||
fork = _DecodeModel(token_drop=token_drop, frame_overlap=frame_overlap)
|
||||
out = _decode_temporal_streaming_uint8(fork, z.clone(), None, None, 2, 0, temporal_cat_dtype)
|
||||
assert out.dtype == torch.uint8
|
||||
assert out.shape == expected.shape
|
||||
assert torch.equal(out, expected)
|
||||
|
||||
|
||||
def test_decode_fork_rejects_empty_plan():
|
||||
model = _DecodeModel()
|
||||
model.tokens_chunk_size = 0
|
||||
with pytest.raises(ValueError, match="non-positive output_frames"):
|
||||
_decode_temporal_streaming_uint8(model, torch.randn(1, 3, 0, 4, 4), None, None, 0, 0, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# install + escape hatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_install_binds_both_forks():
|
||||
model = _EncodeModel()
|
||||
# cast(Any, ...) keeps mypy from narrowing the probes to the lambda type,
|
||||
# which has no __func__ for the bound-method assertions below.
|
||||
model.encode_temporal = cast(Any, lambda x: x) # placeholder for the capability check
|
||||
model._decode_temporal_streaming = cast(Any, lambda *a: None)
|
||||
model.processor = _FakeProcessor()
|
||||
install_temporal_stream_patches(model)
|
||||
assert model.encode_temporal.__func__ is _encode_temporal_tail_pad
|
||||
assert model._decode_temporal_streaming.__func__ is _decode_temporal_streaming_uint8
|
||||
|
||||
|
||||
def test_install_skips_decode_fork_without_denorm_contract():
|
||||
model = _EncodeModel()
|
||||
model.encode_temporal = cast(Any, lambda x: x)
|
||||
model._decode_temporal_streaming = cast(Any, lambda *a: None)
|
||||
install_temporal_stream_patches(model) # no processor -> keep original
|
||||
assert model.encode_temporal.__func__ is _encode_temporal_tail_pad
|
||||
assert not hasattr(model._decode_temporal_streaming, "__func__")
|
||||
|
||||
|
||||
def test_install_respects_legacy_env(monkeypatch):
|
||||
monkeypatch.setenv("VLLM_OMNI_VAE_LEGACY_TEMPORAL", "1")
|
||||
model = _EncodeModel()
|
||||
install_temporal_stream_patches(model)
|
||||
assert not hasattr(getattr(model, "encode_temporal", None), "__func__")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _prepare_minimax_h3_video_output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prepare_output_matches_previous_conversion():
|
||||
video = torch.randn(1, 3, 4, 6, 8).clamp_(-1, 2)
|
||||
legacy = (
|
||||
video.detach()
|
||||
.float()
|
||||
.clamp(0, 1)
|
||||
.mul(255)
|
||||
.round()
|
||||
.permute(0, 2, 3, 4, 1)
|
||||
.to(dtype=torch.uint8, memory_format=torch.contiguous_format)
|
||||
)
|
||||
out = _prepare_minimax_h3_video_output(video.clone())
|
||||
assert out.dtype == torch.uint8
|
||||
assert out.shape == (1, 4, 6, 8, 3)
|
||||
assert out.is_contiguous()
|
||||
assert torch.equal(out, legacy)
|
||||
|
||||
|
||||
def test_prepare_output_uint8_passthrough():
|
||||
video = torch.randint(0, 256, (1, 3, 4, 6, 8), dtype=torch.uint8)
|
||||
out = _prepare_minimax_h3_video_output(video)
|
||||
assert out.dtype == torch.uint8
|
||||
assert out.is_contiguous()
|
||||
assert torch.equal(out, video.permute(0, 2, 3, 4, 1).contiguous())
|
||||
@@ -0,0 +1,221 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import math
|
||||
import sys
|
||||
import types
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.attention.backends.abstract import (
|
||||
AttentionMetadata,
|
||||
PackedPaddingMetadata,
|
||||
VideoTokenLayout,
|
||||
VideoTokenSpan,
|
||||
)
|
||||
from vllm_omni.diffusion.models.minimax_h3.attention.fastvideo_h3 import (
|
||||
MiniMaxH3VSAImpl,
|
||||
_get_h3_tile_metadata,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _h3_metadata(prefix_segments, video_shape, *, gate=None, packed_padding=None):
|
||||
extra: dict[str, Any] = {
|
||||
"vsa_h3_prefix_segments": prefix_segments,
|
||||
}
|
||||
if gate is not None:
|
||||
extra["gate_compress"] = gate
|
||||
return AttentionMetadata(
|
||||
packed_padding=packed_padding,
|
||||
video_layout=VideoTokenLayout(
|
||||
used_len=sum(prefix_segments) + math.prod(video_shape),
|
||||
video_spans=(VideoTokenSpan(start=sum(prefix_segments), latent_grid=video_shape, role="target"),),
|
||||
),
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
def _fake_block_sparse_kernel(monkeypatch, calls):
|
||||
"""Stand in for fastvideo_kernel.block_sparse_attn with a mask-obeying SDPA."""
|
||||
|
||||
def block_sparse_attn(q, k, v, block_map, variable_block_sizes):
|
||||
calls["block_map"] = block_map.clone()
|
||||
calls["variable_block_sizes"] = variable_block_sizes.tolist()
|
||||
calls["q_shape"] = tuple(q.shape)
|
||||
blocks = block_map.shape[-1]
|
||||
assert blocks * 64 == q.shape[2]
|
||||
# Expand the per-block map to a per-token mask so the stand-in honours
|
||||
# the sparsity contract the real kernel implements, and drop the rows
|
||||
# each partial block pads with, which the real kernel skips through
|
||||
# variable_block_sizes.
|
||||
token_mask = block_map.repeat_interleave(64, dim=-1).repeat_interleave(64, dim=-2)
|
||||
within = torch.arange(64, device=q.device)[None, :] < variable_block_sizes[:, None]
|
||||
token_mask = token_mask & within.reshape(1, 1, 1, -1)
|
||||
out = torch.nn.functional.scaled_dot_product_attention(q.float(), k.float(), v.float(), attn_mask=token_mask)
|
||||
return out.to(q.dtype), None
|
||||
|
||||
module = types.ModuleType("fastvideo_kernel.block_sparse_attn")
|
||||
setattr(module, "block_sparse_attn", block_sparse_attn)
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel", types.ModuleType("fastvideo_kernel"))
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel.block_sparse_attn", module)
|
||||
|
||||
|
||||
def _h3_impl(**backend_kwargs):
|
||||
kwargs = {"topk": 1, "min_seq_len": 1, "disable_when_sp_active": False}
|
||||
kwargs.update(backend_kwargs)
|
||||
return MiniMaxH3VSAImpl(num_heads=2, head_size=8, softmax_scale=8**-0.5, backend_kwargs=kwargs)
|
||||
|
||||
|
||||
def test_h3_forward_tiles_untiles_and_restores_the_original_row_order(monkeypatch):
|
||||
calls: dict[str, Any] = {}
|
||||
_fake_block_sparse_kernel(monkeypatch, calls)
|
||||
impl = _h3_impl(topk=64)
|
||||
prefix_segments, video_shape = (5, 70), (2, 4, 4)
|
||||
seq_len = sum(prefix_segments) + 2 * 4 * 4
|
||||
torch.manual_seed(0)
|
||||
query = torch.randn(1, seq_len, 2, 8, dtype=torch.bfloat16)
|
||||
|
||||
output = impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape))
|
||||
|
||||
assert output.shape == query.shape
|
||||
# topk covers every video block, so the block map is dense and the tiled
|
||||
# kernel must reproduce plain attention over the same rows.
|
||||
reference = torch.nn.functional.scaled_dot_product_attention(
|
||||
*(x.float().transpose(1, 2) for x in (query, query, query))
|
||||
).transpose(1, 2)
|
||||
torch.testing.assert_close(output.float(), reference, atol=2e-2, rtol=2e-2)
|
||||
# 5 + 70 splits into segment-pure 5 | 64 | 6, then one (2, 4, 4) video tile.
|
||||
assert calls["variable_block_sizes"] == [5, 64, 6, 32]
|
||||
|
||||
|
||||
def test_h3_forward_keeps_prefix_dense_and_selects_top_k_video_tiles(monkeypatch):
|
||||
calls: dict[str, Any] = {}
|
||||
_fake_block_sparse_kernel(monkeypatch, calls)
|
||||
impl = _h3_impl(topk=1)
|
||||
prefix_segments, video_shape = (5,), (8, 4, 4)
|
||||
seq_len = 5 + 8 * 4 * 4
|
||||
torch.manual_seed(0)
|
||||
query = torch.randn(1, seq_len, 2, 8, dtype=torch.bfloat16)
|
||||
|
||||
impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape))
|
||||
|
||||
# 1 prefix block + 2 video blocks. The odd count is padded to an even one
|
||||
# for the paired-CTA kernel contract, and the Triton route slices that
|
||||
# transport-only partner off again before launching.
|
||||
block_map = calls["block_map"]
|
||||
assert block_map.shape[-1] == 3
|
||||
assert calls["variable_block_sizes"] == [5, 64, 64]
|
||||
assert block_map[:, :, :1, :].all(), "prefix queries stay dense"
|
||||
assert block_map[..., :1].all(), "prefix keys are exempt from selection"
|
||||
assert (block_map[:, :, 1:, 1:].sum(dim=-1) == 1).all(), "one video tile per video query"
|
||||
|
||||
# The same odd geometry must still untile back onto the original rows.
|
||||
dense = _h3_impl(topk=64).forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape))
|
||||
reference = torch.nn.functional.scaled_dot_product_attention(
|
||||
*(x.float().transpose(1, 2) for x in (query, query, query))
|
||||
).transpose(1, 2)
|
||||
torch.testing.assert_close(dense.float(), reference, atol=2e-2, rtol=2e-2)
|
||||
|
||||
|
||||
def test_h3_forward_applies_the_learned_compression_gate(monkeypatch):
|
||||
_fake_block_sparse_kernel(monkeypatch, {})
|
||||
impl = _h3_impl(topk=64)
|
||||
prefix_segments, video_shape = (5,), (2, 4, 4)
|
||||
seq_len = 5 + 2 * 4 * 4
|
||||
torch.manual_seed(0)
|
||||
query = torch.randn(1, seq_len, 2, 8, dtype=torch.bfloat16)
|
||||
|
||||
without_gate = impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape))
|
||||
zero_gate = torch.zeros_like(query)
|
||||
with_zero_gate = impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape, gate=zero_gate))
|
||||
live_gate = torch.full_like(query, 0.5)
|
||||
with_live_gate = impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape, gate=live_gate))
|
||||
|
||||
torch.testing.assert_close(with_zero_gate.float(), without_gate.float())
|
||||
assert not torch.allclose(with_live_gate.float(), without_gate.float())
|
||||
|
||||
|
||||
def test_h3_forward_restores_rows_the_packed_padding_excludes(monkeypatch):
|
||||
_fake_block_sparse_kernel(monkeypatch, {})
|
||||
impl = _h3_impl(topk=64)
|
||||
prefix_segments, video_shape = (5,), (2, 4, 4)
|
||||
valid = 5 + 2 * 4 * 4
|
||||
torch.manual_seed(0)
|
||||
query = torch.randn(1, valid + 24, 2, 8, dtype=torch.bfloat16)
|
||||
padding = PackedPaddingMetadata(
|
||||
q_length=valid,
|
||||
kv_length=valid,
|
||||
cu_seqlens_q=torch.tensor([0, valid], dtype=torch.int32),
|
||||
cu_seqlens_k=torch.tensor([0, valid], dtype=torch.int32),
|
||||
)
|
||||
|
||||
output = impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape, packed_padding=padding))
|
||||
|
||||
assert output.shape == query.shape
|
||||
assert torch.count_nonzero(output[:, valid:]) == 0
|
||||
|
||||
|
||||
def test_h3_geometry_keeps_prefix_segments_pure_and_tiles_video_3d():
|
||||
partition, sizes, non_pad, untile, prefix_blocks, video_blocks = _get_h3_tile_metadata(
|
||||
(5, 70, 9), (5, 6, 6), torch.device("cpu")
|
||||
)
|
||||
assert sizes[:4].tolist() == [5, 64, 6, 9]
|
||||
assert prefix_blocks == 4
|
||||
assert video_blocks == 8
|
||||
assert int(sizes.sum()) == 5 + 70 + 9 + 5 * 6 * 6
|
||||
assert partition.numel() == non_pad.numel() == untile.numel() == int(sizes.sum())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fallback_on_error", [False, True])
|
||||
def test_h3_kernel_failure_preserves_fallback_policy(monkeypatch, fallback_on_error):
|
||||
impl = _h3_impl(fallback_on_error=fallback_on_error)
|
||||
query = torch.randn(1, 37, 2, 8)
|
||||
metadata = _h3_metadata((5,), (2, 4, 4))
|
||||
|
||||
def fail(*args, **kwargs):
|
||||
raise RuntimeError("kernel failure")
|
||||
|
||||
monkeypatch.setattr(impl, "_forward_h3", fail)
|
||||
monkeypatch.setattr(impl.sdpa_fallback, "forward", impl.sdpa_fallback.forward_cuda)
|
||||
if fallback_on_error:
|
||||
output = impl.forward_cuda(query, query, query, metadata)
|
||||
expected = impl.sdpa_fallback.forward_cuda(query, query, query, metadata)
|
||||
torch.testing.assert_close(output, expected, rtol=0, atol=0)
|
||||
else:
|
||||
with pytest.raises(RuntimeError, match="kernel failure"):
|
||||
impl.forward_cuda(query, query, query, metadata)
|
||||
|
||||
|
||||
def test_h3_accelerator_failure_is_not_swallowed(monkeypatch):
|
||||
impl = _h3_impl(fallback_on_error=True)
|
||||
query = torch.randn(1, 37, 2, 8)
|
||||
|
||||
def fail(*args, **kwargs):
|
||||
raise torch.AcceleratorError("device failure")
|
||||
|
||||
monkeypatch.setattr(impl, "_forward_h3", fail)
|
||||
with pytest.raises(torch.AcceleratorError, match="device failure"):
|
||||
impl.forward_cuda(query, query, query, _h3_metadata((5,), (2, 4, 4)))
|
||||
|
||||
|
||||
def test_h3_without_layout_delegates_to_shared_backend(monkeypatch):
|
||||
from vllm_omni.diffusion.attention.backends.fastvideo_vsa import FastVideoVSAImpl
|
||||
|
||||
impl = _h3_impl()
|
||||
query = torch.randn(1, 37, 2, 8)
|
||||
metadata = AttentionMetadata()
|
||||
expected = torch.empty_like(query)
|
||||
calls = []
|
||||
|
||||
def forward(self, q, k, v, attn_metadata):
|
||||
calls.append((self, q, k, v, attn_metadata))
|
||||
return expected
|
||||
|
||||
monkeypatch.setattr(FastVideoVSAImpl, "forward_cuda", forward)
|
||||
assert impl.forward_cuda(query, query, query, metadata) is expected
|
||||
assert len(calls) == 1
|
||||
assert all(actual is wanted for actual, wanted in zip(calls[0], (impl, query, query, query, metadata)))
|
||||
@@ -0,0 +1,2 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
@@ -0,0 +1,500 @@
|
||||
#!/usr/bin/env python
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""π0.5 parity against LeRobot.
|
||||
|
||||
Reference: **lerobot 0.6.1**, transformers 5.5.4, torch 2.11.0+cpu. The oracle
|
||||
is LeRobot's own code, so a later version changing its π0.5 implementation
|
||||
fails this without anything here regressing — diff ``policies/pi05/`` first.
|
||||
|
||||
Compares final action chunks under fixed noise: float32 exactly, bfloat16
|
||||
against LeRobot's own bfloat16 layout within ``BF16_ATOL``.
|
||||
|
||||
Needs a separate ``lerobot[pi]`` venv (its dependencies conflict with
|
||||
vllm-omni's) with the vllm-omni ``pi05`` package importable::
|
||||
|
||||
python -m pytest tests/diffusion/models/pi05/test_pi05_parity.py -v -s
|
||||
|
||||
Skips itself when LeRobot is absent. ``PI05_PARITY_MODEL_PATH`` points at a
|
||||
local checkpoint to skip the HF download.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
# local_model: needs real weights + a lerobot venv, so it runs locally rather
|
||||
# than in the ready-CI. Additionally gated on lerobot being importable.
|
||||
_HAS_LEROBOT = importlib.util.find_spec("lerobot") is not None
|
||||
|
||||
# cpu: the comparison runs on CPU in float32 so both implementations see
|
||||
# identical numerics; local_model: it needs a lerobot venv that CI does not have.
|
||||
pytestmark = [pytest.mark.local_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
# ─── Config (fixed; matches LeRobot defaults for a π0.5 checkpoint) ────
|
||||
DEVICE = "cpu"
|
||||
DTYPE_STR = "float32"
|
||||
ATOL = 1e-4
|
||||
BF16_ATOL = 5e-2 # measured 2.95e-2, reproducible; margin is for version drift
|
||||
NUM_STEPS = 10
|
||||
BATCH_SIZE = 2
|
||||
ACTION_DIM = 32
|
||||
STATE_DIM = 32
|
||||
ACTION_HORIZON = 50
|
||||
MAX_TOKEN_LEN = 200 # π0 uses 48
|
||||
NUM_STATE_BINS = 256
|
||||
|
||||
MODEL_PATH = os.environ.get("PI05_PARITY_MODEL_PATH", "lerobot/pi05_base")
|
||||
|
||||
CAMERAS = ("base_0_rgb", "left_wrist_0_rgb", "right_wrist_0_rgb")
|
||||
|
||||
|
||||
def _resolve_checkpoint_dir() -> str:
|
||||
if os.path.isdir(MODEL_PATH):
|
||||
return MODEL_PATH
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
return snapshot_download(repo_id=MODEL_PATH, repo_type="model")
|
||||
|
||||
|
||||
# ─── Dummy dataset stats ──────────────────────────────────────────────
|
||||
def _dummy_dataset_stats() -> dict:
|
||||
"""Identity-ish stats. π0.5 normalizes with QUANTILES by default, so q01/q99
|
||||
carry the load here where π0's mean/std would.
|
||||
|
||||
Using dummy stats means this test needs no checkpoint with real
|
||||
``norm_stats`` — it verifies numerical equivalence, not calibration.
|
||||
"""
|
||||
return {
|
||||
"observation.state": {
|
||||
"mean": torch.zeros(STATE_DIM),
|
||||
"std": torch.ones(STATE_DIM),
|
||||
"q01": -torch.ones(STATE_DIM),
|
||||
"q99": torch.ones(STATE_DIM),
|
||||
},
|
||||
"action": {
|
||||
"mean": torch.zeros(ACTION_DIM),
|
||||
"std": torch.ones(ACTION_DIM),
|
||||
"q01": -torch.ones(ACTION_DIM),
|
||||
"q99": torch.ones(ACTION_DIM),
|
||||
},
|
||||
"images": {
|
||||
cam: {
|
||||
"mean": torch.zeros(3, 224, 224),
|
||||
"std": torch.ones(3, 224, 224),
|
||||
"q01": torch.zeros(3, 224, 224),
|
||||
"q99": torch.ones(3, 224, 224),
|
||||
}
|
||||
for cam in CAMERAS
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _create_dummy_batch(batch_size: int = BATCH_SIZE, num_views: int = 3, device: str = DEVICE) -> dict:
|
||||
"""Reproducible dummy inputs — identical across both implementations."""
|
||||
g = torch.Generator(device="cpu").manual_seed(0)
|
||||
prompt = "Pick up the red block and place it in the bin"
|
||||
batch = {
|
||||
"observation.state": torch.randn(batch_size, STATE_DIM, generator=g, dtype=torch.float32).to(device),
|
||||
"action": torch.randn(batch_size, ACTION_HORIZON, ACTION_DIM, generator=g, dtype=torch.float32).to(device),
|
||||
"task": [prompt for _ in range(batch_size)],
|
||||
}
|
||||
for cam in CAMERAS[:num_views]:
|
||||
batch[f"observation.images.{cam}"] = torch.rand(batch_size, 3, 224, 224, generator=g, dtype=torch.float32).to(
|
||||
device
|
||||
)
|
||||
return batch
|
||||
|
||||
|
||||
# ─── Instantiation ────────────────────────────────────────────────────
|
||||
def _instantiate_lerobot(use_relative_actions: bool = False, action_feature_names=None, dtype: str = DTYPE_STR):
|
||||
from lerobot.policies.pi05 import PI05Policy
|
||||
from lerobot.policies.pi05.processor_pi05 import make_pi05_pre_post_processors
|
||||
|
||||
policy = PI05Policy.from_pretrained(MODEL_PATH, strict=True, dtype=dtype)
|
||||
policy.to(DEVICE)
|
||||
policy.config.device = DEVICE
|
||||
policy.config.use_relative_actions = use_relative_actions
|
||||
if action_feature_names is not None:
|
||||
# Must land on the config *before* the processors are built:
|
||||
# make_pi05_pre_post_processors captures action_feature_names into
|
||||
# RelativeActionsProcessorStep at construction time, and its _build_mask
|
||||
# treats ``action_names is None`` as "shift every dim" — including the
|
||||
# gripper that relative_exclude_joints is supposed to hold back. Setting
|
||||
# it afterwards leaves the step with the all-True mask and silently
|
||||
# compares two different transforms.
|
||||
policy.config.action_feature_names = action_feature_names
|
||||
policy.eval()
|
||||
|
||||
pre, post = make_pi05_pre_post_processors(config=policy.config, dataset_stats=_dummy_dataset_stats())
|
||||
return policy, pre, post
|
||||
|
||||
|
||||
def _instantiate_vllm_omni(
|
||||
use_relative_actions: bool = False,
|
||||
action_feature_names=None,
|
||||
dtype: str = DTYPE_STR,
|
||||
):
|
||||
"""Build the vllm-omni π0.5 model in isolation (no pipeline, no engine)."""
|
||||
from vllm_omni.diffusion.models.pi05 import Pi05Config, Pi05ForActionPrediction
|
||||
from vllm_omni.diffusion.models.pi05.pipeline_pi05 import _set_inference_dtype
|
||||
|
||||
cfg = Pi05Config(
|
||||
max_action_dim=ACTION_DIM,
|
||||
max_state_dim=STATE_DIM,
|
||||
chunk_size=ACTION_HORIZON,
|
||||
num_inference_steps=NUM_STEPS,
|
||||
tokenizer_max_length=MAX_TOKEN_LEN,
|
||||
state_num_bins=NUM_STATE_BINS,
|
||||
dtype=dtype,
|
||||
use_relative_actions=use_relative_actions,
|
||||
relative_exclude_joints=["gripper"] if action_feature_names else [],
|
||||
action_feature_names=action_feature_names,
|
||||
)
|
||||
model = Pi05ForActionPrediction(cfg)
|
||||
_set_inference_dtype(model, getattr(torch, dtype))
|
||||
model.to(device=DEVICE)
|
||||
_load_lerobot_weights(model)
|
||||
model.eval()
|
||||
return model, cfg
|
||||
|
||||
|
||||
def _load_lerobot_weights(model):
|
||||
import safetensors.torch
|
||||
|
||||
path = os.path.join(_resolve_checkpoint_dir(), "model.safetensors")
|
||||
state = safetensors.torch.load_file(path)
|
||||
filled = model.load_weights(list(state.items()))
|
||||
assert filled, "no weights were loaded — the remap rules are broken"
|
||||
|
||||
|
||||
def _extract_lerobot_model_inputs(lerobot_policy, processed_batch):
|
||||
"""Mimic what ``PI05Policy.predict_action_chunk`` feeds into
|
||||
``self.model.sample_actions``. Using these *exact* tensors for vllm-omni
|
||||
means any divergence must come from the core model, not preprocessing.
|
||||
|
||||
Note π0.5 has no ``prepare_state`` step: the state is already inside
|
||||
``lang_tokens``.
|
||||
"""
|
||||
from lerobot.utils.constants import (
|
||||
OBS_LANGUAGE_ATTENTION_MASK,
|
||||
OBS_LANGUAGE_TOKENS,
|
||||
)
|
||||
|
||||
images, img_masks = lerobot_policy._preprocess_images(processed_batch)
|
||||
return images, img_masks, processed_batch[OBS_LANGUAGE_TOKENS], processed_batch[OBS_LANGUAGE_ATTENTION_MASK]
|
||||
|
||||
|
||||
def _make_fixed_noise(batch_size: int, device: str) -> torch.Tensor:
|
||||
g = torch.Generator(device="cpu").manual_seed(42)
|
||||
return torch.randn(batch_size, ACTION_HORIZON, ACTION_DIM, generator=g, dtype=torch.float32).to(device)
|
||||
|
||||
|
||||
# ─── Main parity test ─────────────────────────────────────────────────
|
||||
@pytest.mark.skipif(not _HAS_LEROBOT, reason="lerobot not installed (run in a lerobot venv).")
|
||||
@pytest.mark.parametrize("num_views", [1, 2, 3])
|
||||
def test_pi05_vllm_omni_vs_lerobot(num_views):
|
||||
lerobot_policy, lerobot_pre, _ = _instantiate_lerobot()
|
||||
omni_model, _ = _instantiate_vllm_omni()
|
||||
|
||||
raw_batch = _create_dummy_batch(num_views=num_views)
|
||||
processed = lerobot_pre(copy.deepcopy(raw_batch))
|
||||
images, img_masks, lang_tokens, lang_masks = _extract_lerobot_model_inputs(lerobot_policy, processed)
|
||||
noise = _make_fixed_noise(raw_batch["observation.state"].shape[0], DEVICE)
|
||||
|
||||
# The input contract: 256 tokens per view + a constant 200 text tokens.
|
||||
assert lang_tokens.shape[1] == MAX_TOKEN_LEN, "π0.5 must pad text to exactly 200 tokens"
|
||||
|
||||
with torch.no_grad():
|
||||
lerobot_actions = lerobot_policy.model.sample_actions(
|
||||
images, img_masks, lang_tokens, lang_masks, noise=noise, num_steps=NUM_STEPS
|
||||
)
|
||||
omni_actions = omni_model.sample_actions(
|
||||
images=images,
|
||||
image_masks=img_masks,
|
||||
lang_tokens=lang_tokens,
|
||||
lang_masks=lang_masks,
|
||||
noise=noise,
|
||||
num_steps=NUM_STEPS,
|
||||
)
|
||||
|
||||
diff = (lerobot_actions.float() - omni_actions.float()).abs()
|
||||
print(f"[parity] views={num_views} |Δ| max={diff.max().item():.2e} mean={diff.mean().item():.2e}")
|
||||
if not torch.allclose(lerobot_actions.float(), omni_actions.float(), atol=ATOL):
|
||||
_diagnose_divergence(lerobot_policy.model, omni_model, images, img_masks, lang_tokens, lang_masks, noise)
|
||||
assert torch.allclose(lerobot_actions.float(), omni_actions.float(), atol=ATOL), (
|
||||
f"actions differ beyond atol={ATOL}; max_diff={diff.max().item():.2e}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_LEROBOT, reason="lerobot not installed (run in a lerobot venv).")
|
||||
def test_pi05_bfloat16_matches_lerobot_bfloat16():
|
||||
"""Both sides in bfloat16, so this compares implementations rather than
|
||||
measuring bfloat16's drift from float32."""
|
||||
# LeRobot applies the layout in PaliGemmaWithExpertModel.__init__ from
|
||||
# config.dtype, so the policy must be built as bfloat16 rather than cast after.
|
||||
lerobot_policy, lerobot_pre, _ = _instantiate_lerobot(dtype="bfloat16")
|
||||
|
||||
omni_model, _ = _instantiate_vllm_omni(dtype="bfloat16")
|
||||
|
||||
raw_batch = _create_dummy_batch(num_views=3)
|
||||
processed = lerobot_pre(copy.deepcopy(raw_batch))
|
||||
images, img_masks, lang_tokens, lang_masks = _extract_lerobot_model_inputs(lerobot_policy, processed)
|
||||
noise = _make_fixed_noise(raw_batch["observation.state"].shape[0], DEVICE)
|
||||
|
||||
with torch.no_grad():
|
||||
lerobot_actions = lerobot_policy.model.sample_actions(
|
||||
images, img_masks, lang_tokens, lang_masks, noise=noise, num_steps=NUM_STEPS
|
||||
)
|
||||
omni_actions = omni_model.sample_actions(
|
||||
images=images,
|
||||
image_masks=img_masks,
|
||||
lang_tokens=lang_tokens,
|
||||
lang_masks=lang_masks,
|
||||
noise=noise,
|
||||
num_steps=NUM_STEPS,
|
||||
)
|
||||
|
||||
diff = (lerobot_actions.float() - omni_actions.float()).abs()
|
||||
print(f"[parity] bfloat16 |Δ| max={diff.max().item():.2e} mean={diff.mean().item():.2e}")
|
||||
assert torch.allclose(lerobot_actions.float(), omni_actions.float(), atol=BF16_ATOL), (
|
||||
f"bfloat16 actions differ beyond atol={BF16_ATOL}; max_diff={diff.max().item():.2e}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_LEROBOT, reason="lerobot not installed (run in a lerobot venv).")
|
||||
def test_pi05_prompt_parity():
|
||||
"""π0.5's state is carried as prompt tokens, so the tokenized prompt is
|
||||
itself part of the contract. A bin-index mismatch here (e.g. from running
|
||||
the discretizer before the normalizer) would otherwise only surface as a
|
||||
small, plausible-looking action difference.
|
||||
"""
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
from vllm_omni.diffusion.models.pi05.processor_pi05 import (
|
||||
apply_norm,
|
||||
build_norm_stats,
|
||||
build_pi05_prompt,
|
||||
tokenize_prompt,
|
||||
)
|
||||
|
||||
lerobot_policy, lerobot_pre, _ = _instantiate_lerobot()
|
||||
raw_batch = _create_dummy_batch(batch_size=1)
|
||||
processed = lerobot_pre(copy.deepcopy(raw_batch))
|
||||
|
||||
from lerobot.utils.constants import OBS_LANGUAGE_TOKENS
|
||||
|
||||
lerobot_tokens = processed[OBS_LANGUAGE_TOKENS][0]
|
||||
|
||||
stats = _dummy_dataset_stats()["observation.state"]
|
||||
norm = build_norm_stats({"state": {"q01": stats["q01"], "q99": stats["q99"]}}, "state")
|
||||
normalized = apply_norm(raw_batch["observation.state"][0].float(), norm)
|
||||
prompt = build_pi05_prompt(
|
||||
task=raw_batch["task"][0],
|
||||
normalized_state=normalized.numpy(),
|
||||
state_num_bins=NUM_STATE_BINS,
|
||||
)
|
||||
tokenizer = AutoTokenizer.from_pretrained("google/paligemma-3b-pt-224", padding_side="right")
|
||||
ids, _ = tokenize_prompt(tokenizer, prompt, MAX_TOKEN_LEN)
|
||||
|
||||
assert len(ids) == MAX_TOKEN_LEN
|
||||
assert torch.equal(torch.tensor(ids), lerobot_tokens.cpu()), "π0.5 prompt tokens diverge from LeRobot"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_LEROBOT, reason="lerobot not installed (run in a lerobot venv).")
|
||||
@pytest.mark.parametrize("state_dim", [7, STATE_DIM])
|
||||
def test_pi05_prompt_parity_across_state_widths(state_dim):
|
||||
"""``pi05_base`` declares a 32-dim state, which is exactly ``max_state_dim``,
|
||||
so the full-policy parity above cannot tell padding apart from the real
|
||||
width. This drives LeRobot's tokenizer step directly, at both widths.
|
||||
"""
|
||||
from lerobot.lerobot_types import TransitionKey
|
||||
from lerobot.policies.pi05.processor_pi05 import Pi05PrepareStateTokenizerProcessorStep
|
||||
from lerobot.utils.constants import OBS_STATE
|
||||
|
||||
from vllm_omni.diffusion.models.pi05.processor_pi05 import build_pi05_prompt
|
||||
|
||||
g = torch.Generator().manual_seed(state_dim)
|
||||
state = torch.randn(state_dim, generator=g, dtype=torch.float32)
|
||||
task = "pick up the red block"
|
||||
|
||||
step = Pi05PrepareStateTokenizerProcessorStep(max_state_dim=STATE_DIM)
|
||||
transition = step(
|
||||
{
|
||||
TransitionKey.OBSERVATION: {OBS_STATE: state.unsqueeze(0)},
|
||||
TransitionKey.COMPLEMENTARY_DATA: {"task": [task]},
|
||||
}
|
||||
)
|
||||
lerobot_prompt = transition[TransitionKey.COMPLEMENTARY_DATA]["task"][0]
|
||||
|
||||
prompt = build_pi05_prompt(task=task, normalized_state=state.numpy())
|
||||
|
||||
assert prompt == lerobot_prompt
|
||||
assert len(prompt.split("State: ")[1].split(";")[0].split()) == state_dim
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_LEROBOT, reason="lerobot not installed (run in a lerobot venv).")
|
||||
@pytest.mark.parametrize(
|
||||
"lerobot_mode,stats,ours,state,state_dim",
|
||||
[
|
||||
("MEAN_STD", {"mean": [0.0], "std": [1e-7]}, {"mode": "mean_std", "mean": [0.0], "std": [1e-7]}, [1e-7], 1),
|
||||
("QUANTILES", {"q01": [0.0], "q99": [1e-7]}, {"mode": "quantile", "q01": [0.0], "q99": [1e-7]}, [1e-7], 1),
|
||||
("QUANTILES", {"q01": [2.0], "q99": [2.0]}, {"mode": "quantile", "q01": [2.0], "q99": [2.0]}, [2.0], 1),
|
||||
(
|
||||
"QUANTILES",
|
||||
{"q01": [0.0] * 4, "q99": [10.0] * 4},
|
||||
{"mode": "quantile", "q01": [0.0] * 4, "q99": [10.0] * 4},
|
||||
[-10.0, 0.0, 5.0, 20.0],
|
||||
4,
|
||||
),
|
||||
(
|
||||
"MEAN_STD",
|
||||
{"mean": [1.0, 2.0], "std": [0.5, 4.0]},
|
||||
{"mode": "mean_std", "mean": [1.0, 2.0], "std": [0.5, 4.0]},
|
||||
[3.0, -6.0],
|
||||
2,
|
||||
),
|
||||
("MIN_MAX", {"min": [-1.0], "max": [3.0]}, {"mode": "min_max", "min": [-1.0], "max": [3.0]}, [2.0], 1),
|
||||
],
|
||||
)
|
||||
def test_pi05_state_normalization_parity(lerobot_mode, stats, ours, state, state_dim):
|
||||
"""State normalization against LeRobot's ``NormalizerProcessorStep``.
|
||||
|
||||
The full-policy parity test feeds both sides stats with a wide range, so it
|
||||
cannot see the denominator rule. A wrongly normalized state still discretizes
|
||||
into a well-formed prompt: for ``q01=0, q99=1e-7`` a threshold substitution
|
||||
moves the state token from bin 255 to bin 0.
|
||||
"""
|
||||
from lerobot.configs.types import FeatureType, NormalizationMode, PolicyFeature
|
||||
from lerobot.processor.normalize_processor import NormalizerProcessorStep
|
||||
from lerobot.utils.constants import OBS_STATE
|
||||
|
||||
from vllm_omni.diffusion.models.pi05.processor_pi05 import apply_norm, build_norm_stats
|
||||
|
||||
step = NormalizerProcessorStep(
|
||||
features={OBS_STATE: PolicyFeature(type=FeatureType.STATE, shape=(state_dim,))},
|
||||
norm_map={FeatureType.STATE: getattr(NormalizationMode, lerobot_mode)},
|
||||
stats={OBS_STATE: {k: torch.tensor(v, dtype=torch.float32) for k, v in stats.items()}},
|
||||
)
|
||||
expected = step._apply_transform(
|
||||
torch.tensor(state, dtype=torch.float32), OBS_STATE, FeatureType.STATE, inverse=False
|
||||
)
|
||||
|
||||
actual = apply_norm(torch.tensor(state, dtype=torch.float32), build_norm_stats({"state": ours}, "state"))
|
||||
|
||||
assert torch.allclose(actual, expected, atol=1e-6)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_LEROBOT, reason="lerobot not installed (run in a lerobot venv).")
|
||||
def test_pi05_relative_actions_parity():
|
||||
"""With ``use_relative_actions=True`` the checkpoint's norm_stats live in
|
||||
relative space, so the post-pipeline must add the raw state back. Compares
|
||||
the full pre → model → post path against LeRobot's."""
|
||||
from lerobot.utils.constants import (
|
||||
OBS_LANGUAGE_ATTENTION_MASK,
|
||||
OBS_LANGUAGE_TOKENS,
|
||||
)
|
||||
|
||||
from vllm_omni.diffusion.models.pi05.processor_pi05 import (
|
||||
Pi05RelativeActions,
|
||||
apply_norm,
|
||||
build_norm_stats,
|
||||
)
|
||||
|
||||
action_names = [f"joint_{i}" for i in range(ACTION_DIM - 1)] + ["gripper"]
|
||||
lerobot_policy, lerobot_pre, lerobot_post = _instantiate_lerobot(
|
||||
use_relative_actions=True, action_feature_names=action_names
|
||||
)
|
||||
omni_model, cfg = _instantiate_vllm_omni(use_relative_actions=True, action_feature_names=action_names)
|
||||
|
||||
raw_batch = _create_dummy_batch()
|
||||
processed = lerobot_pre(copy.deepcopy(raw_batch))
|
||||
images, img_masks = lerobot_policy._preprocess_images(processed)
|
||||
lang_tokens = processed[OBS_LANGUAGE_TOKENS]
|
||||
lang_masks = processed[OBS_LANGUAGE_ATTENTION_MASK]
|
||||
noise = _make_fixed_noise(raw_batch["observation.state"].shape[0], DEVICE)
|
||||
|
||||
with torch.no_grad():
|
||||
lerobot_actions = lerobot_post(
|
||||
lerobot_policy.model.sample_actions(
|
||||
images, img_masks, lang_tokens, lang_masks, noise=noise, num_steps=NUM_STEPS
|
||||
)
|
||||
)
|
||||
omni_raw = omni_model.sample_actions(
|
||||
images=images,
|
||||
image_masks=img_masks,
|
||||
lang_tokens=lang_tokens,
|
||||
lang_masks=lang_masks,
|
||||
noise=noise,
|
||||
num_steps=NUM_STEPS,
|
||||
)
|
||||
|
||||
rel = Pi05RelativeActions(
|
||||
enabled=True,
|
||||
exclude_joints=cfg.relative_exclude_joints,
|
||||
action_names=action_names,
|
||||
max_action_dim=ACTION_DIM,
|
||||
)
|
||||
omni_actions = apply_norm(omni_raw, build_norm_stats(cfg.norm_stats, "action"), inverse=True)
|
||||
# Per-sample reference state, matching LeRobot: its
|
||||
# RelativeActionsProcessorStep caches the whole (B, D) state on the input
|
||||
# side and shifts each sample by its own row. Passing a single row here
|
||||
# would shift every sample by sample 0's state, which only agrees for B=1.
|
||||
omni_actions = rel.to_absolute(omni_actions, raw_batch["observation.state"])
|
||||
|
||||
lerobot_actions = torch.as_tensor(lerobot_actions).float()
|
||||
assert torch.allclose(lerobot_actions, omni_actions.float(), atol=ATOL), (
|
||||
"relative-action path diverges from LeRobot; "
|
||||
f"max_diff={(lerobot_actions - omni_actions.float()).abs().max().item():.2e}"
|
||||
)
|
||||
|
||||
|
||||
# ─── Per-stage divergence diagnostics ─────────────────────────────────
|
||||
@torch.no_grad()
|
||||
def _diagnose_divergence(lerobot_flow_model, omni_model, images, img_masks, lang_tokens, lang_masks, noise):
|
||||
"""Localize a numerical mismatch to a specific stage:
|
||||
1. Prefix embeddings (SigLIP / embed_tokens / projector) — image vs lang.
|
||||
2. Prefix KV cache layer 0 — PaliGemma LM attention.
|
||||
3. The AdaRMS timestep conditioning vector.
|
||||
4. A single denoise_step velocity at t=1.0 — action expert forward.
|
||||
"""
|
||||
from vllm_omni.diffusion.models.pi05.modeling_pi05 import (
|
||||
make_att_2d_masks,
|
||||
prepare_attention_masks_4d,
|
||||
)
|
||||
|
||||
lr_embs, lr_pad, lr_att = lerobot_flow_model.embed_prefix(images, img_masks, lang_tokens, lang_masks)
|
||||
sg_embs, sg_pad, sg_att = omni_model.embed_prefix(images, img_masks, lang_tokens, lang_masks)
|
||||
print(f"[diag] prefix_embs max|Δ| = {(lr_embs.float() - sg_embs.float()).abs().max().item():.2e}")
|
||||
print(f"[diag] prefix_pad_masks equal: {torch.equal(lr_pad, sg_pad)}")
|
||||
|
||||
img_len = 256 * len(images)
|
||||
print(f"[diag] image slice max|Δ| = {(lr_embs[:, :img_len] - sg_embs[:, :img_len]).abs().max().item():.2e}")
|
||||
print(f"[diag] lang slice max|Δ| = {(lr_embs[:, img_len:] - sg_embs[:, img_len:]).abs().max().item():.2e}")
|
||||
|
||||
prefix_att_4d = prepare_attention_masks_4d(make_att_2d_masks(sg_pad, sg_att))
|
||||
prefix_pos = torch.cumsum(sg_pad, dim=1) - 1
|
||||
_, sg_kv = omni_model.paligemma_with_expert.forward(
|
||||
attention_mask=prefix_att_4d,
|
||||
position_ids=prefix_pos,
|
||||
past_key_values=None,
|
||||
inputs_embeds=[sg_embs, None],
|
||||
use_cache=True,
|
||||
)
|
||||
|
||||
t = torch.ones(noise.shape[0], dtype=torch.float32)
|
||||
sg_cond = omni_model.embed_timestep(t)
|
||||
print(f"[diag] adarms cond: shape={tuple(sg_cond.shape)} mean={sg_cond.mean().item():+.4f}")
|
||||
|
||||
sg_vt = omni_model.denoise_step(sg_pad, sg_kv, noise, t)
|
||||
print(f"[diag] denoise_step(t=1) v_t mean={sg_vt.mean().item():+.6f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_pi05_vllm_omni_vs_lerobot(3)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,196 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from vllm_omni.diffusion.layers.rope import RotaryEmbedding
|
||||
from vllm_omni.diffusion.models.qwen_image.qwen_image_transformer import (
|
||||
_qwen_image_qk_norm_rope,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
|
||||
|
||||
BATCH = 2
|
||||
Q_HEADS = 24
|
||||
K_HEADS = 24
|
||||
HEAD_DIM = 128
|
||||
EPS = 1e-6
|
||||
SEED = 2026
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QwenImageQKInput:
|
||||
q: torch.Tensor
|
||||
k: torch.Tensor
|
||||
norm_q: nn.RMSNorm
|
||||
norm_k: nn.RMSNorm
|
||||
freqs: torch.Tensor
|
||||
|
||||
|
||||
def _make_input(
|
||||
*,
|
||||
seq_len: int,
|
||||
dtype: torch.dtype,
|
||||
device: torch.device,
|
||||
packed_qkv_view: bool,
|
||||
) -> QwenImageQKInput:
|
||||
gen = torch.Generator(device=device)
|
||||
gen.manual_seed(SEED + seq_len)
|
||||
|
||||
if packed_qkv_view:
|
||||
qkv_dim = (Q_HEADS + K_HEADS + K_HEADS) * HEAD_DIM
|
||||
qkv = torch.randn(BATCH, seq_len, qkv_dim, device=device, dtype=dtype, generator=gen)
|
||||
q, k, _ = qkv.split(
|
||||
[
|
||||
Q_HEADS * HEAD_DIM,
|
||||
K_HEADS * HEAD_DIM,
|
||||
K_HEADS * HEAD_DIM,
|
||||
],
|
||||
dim=-1,
|
||||
)
|
||||
q = q.unflatten(-1, (Q_HEADS, HEAD_DIM))
|
||||
k = k.unflatten(-1, (K_HEADS, HEAD_DIM))
|
||||
else:
|
||||
q = torch.randn(BATCH, seq_len, Q_HEADS, HEAD_DIM, device=device, dtype=dtype, generator=gen)
|
||||
k = torch.randn(BATCH, seq_len, K_HEADS, HEAD_DIM, device=device, dtype=dtype, generator=gen)
|
||||
|
||||
norm_q = nn.RMSNorm(HEAD_DIM, eps=EPS, device=device, dtype=dtype)
|
||||
norm_k = nn.RMSNorm(HEAD_DIM, eps=EPS, device=device, dtype=dtype)
|
||||
norm_q.requires_grad_(False)
|
||||
norm_k.requires_grad_(False)
|
||||
with torch.no_grad():
|
||||
norm_q.weight.copy_(torch.randn(HEAD_DIM, device=device, dtype=dtype, generator=gen))
|
||||
norm_k.weight.copy_(torch.randn(HEAD_DIM, device=device, dtype=dtype, generator=gen))
|
||||
angles = torch.randn(seq_len, HEAD_DIM // 2, device=device, dtype=torch.float32, generator=gen)
|
||||
freqs = torch.polar(torch.ones_like(angles), angles)
|
||||
return QwenImageQKInput(q=q, k=k, norm_q=norm_q, norm_k=norm_k, freqs=freqs)
|
||||
|
||||
|
||||
def _eager_rotary(data: QwenImageQKInput) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
q = data.norm_q(data.q)
|
||||
k = data.norm_k(data.k)
|
||||
rope = RotaryEmbedding(is_neox_style=False)
|
||||
cos = data.freqs.real.to(data.q.dtype)
|
||||
sin = data.freqs.imag.to(data.q.dtype)
|
||||
return rope(q, cos, sin), rope(k, cos, sin)
|
||||
|
||||
|
||||
def _fp32_complex_rotary(x: torch.Tensor, freqs: torch.Tensor) -> torch.Tensor:
|
||||
"""Local copy of the old CUDA helper; fused kernel still tracks this math."""
|
||||
paired = torch.view_as_complex(x.float().reshape(*x.shape[:-1], -1, 2))
|
||||
return torch.view_as_real(paired * freqs.unsqueeze(1)).flatten(3).to(x.dtype)
|
||||
|
||||
|
||||
def _fused_kernel_reference(data: QwenImageQKInput) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
q = data.norm_q(data.q)
|
||||
k = data.norm_k(data.k)
|
||||
return _fp32_complex_rotary(q, data.freqs), _fp32_complex_rotary(k, data.freqs)
|
||||
|
||||
|
||||
def _run(data: QwenImageQKInput, *, use_fused: bool = True) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
return _qwen_image_qk_norm_rope(
|
||||
data.q,
|
||||
data.k,
|
||||
data.norm_q,
|
||||
data.norm_k,
|
||||
data.freqs,
|
||||
RotaryEmbedding(is_neox_style=False),
|
||||
EPS,
|
||||
use_fused=use_fused,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
def test_qwen_image_qk_norm_rope_cuda_fp32_fallback_matches_reference():
|
||||
data = _make_input(
|
||||
seq_len=7,
|
||||
dtype=torch.float32,
|
||||
device=torch.device("cuda:0"),
|
||||
packed_qkv_view=True,
|
||||
)
|
||||
|
||||
actual_q, actual_k = _run(data, use_fused=False)
|
||||
expected_q, expected_k = _eager_rotary(data)
|
||||
|
||||
torch.testing.assert_close(actual_q, expected_q, atol=1e-5, rtol=1e-5)
|
||||
torch.testing.assert_close(actual_k, expected_k, atol=1e-5, rtol=1e-5)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
def test_qwen_image_qk_norm_rope_cuda_fp16_fallback_matches_reference():
|
||||
data = _make_input(
|
||||
seq_len=7,
|
||||
dtype=torch.float16,
|
||||
device=torch.device("cuda:0"),
|
||||
packed_qkv_view=True,
|
||||
)
|
||||
|
||||
actual_q, actual_k = _run(data, use_fused=False)
|
||||
expected_q, expected_k = _eager_rotary(data)
|
||||
|
||||
torch.testing.assert_close(actual_q, expected_q, atol=1e-3, rtol=1e-3)
|
||||
torch.testing.assert_close(actual_k, expected_k, atol=1e-3, rtol=1e-3)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@pytest.mark.parametrize("seq_len", [1, 7, 257, 1024])
|
||||
@pytest.mark.parametrize("packed_qkv_view", [False, True])
|
||||
def test_qwen_image_fused_qk_norm_rope_cuda_matches_fp32_rope_reference(
|
||||
seq_len: int,
|
||||
packed_qkv_view: bool,
|
||||
):
|
||||
data = _make_input(
|
||||
seq_len=seq_len,
|
||||
dtype=torch.bfloat16,
|
||||
device=torch.device("cuda:0"),
|
||||
packed_qkv_view=packed_qkv_view,
|
||||
)
|
||||
|
||||
actual_q, actual_k = _run(data)
|
||||
expected_q, expected_k = _fused_kernel_reference(data)
|
||||
|
||||
torch.testing.assert_close(actual_q, expected_q, atol=0.0625, rtol=0.02)
|
||||
torch.testing.assert_close(actual_k, expected_k, atol=0.0625, rtol=0.02)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
def test_qwen_image_fused_qk_norm_rope_torch_compile_fullgraph_capture():
|
||||
data = _make_input(
|
||||
seq_len=257,
|
||||
dtype=torch.bfloat16,
|
||||
device=torch.device("cuda:0"),
|
||||
packed_qkv_view=True,
|
||||
)
|
||||
rope = RotaryEmbedding(is_neox_style=False)
|
||||
|
||||
def fn(
|
||||
q: torch.Tensor,
|
||||
k: torch.Tensor,
|
||||
freqs: torch.Tensor,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
return _qwen_image_qk_norm_rope(
|
||||
q,
|
||||
k,
|
||||
data.norm_q,
|
||||
data.norm_k,
|
||||
freqs,
|
||||
rope,
|
||||
EPS,
|
||||
)
|
||||
|
||||
compiled_fn = torch.compile(fn, dynamic=True, fullgraph=True)
|
||||
expected_q, expected_k = fn(data.q, data.k, data.freqs)
|
||||
actual_q, actual_k = compiled_fn(data.q, data.k, data.freqs)
|
||||
|
||||
torch.testing.assert_close(actual_q, expected_q, atol=0.0625, rtol=0.02)
|
||||
torch.testing.assert_close(actual_k, expected_k, atol=0.0625, rtol=0.02)
|
||||
@@ -0,0 +1,131 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Encoder/VAE placement under cpu_offload; DiT follows the loader device context."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
_DEVICE_STACK: list[str] = ["cpu"]
|
||||
_REAL_TORCH_DEVICE = torch.device
|
||||
|
||||
|
||||
class _FakeDevice:
|
||||
def __init__(self, type_: str):
|
||||
self.type = type_
|
||||
self.index = 0
|
||||
|
||||
def __enter__(self):
|
||||
_DEVICE_STACK.append(self.type)
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
_DEVICE_STACK.pop()
|
||||
return False
|
||||
|
||||
|
||||
def _fake_torch_device(spec):
|
||||
if isinstance(spec, _FakeDevice):
|
||||
return spec
|
||||
if isinstance(spec, _REAL_TORCH_DEVICE):
|
||||
return _FakeDevice(spec.type)
|
||||
return _FakeDevice(str(spec).split(":")[0])
|
||||
|
||||
|
||||
class _RecordingTransformer(nn.Module):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__()
|
||||
self.probe_device_type = _DEVICE_STACK[-1]
|
||||
|
||||
|
||||
class _MoveableModule(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.placed_device = _FakeDevice("cpu")
|
||||
self.weight = nn.Parameter(torch.zeros(1))
|
||||
|
||||
def to(self, device, *args, **kwargs):
|
||||
if isinstance(device, _FakeDevice):
|
||||
self.placed_device = device
|
||||
elif isinstance(device, _REAL_TORCH_DEVICE):
|
||||
self.placed_device = _FakeDevice(device.type)
|
||||
else:
|
||||
self.placed_device = _FakeDevice(str(device).split(":")[0])
|
||||
return self
|
||||
|
||||
@property
|
||||
def temperal_downsample(self):
|
||||
return [True, True, True]
|
||||
|
||||
|
||||
def _run_pipeline_init(monkeypatch, *, enable_cpu_offload, loader_device):
|
||||
import vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image as pipe_mod
|
||||
from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image import QwenImagePipeline
|
||||
|
||||
enc = _MoveableModule()
|
||||
vae = _MoveableModule()
|
||||
_DEVICE_STACK.clear()
|
||||
_DEVICE_STACK.append(loader_device)
|
||||
|
||||
monkeypatch.setattr(pipe_mod, "get_local_device", lambda: _REAL_TORCH_DEVICE("cuda"))
|
||||
monkeypatch.setattr(pipe_mod.torch, "device", _fake_torch_device)
|
||||
monkeypatch.setattr(pipe_mod, "prefetch_subfolders", lambda *a, **k: None)
|
||||
monkeypatch.setattr(pipe_mod, "get_transformer_config_kwargs", lambda *a, **k: {})
|
||||
monkeypatch.setattr(pipe_mod, "QwenImageTransformer2DModel", _RecordingTransformer)
|
||||
monkeypatch.setattr(
|
||||
pipe_mod.FlowMatchEulerDiscreteScheduler,
|
||||
"from_pretrained",
|
||||
classmethod(lambda cls, *a, **k: MagicMock()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
pipe_mod.Qwen2Tokenizer,
|
||||
"from_pretrained",
|
||||
classmethod(lambda cls, *a, **k: MagicMock()),
|
||||
)
|
||||
|
||||
def _prefetch(factory, *args, **kwargs):
|
||||
subfolder = kwargs.get("subfolder")
|
||||
if subfolder == "text_encoder":
|
||||
return enc
|
||||
if subfolder == "vae":
|
||||
return vae
|
||||
raise AssertionError(f"unexpected prefetch subfolder={subfolder!r}")
|
||||
|
||||
monkeypatch.setattr(pipe_mod, "from_pretrained_with_prefetch", _prefetch)
|
||||
monkeypatch.setattr(
|
||||
QwenImagePipeline,
|
||||
"setup_diffusion_pipeline_profiler",
|
||||
lambda self, **kwargs: None,
|
||||
)
|
||||
|
||||
od_config = SimpleNamespace(
|
||||
model="/tmp/unused-qwen-image",
|
||||
parallel_config=SimpleNamespace(),
|
||||
tf_model_config={},
|
||||
quantization_config=None,
|
||||
enable_cpu_offload=enable_cpu_offload,
|
||||
enable_diffusion_pipeline_profiler=False,
|
||||
)
|
||||
return QwenImagePipeline(od_config=od_config)
|
||||
|
||||
|
||||
def test_pipeline_cpu_offload_parks_encoder_vae_on_cpu(monkeypatch):
|
||||
"""#7555: cpu_offload keeps BF16 encoder/VAE off the GPU during DiT init."""
|
||||
pipe = _run_pipeline_init(monkeypatch, enable_cpu_offload=True, loader_device="cuda")
|
||||
assert pipe.text_encoder.placed_device.type == "cpu"
|
||||
assert pipe.vae.placed_device.type == "cpu"
|
||||
assert pipe.transformer.probe_device_type == "cuda"
|
||||
|
||||
|
||||
def test_pipeline_dit_follows_loader_cpu_without_override(monkeypatch):
|
||||
"""Layerwise / HSDP: DiT must not be forced onto CUDA by the pipeline."""
|
||||
pipe = _run_pipeline_init(monkeypatch, enable_cpu_offload=False, loader_device="cpu")
|
||||
assert pipe.transformer.probe_device_type == "cpu"
|
||||
assert pipe.text_encoder.placed_device.type == "cuda"
|
||||
assert pipe.vae.placed_device.type == "cuda"
|
||||
@@ -3,37 +3,12 @@ import torch
|
||||
|
||||
from vllm_omni.diffusion.models.qwen_image.qwen_image_transformer import (
|
||||
QwenEmbedRope,
|
||||
_apply_qwen_image_rotary_emb,
|
||||
)
|
||||
from vllm_omni.diffusion.models.qwen_image.rope_utils import txt_seq_lens_from_embeds
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
|
||||
@pytest.mark.parametrize("noncontiguous", [False, True])
|
||||
def test_qwen_rotary_preserves_frequency_precision(dtype, noncontiguous):
|
||||
from diffusers.models.transformers.transformer_qwenimage import apply_rotary_emb_qwen
|
||||
|
||||
generator = torch.Generator().manual_seed(42)
|
||||
x = torch.randn(2, 17, 3, 128, generator=generator).to(dtype)
|
||||
if noncontiguous:
|
||||
x = x.transpose(1, 2).contiguous().transpose(1, 2)
|
||||
angles = torch.randn(17, 64, generator=generator)
|
||||
freqs = torch.polar(torch.ones_like(angles), angles)
|
||||
expected = apply_rotary_emb_qwen(x, freqs, use_real=False)
|
||||
actual = _apply_qwen_image_rotary_emb(x, freqs)
|
||||
assert actual.dtype == dtype
|
||||
assert actual.shape == x.shape
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
|
||||
if dtype == torch.bfloat16:
|
||||
# A regression to early BF16 frequency rounding must be observable.
|
||||
rounded_freqs = torch.complex(freqs.real.to(dtype).float(), freqs.imag.to(dtype).float())
|
||||
rounded = _apply_qwen_image_rotary_emb(x, rounded_freqs)
|
||||
assert not torch.equal(actual, rounded)
|
||||
|
||||
|
||||
def test_txt_seq_lens_from_embeds_uses_padded_width_not_valid_token_count():
|
||||
prompt_embeds = torch.zeros(2, 64, 8)
|
||||
prompt_embeds_mask = torch.zeros(2, 64, dtype=torch.bool)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from vllm_omni.diffusion.models.qwen_image import qwen_image_transformer
|
||||
from vllm_omni.diffusion.models.qwen_image.qwen_image_transformer import QwenImageTransformerBlock
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _StaticOutput(nn.Module):
|
||||
def __init__(self, output: torch.Tensor):
|
||||
super().__init__()
|
||||
self.output = output
|
||||
|
||||
def forward(self, _: torch.Tensor) -> torch.Tensor:
|
||||
return self.output
|
||||
|
||||
|
||||
class _RecordingNorm(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.calls: list[tuple[torch.Tensor, torch.Tensor, torch.Tensor]] = []
|
||||
|
||||
def forward(self, x: torch.Tensor, scale: torch.Tensor, shift: torch.Tensor) -> torch.Tensor:
|
||||
self.calls.append((x, scale, shift))
|
||||
return x
|
||||
|
||||
|
||||
class _ZeroAttention(nn.Module):
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
encoder_hidden_states: torch.Tensor,
|
||||
**_: object,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
return torch.zeros_like(hidden_states), torch.zeros_like(encoder_hidden_states)
|
||||
|
||||
|
||||
class _ZeroMLP(nn.Module):
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
return torch.zeros_like(x)
|
||||
|
||||
|
||||
def test_non_cuda_select01_uses_adalayernorm_dispatch(monkeypatch):
|
||||
hidden_size = 4
|
||||
block = QwenImageTransformerBlock.__new__(QwenImageTransformerBlock)
|
||||
nn.Module.__init__(block)
|
||||
block.zero_cond_t = True
|
||||
block.img_mod = _StaticOutput(torch.zeros(2, 6 * hidden_size))
|
||||
block.txt_mod = _StaticOutput(torch.zeros(1, 6 * hidden_size))
|
||||
block.img_norm1 = _RecordingNorm()
|
||||
block.img_norm2 = _RecordingNorm()
|
||||
block.txt_norm1 = _RecordingNorm()
|
||||
block.txt_norm2 = _RecordingNorm()
|
||||
block.attn = _ZeroAttention()
|
||||
block.img_mlp = _ZeroMLP()
|
||||
block.txt_mlp = _ZeroMLP()
|
||||
|
||||
monkeypatch.setattr(qwen_image_transformer, "can_use_qwen_select01_triton", lambda _: False)
|
||||
|
||||
def fail_if_called(*args, **kwargs):
|
||||
pytest.fail("non-CUDA select01 must preserve the AdaLayerNorm dispatch")
|
||||
|
||||
monkeypatch.setattr(qwen_image_transformer, "fused_layernorm_select01", fail_if_called)
|
||||
monkeypatch.setattr(qwen_image_transformer, "fused_residual_layernorm_select01", fail_if_called)
|
||||
|
||||
hidden_states = torch.randn(1, 3, hidden_size)
|
||||
encoder_hidden_states = torch.randn(1, 2, hidden_size)
|
||||
modulate_index = torch.tensor([[0, 1, 0]])
|
||||
block(
|
||||
hidden_states,
|
||||
encoder_hidden_states,
|
||||
torch.ones(1, 2),
|
||||
torch.zeros(2, hidden_size),
|
||||
(torch.empty(0), torch.empty(0)),
|
||||
modulate_index=modulate_index,
|
||||
)
|
||||
|
||||
assert len(block.img_norm1.calls) == 1
|
||||
assert len(block.img_norm2.calls) == 1
|
||||
for _, scale, shift in (*block.img_norm1.calls, *block.img_norm2.calls):
|
||||
assert scale.shape == hidden_states.shape
|
||||
assert shift.shape == hidden_states.shape
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
@@ -33,7 +36,7 @@ class StubScheduler:
|
||||
self.config = SimpleNamespace(num_train_timesteps=1000)
|
||||
self.set_timesteps_calls: list[tuple[int, torch.device]] = []
|
||||
|
||||
def set_timesteps(self, num_steps: int, device: torch.device) -> None:
|
||||
def set_timesteps(self, num_steps: int, device: torch.device, shift: float | None = None) -> None:
|
||||
self.set_timesteps_calls.append((num_steps, device))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"source_url": "https://github.com/Wan-Video/Wan2.2/blob/42bf4cfaa384bc21833865abc2f9e6c0e67233dc/wan/utils/fm_solvers_unipc.py",
|
||||
"source_sha256": "0dec8c7ed17f6f2049275c6848113314da6ccec1c8db5bdc89df43c05c6038d9",
|
||||
"git_head": "42bf4cfaa384bc21833865abc2f9e6c0e67233dc",
|
||||
"num_train_timesteps": 1000,
|
||||
"use_dynamic_shifting": false,
|
||||
"reproduction": "Load FlowUniPCMultistepScheduler from source_url; construct with num_train_timesteps=1000, shift=1.0, use_dynamic_shifting=False; call set_timesteps(40, device='cpu', shift=12.0); record timesteps.tolist() and sigmas.tolist().",
|
||||
"constructor_shift": 1.0,
|
||||
"runtime_shift": 12.0,
|
||||
"steps": 40,
|
||||
"timesteps": [
|
||||
999,
|
||||
997,
|
||||
995,
|
||||
993,
|
||||
990,
|
||||
988,
|
||||
985,
|
||||
982,
|
||||
979,
|
||||
976,
|
||||
972,
|
||||
969,
|
||||
965,
|
||||
961,
|
||||
956,
|
||||
952,
|
||||
947,
|
||||
941,
|
||||
936,
|
||||
929,
|
||||
922,
|
||||
915,
|
||||
907,
|
||||
898,
|
||||
888,
|
||||
877,
|
||||
865,
|
||||
852,
|
||||
837,
|
||||
819,
|
||||
799,
|
||||
776,
|
||||
749,
|
||||
717,
|
||||
678,
|
||||
631,
|
||||
571,
|
||||
492,
|
||||
386,
|
||||
235
|
||||
],
|
||||
"sigmas": [
|
||||
0.9999166131019592,
|
||||
0.9977826476097107,
|
||||
0.9955461621284485,
|
||||
0.9931996464729309,
|
||||
0.9907346963882446,
|
||||
0.988142192363739,
|
||||
0.9854119420051575,
|
||||
0.9825326800346375,
|
||||
0.9794917702674866,
|
||||
0.9762753844261169,
|
||||
0.9728676676750183,
|
||||
0.9692512154579163,
|
||||
0.9654061794281006,
|
||||
0.961310088634491,
|
||||
0.9569376707077026,
|
||||
0.95225989818573,
|
||||
0.9472436308860779,
|
||||
0.9418508410453796,
|
||||
0.9360373020172119,
|
||||
0.9297519326210022,
|
||||
0.9229347705841064,
|
||||
0.9155154228210449,
|
||||
0.9074103832244873,
|
||||
0.8985199332237244,
|
||||
0.888724148273468,
|
||||
0.8778772950172424,
|
||||
0.865800678730011,
|
||||
0.8522725701332092,
|
||||
0.8370144367218018,
|
||||
0.8196719288825989,
|
||||
0.7997865080833435,
|
||||
0.7767546772956848,
|
||||
0.7497654557228088,
|
||||
0.717703104019165,
|
||||
0.6789888143539429,
|
||||
0.6313128471374512,
|
||||
0.5711563229560852,
|
||||
0.49288034439086914,
|
||||
0.38684695959091187,
|
||||
0.23510953783988953,
|
||||
0.0
|
||||
],
|
||||
"implementation": "Wan-Video/Wan2.2 wan/utils/fm_solvers_unipc.py"
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Regression tests for transformer quant-config auto-detection.
|
||||
|
||||
The loader path at pipeline_wan2_2.py carries two quantization contracts:
|
||||
@@ -27,6 +27,7 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
|
||||
import vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 as wan22_module
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import create_transformer_from_config
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
@@ -381,3 +382,213 @@ def test_pipeline_cascade_mxfp4_dualscale_each_transformer_gets_correct_ignored_
|
||||
|
||||
# od_config must remain unchanged — _create_transformer does not modify it.
|
||||
assert od_config.quantization_config is None
|
||||
|
||||
|
||||
def _construct_quantized_pipeline(pipeline_kind, active, disk_quant_config, tmp_path, monkeypatch):
|
||||
import json
|
||||
|
||||
import torch
|
||||
|
||||
import vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_i2v as i2v_module
|
||||
import vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_vace as vace_module
|
||||
|
||||
monkeypatch.setattr("vllm_omni.platforms.current_omni_platform.is_npu", lambda: True)
|
||||
for component in ("transformer", "transformer_2"):
|
||||
directory = tmp_path / component
|
||||
directory.mkdir()
|
||||
config = dict(_MIN_CFG)
|
||||
expert_quant_config = (
|
||||
disk_quant_config[0 if component == "transformer" else 1]
|
||||
if isinstance(disk_quant_config, tuple)
|
||||
else disk_quant_config
|
||||
)
|
||||
if expert_quant_config is not None:
|
||||
config["quantization_config"] = expert_quant_config
|
||||
(directory / "config.json").write_text(json.dumps(config))
|
||||
(tmp_path / "model_index.json").write_text(json.dumps({"transformer_2": ["diffusers", "WanTransformer3DModel"]}))
|
||||
|
||||
class FakeComponent(torch.nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.config = SimpleNamespace(scale_factor_temporal=4, scale_factor_spatial=8)
|
||||
|
||||
class FakeTransformer(FakeComponent):
|
||||
def __init__(self, quant_config=None, **kwargs):
|
||||
super().__init__()
|
||||
self.received_quant_config = quant_config
|
||||
|
||||
for module in (wan22_module, i2v_module):
|
||||
monkeypatch.setattr(module, "get_local_device", lambda: torch.device("cpu"))
|
||||
monkeypatch.setattr(module, "prefetch_subfolders", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(module, "from_pretrained_with_prefetch", lambda *args, **kwargs: FakeComponent())
|
||||
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
|
||||
monkeypatch.setattr(vace_module, "WanVACETransformer3DModel", FakeTransformer)
|
||||
pipeline_cls = {
|
||||
"t2v": wan22_module.Wan22Pipeline,
|
||||
"i2v": i2v_module.Wan22I2VPipeline,
|
||||
"vace": vace_module.Wan22VACEPipeline,
|
||||
}[pipeline_kind]
|
||||
monkeypatch.setattr(pipeline_cls, "setup_diffusion_pipeline_profiler", lambda *args, **kwargs: None)
|
||||
od_config = OmniDiffusionConfig(
|
||||
model=str(tmp_path),
|
||||
model_class_name=pipeline_cls.__name__,
|
||||
dtype=torch.bfloat16,
|
||||
quantization_config=active,
|
||||
boundary_ratio=0.875,
|
||||
flow_shift=12.0,
|
||||
enable_diffusion_pipeline_profiler=False,
|
||||
)
|
||||
od_config.enrich_config()
|
||||
pipeline = pipeline_cls(od_config=od_config)
|
||||
return pipeline
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pipeline_kind", ["t2v", "i2v", "vace"])
|
||||
@pytest.mark.parametrize("method, flag", [("mxfp4", "is_checkpoint_mxfp4_serialized")])
|
||||
@pytest.mark.parametrize("offline", [False, True])
|
||||
def test_pipeline_constructor_routes_each_expert_quantization(
|
||||
pipeline_kind, method, flag, offline, tmp_path, monkeypatch, mocker
|
||||
):
|
||||
"""Run the actual pipeline constructors, expert factories and quant-method dispatch."""
|
||||
from vllm.model_executor.layers.linear import LinearBase, UnquantizedLinearMethod
|
||||
|
||||
from vllm_omni.quantization import build_quant_config
|
||||
|
||||
active = build_quant_config(
|
||||
{
|
||||
"transformer": {
|
||||
"method": method,
|
||||
"w4a8_fallback_layers": ["blocks.10.attn1.to_qkv"],
|
||||
"w4a8_fallback_steps": [0, 1],
|
||||
},
|
||||
"transformer_2": {
|
||||
"method": method,
|
||||
"w4a8_fallback_layers": ["blocks.11.attn1.to_qkv"],
|
||||
"w4a8_fallback_steps": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
disk_quant_config = None
|
||||
if offline:
|
||||
disk_quant_config = {
|
||||
"quant_method": method,
|
||||
flag: True,
|
||||
"ignored_layers": ["blocks.12.ffn.net_2"],
|
||||
"w4a8_fallback_layers": ["blocks.9.attn1.to_qkv"],
|
||||
"w4a8_fallback_steps": [9],
|
||||
}
|
||||
pipeline = _construct_quantized_pipeline(pipeline_kind, active, disk_quant_config, tmp_path, monkeypatch)
|
||||
for index, transformer in enumerate((pipeline.transformer, pipeline.transformer_2)):
|
||||
received = transformer.received_quant_config
|
||||
assert received.get_name() == method
|
||||
assert getattr(received, flag) == offline
|
||||
assert received.w4a8_fallback_steps == ([0, 1] if index == 0 else [])
|
||||
selected = f"blocks.{10 + index}.attn1.to_qkv"
|
||||
other = f"blocks.{11 - index}.attn1.to_qkv"
|
||||
assert received.w4a8_fallback_layers == [selected]
|
||||
assert received.get_quant_method(mocker.Mock(spec=LinearBase), selected).is_w4a8_fallback_layer
|
||||
assert not received.get_quant_method(mocker.Mock(spec=LinearBase), other).is_w4a8_fallback_layer
|
||||
if offline:
|
||||
assert isinstance(
|
||||
received.get_quant_method(mocker.Mock(spec=LinearBase), "blocks.12.ffn.net_2"), UnquantizedLinearMethod
|
||||
)
|
||||
assert pipeline.od_config.quantization_config is active
|
||||
|
||||
|
||||
@pytest.mark.parametrize("explicit_none", [False, True])
|
||||
def test_pipeline_disabled_component_requires_unquantized_checkpoint(explicit_none, monkeypatch):
|
||||
from vllm_omni.quantization import build_quant_config
|
||||
|
||||
FakeTransformer, captured = _make_fake_transformer()
|
||||
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
|
||||
components = {"transformer": {"method": "mxfp4"}, "transformer_2": None} if explicit_none else {"vae": None}
|
||||
active = build_quant_config(components)
|
||||
pipeline = _FakePipeline(OmniDiffusionConfig(model="", quantization_config=active))
|
||||
pipeline._create_transformer(_MIN_CFG, component="transformer_2")
|
||||
assert captured[-1].get("quant_config") is None
|
||||
with pytest.raises(ValueError, match="disabled for component 'transformer_2'"):
|
||||
pipeline._create_transformer(
|
||||
{**_MIN_CFG, "quantization_config": {"quant_method": "mxfp4", "is_checkpoint_mxfp4_serialized": True}},
|
||||
component="transformer_2",
|
||||
)
|
||||
|
||||
|
||||
def test_pipeline_component_default_reaches_expert_factory(monkeypatch):
|
||||
from vllm_omni.quantization import build_quant_config
|
||||
|
||||
FakeTransformer, captured = _make_fake_transformer()
|
||||
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
|
||||
active = build_quant_config({"default": {"method": "mxfp4", "w4a8_fallback_layers": ["blocks.10.attn1.to_qkv"]}})
|
||||
pipeline = _FakePipeline(OmniDiffusionConfig(model="", quantization_config=active))
|
||||
pipeline._create_transformer(_MIN_CFG, component="transformer_2")
|
||||
assert captured[-1]["quant_config"] is active.default_config
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pipeline_kind", ["t2v", "i2v", "vace"])
|
||||
@pytest.mark.parametrize(
|
||||
"configured_expert, with_default",
|
||||
[
|
||||
("transformer", False),
|
||||
("transformer", True),
|
||||
("transformer_2", False),
|
||||
("transformer_2", True),
|
||||
],
|
||||
)
|
||||
def test_pipeline_partial_expert_config_does_not_match_sibling(
|
||||
pipeline_kind, configured_expert, with_default, tmp_path, monkeypatch
|
||||
):
|
||||
from vllm_omni.quantization import build_quant_config
|
||||
|
||||
component_spec = {
|
||||
configured_expert: {"method": "mxfp4", "w4a8_fallback_layers": ["blocks.10.attn1.to_qkv"]},
|
||||
}
|
||||
if with_default:
|
||||
component_spec["default"] = {"method": "mxfp4", "w4a8_fallback_layers": ["blocks.11.attn1.to_qkv"]}
|
||||
active = build_quant_config(component_spec)
|
||||
pipeline = _construct_quantized_pipeline(pipeline_kind, active, None, tmp_path, monkeypatch)
|
||||
sibling = "transformer_2" if configured_expert == "transformer" else "transformer"
|
||||
selected = getattr(pipeline, configured_expert).received_quant_config
|
||||
other = getattr(pipeline, sibling).received_quant_config
|
||||
assert selected is active.component_configs[configured_expert]
|
||||
assert other is active.default_config
|
||||
if with_default:
|
||||
assert other.w4a8_fallback_layers == ["blocks.11.attn1.to_qkv"]
|
||||
else:
|
||||
assert other is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pipeline_kind", ["t2v", "i2v", "vace"])
|
||||
@pytest.mark.parametrize("different_ignored_layers", [False, True])
|
||||
def test_enriched_auto_quantization_preserves_each_expert_policy(
|
||||
pipeline_kind, different_ignored_layers, tmp_path, monkeypatch
|
||||
):
|
||||
"""Startup auto-detection must not turn the first expert into a user override."""
|
||||
high = {
|
||||
"quant_method": "mxfp4",
|
||||
"is_checkpoint_mxfp4_serialized": True,
|
||||
"ignored_layers": ["blocks.0.ffn.net_2"],
|
||||
"w4a8_fallback_steps": [],
|
||||
"w4a8_fallback_layers": [],
|
||||
"mxfp4_scale_alg": 0,
|
||||
"require_smooth_scale": False,
|
||||
}
|
||||
low = {
|
||||
**high,
|
||||
"ignored_layers": ["blocks.1.ffn.net_2"] if different_ignored_layers else high["ignored_layers"],
|
||||
"w4a8_fallback_steps": [37],
|
||||
"w4a8_fallback_layers": ["blocks.11.attn1.to_qkv"],
|
||||
"mxfp4_scale_alg": 2,
|
||||
"require_smooth_scale": True,
|
||||
}
|
||||
pipeline = _construct_quantized_pipeline(pipeline_kind, None, (high, low), tmp_path, monkeypatch)
|
||||
assert pipeline.od_config.quantization_config is not None # enrich_config really detected the first expert.
|
||||
for transformer, expected in [(pipeline.transformer, high), (pipeline.transformer_2, low)]:
|
||||
config = transformer.received_quant_config
|
||||
for policy in (
|
||||
"ignored_layers",
|
||||
"w4a8_fallback_steps",
|
||||
"w4a8_fallback_layers",
|
||||
"mxfp4_scale_alg",
|
||||
"require_smooth_scale",
|
||||
):
|
||||
assert getattr(config, policy) == expected[policy], policy
|
||||
@@ -0,0 +1,165 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import io
|
||||
from types import SimpleNamespace
|
||||
|
||||
import av
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.wan2_2.chunked_mp4 import (
|
||||
WAN_DEFAULT_OUTPUT_FPS,
|
||||
resolve_wan_output_fps,
|
||||
resolve_wan_preencode_batch_frames,
|
||||
resolve_wan_preencode_mp4,
|
||||
resolve_wan_video_codec_options,
|
||||
wan_preencoded_mp4_payload,
|
||||
)
|
||||
from vllm_omni.diffusion.utils.chunked_video import decode_to_mp4
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
class _FakeWanVAE:
|
||||
"""Publish temporal chunks the way the Wan VAE seam does."""
|
||||
|
||||
chunk_value_range = (-1.0, 1.0)
|
||||
|
||||
def __init__(self, *, batch: int, chunks: int, frames_per_chunk: int, height: int, width: int):
|
||||
self.batch = batch
|
||||
self.chunks = chunks
|
||||
self.frames_per_chunk = frames_per_chunk
|
||||
self.height = height
|
||||
self.width = width
|
||||
self.chunk_calls = 0
|
||||
|
||||
def decode_with_chunks(self, latents, *, on_chunk):
|
||||
del latents
|
||||
assert on_chunk is not None
|
||||
for index in range(self.chunks):
|
||||
self.chunk_calls += 1
|
||||
value = -1.0 + 2.0 * index / max(self.chunks - 1, 1)
|
||||
on_chunk(torch.full((self.batch, 3, self.frames_per_chunk, self.height, self.width), value))
|
||||
|
||||
|
||||
def _decoded_frame_count(data: bytes) -> int:
|
||||
with av.open(io.BytesIO(data)) as container:
|
||||
return sum(1 for _ in container.decode(video=0))
|
||||
|
||||
|
||||
def test_decode_wan_latents_to_mp4_emits_one_playable_mp4_per_batch_entry():
|
||||
vae = _FakeWanVAE(batch=2, chunks=6, frames_per_chunk=2, height=16, width=32)
|
||||
|
||||
videos = decode_to_mp4(vae, torch.zeros(1), fps=24, batch_frames=4)
|
||||
|
||||
assert len(videos) == 2
|
||||
for data in videos:
|
||||
assert data[:4] and _decoded_frame_count(data) == 12
|
||||
|
||||
|
||||
def test_decode_wan_latents_to_mp4_coalesces_transfers_without_losing_frames():
|
||||
"""batch_frames only changes how many transfers carry the same frames."""
|
||||
unbatched = _FakeWanVAE(batch=1, chunks=8, frames_per_chunk=1, height=16, width=32)
|
||||
batched = _FakeWanVAE(batch=1, chunks=8, frames_per_chunk=1, height=16, width=32)
|
||||
|
||||
per_chunk = decode_to_mp4(unbatched, torch.zeros(1), fps=24, batch_frames=1)
|
||||
coalesced = decode_to_mp4(batched, torch.zeros(1), fps=24, batch_frames=4)
|
||||
|
||||
assert _decoded_frame_count(per_chunk[0]) == _decoded_frame_count(coalesced[0]) == 8
|
||||
|
||||
|
||||
def test_decode_wan_latents_to_mp4_returns_nothing_for_a_rank_without_output():
|
||||
class SilentVAE:
|
||||
chunk_value_range = (-1.0, 1.0)
|
||||
|
||||
def decode_with_chunks(self, latents, *, on_chunk):
|
||||
del latents, on_chunk
|
||||
|
||||
assert decode_to_mp4(SilentVAE(), torch.zeros(1), fps=24) == []
|
||||
|
||||
|
||||
def test_decode_wan_latents_to_mp4_rejects_a_non_positive_batch():
|
||||
with pytest.raises(ValueError, match="batch_frames"):
|
||||
decode_to_mp4(
|
||||
_FakeWanVAE(batch=1, chunks=1, frames_per_chunk=1, height=16, width=16), None, fps=24, batch_frames=0
|
||||
)
|
||||
|
||||
|
||||
def test_decode_wan_latents_to_mp4_rejects_a_vae_without_the_capability():
|
||||
class PlainVAE:
|
||||
chunk_value_range = (-1.0, 1.0)
|
||||
|
||||
def decode(self, z, return_dict=True):
|
||||
del z, return_dict
|
||||
|
||||
with pytest.raises(TypeError, match="chunked VAE decode capability"):
|
||||
decode_to_mp4(PlainVAE(), torch.zeros(1), fps=24)
|
||||
|
||||
|
||||
def test_preencode_flag_is_off_unless_the_request_asks_for_it():
|
||||
assert resolve_wan_preencode_mp4(SimpleNamespace(extra_args=None), output_type="np") is False
|
||||
assert resolve_wan_preencode_mp4(SimpleNamespace(extra_args={}), output_type="np") is False
|
||||
assert resolve_wan_preencode_mp4(SimpleNamespace(extra_args={"preencode_mp4": True}), output_type="np") is True
|
||||
|
||||
|
||||
def test_preencode_rejects_combinations_it_cannot_serve():
|
||||
interpolating = SimpleNamespace(extra_args={"preencode_mp4": True}, enable_frame_interpolation=True)
|
||||
with pytest.raises(ValueError, match="enable_frame_interpolation"):
|
||||
resolve_wan_preencode_mp4(interpolating, output_type="np")
|
||||
|
||||
with pytest.raises(ValueError, match="output_type"):
|
||||
resolve_wan_preencode_mp4(SimpleNamespace(extra_args={"preencode_mp4": True}), output_type="pil")
|
||||
|
||||
|
||||
def test_output_fps_mirrors_the_serving_fallback():
|
||||
assert resolve_wan_output_fps(SimpleNamespace(fps=16)) == 16
|
||||
assert resolve_wan_output_fps(SimpleNamespace(fps=[16])) == 16
|
||||
assert resolve_wan_output_fps(SimpleNamespace(fps=None)) == WAN_DEFAULT_OUTPUT_FPS
|
||||
assert resolve_wan_output_fps(SimpleNamespace()) == WAN_DEFAULT_OUTPUT_FPS
|
||||
|
||||
|
||||
def test_codec_options_reach_the_encoder_as_strings():
|
||||
params = SimpleNamespace(extra_args={"video_codec_options": {"preset": "ultrafast", "threads": 0}})
|
||||
assert resolve_wan_video_codec_options(params) == {"preset": "ultrafast", "threads": "0"}
|
||||
assert resolve_wan_video_codec_options(SimpleNamespace(extra_args={})) == {"preset": "ultrafast", "threads": "0"}
|
||||
assert resolve_wan_video_codec_options(SimpleNamespace(extra_args={"video_codec_options": None})) is None
|
||||
assert resolve_wan_video_codec_options(SimpleNamespace(extra_args={"video_codec_options": {}})) == {}
|
||||
|
||||
|
||||
def test_preencoded_payload_passes_bytes_through_and_ignores_tensors():
|
||||
assert wan_preencoded_mp4_payload(torch.zeros(1, 3, 2, 4, 4)) is None
|
||||
assert wan_preencoded_mp4_payload([b"one", b"two"]) == {"payload": {"video": [b"one", b"two"]}, "metadata": {}}
|
||||
assert wan_preencoded_mp4_payload(b"one") == {"payload": {"video": [b"one"]}, "metadata": {}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [1, 5, 33])
|
||||
def test_request_batch_threshold_controls_transfers_without_losing_frames(monkeypatch, value):
|
||||
from vllm_omni.diffusion.utils import chunked_video
|
||||
|
||||
transfers = []
|
||||
quantize = chunked_video.quantize_chunk
|
||||
|
||||
def capture(chunk, value_range):
|
||||
transfers.append(chunk.shape[2])
|
||||
return quantize(chunk, value_range)
|
||||
|
||||
monkeypatch.setattr(chunked_video, "quantize_chunk", capture)
|
||||
params = SimpleNamespace(extra_args={"preencode_mp4": True, "preencode_batch_frames": value})
|
||||
vae = _FakeWanVAE(batch=1, chunks=4, frames_per_chunk=3, height=16, width=32)
|
||||
videos = decode_to_mp4(vae, torch.zeros(1), fps=24, batch_frames=resolve_wan_preencode_batch_frames(params))
|
||||
|
||||
assert transfers == {1: [3, 3, 3, 3], 5: [6, 6], 33: [12]}[value]
|
||||
assert _decoded_frame_count(videos[0]) == 12
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [0, -1, True, 1.5, "17", None])
|
||||
def test_request_batch_threshold_rejects_invalid_values(value):
|
||||
with pytest.raises(ValueError, match="preencode_batch_frames"):
|
||||
resolve_wan_preencode_batch_frames(SimpleNamespace(extra_args={"preencode_batch_frames": value}))
|
||||
|
||||
|
||||
def test_request_batch_threshold_preserves_model_defaults():
|
||||
params = SimpleNamespace(extra_args=None)
|
||||
assert resolve_wan_preencode_batch_frames(params) == 17
|
||||
assert resolve_wan_preencode_batch_frames(params, default=1) == 1
|
||||
@@ -1,15 +1,16 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
from torch import nn
|
||||
|
||||
from tests.diffusion.models.wan2_2.conftest import StubScheduler, StubTransformer, StubVAE, noop_progress_bar
|
||||
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import _WAN_TEXT_ENCODER_OFFLOAD_PLAN
|
||||
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import _WAN_TEXT_ENCODER_OFFLOAD_PLAN, build_wan_scheduler
|
||||
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_i2v import (
|
||||
Wan22I2VPipeline,
|
||||
get_wan22_i2v_post_process_func,
|
||||
@@ -50,7 +51,7 @@ def _make_i2v_pipeline(*, expand_timesteps: bool) -> Wan22I2VPipeline:
|
||||
|
||||
|
||||
def _make_i2v_sampling(**overrides):
|
||||
values = {
|
||||
values: dict[str, object] = {
|
||||
"height": 16,
|
||||
"width": 16,
|
||||
"num_frames": 5,
|
||||
@@ -275,16 +276,18 @@ def test_i2v_prepare_latents_preserves_batched_image_conditions() -> None:
|
||||
assert first_frame_mask.shape == (2, 1, 2, 2, 2)
|
||||
|
||||
|
||||
def test_i2v_forward_batches_conditions_random_inputs_and_outputs(monkeypatch) -> None:
|
||||
@pytest.mark.parametrize("solver", ["unipc", "euler"])
|
||||
@pytest.mark.parametrize("shift", [3.0, 5.0, 12.0])
|
||||
def test_i2v_forward_batches_conditions_random_inputs_and_outputs(monkeypatch, solver: str, shift: float) -> None:
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_i2v.current_omni_platform",
|
||||
SimpleNamespace(is_available=lambda: False),
|
||||
)
|
||||
pipeline = _make_i2v_pipeline(expand_timesteps=True)
|
||||
pipeline.scheduler = StubScheduler([9])
|
||||
pipeline.od_config = SimpleNamespace(flow_shift=5.0)
|
||||
pipeline._sample_solver = "unipc"
|
||||
pipeline._flow_shift = 5.0
|
||||
pipeline.scheduler = build_wan_scheduler(solver, shift)
|
||||
pipeline.od_config = SimpleNamespace(flow_shift=shift)
|
||||
pipeline._sample_solver = solver
|
||||
pipeline._flow_shift = shift
|
||||
pipeline.boundary_ratio = 0.875
|
||||
pipeline.has_image_encoder = False
|
||||
pipeline._guidance_scale = None
|
||||
@@ -327,6 +330,8 @@ def test_i2v_forward_batches_conditions_random_inputs_and_outputs(monkeypatch) -
|
||||
generator=gen_a,
|
||||
latents=latents_a,
|
||||
num_outputs_per_prompt=2,
|
||||
num_inference_steps=50,
|
||||
extra_args={"sample_solver": solver, "flow_shift": shift},
|
||||
),
|
||||
),
|
||||
SimpleNamespace(
|
||||
@@ -336,12 +341,25 @@ def test_i2v_forward_batches_conditions_random_inputs_and_outputs(monkeypatch) -
|
||||
generator=gen_b,
|
||||
latents=latents_b,
|
||||
num_outputs_per_prompt=2,
|
||||
num_inference_steps=50,
|
||||
extra_args={"sample_solver": solver, "flow_shift": shift},
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
outputs = pipeline.forward(batch)
|
||||
if solver == "unipc":
|
||||
sigmas = np.linspace(float(np.float32(0.999)), 0.0, 51)[:-1]
|
||||
sigmas = shift * sigmas / (1.0 + (shift - 1.0) * sigmas)
|
||||
torch.testing.assert_close(
|
||||
pipeline.scheduler.sigmas, torch.tensor(np.append(sigmas, 0.0), dtype=torch.float32), rtol=0, atol=0
|
||||
)
|
||||
assert pipeline.scheduler.timesteps.tolist() == (sigmas * 1000).astype(np.int64).tolist()
|
||||
else:
|
||||
reference = build_wan_scheduler("euler", shift)
|
||||
reference.set_timesteps(50, device="cpu")
|
||||
torch.testing.assert_close(pipeline.scheduler.sigmas, reference.sigmas, rtol=0, atol=0)
|
||||
|
||||
assert prepare_call["batch_size"] == 4
|
||||
assert prepare_call["generator"] == [gen_a, gen_a, gen_b, gen_b]
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Wan integration contracts required by quantized dense/sparse attention."""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.wan2_2.wan2_2_transformer import WanTransformerBlock
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_block_publishes_unpadded_grid_without_partial_packed_metadata():
|
||||
block = object.__new__(WanTransformerBlock)
|
||||
torch.nn.Module.__init__(block)
|
||||
block.scale_shift_table = torch.zeros(1, 6, 4)
|
||||
block.norm1 = lambda x, *a: x
|
||||
block.norm2 = lambda x: x
|
||||
block.norm3 = lambda x, *a: x
|
||||
block.attn1 = Mock(side_effect=lambda x, *a: torch.zeros_like(x))
|
||||
block.attn2 = Mock(side_effect=lambda x, *a: torch.zeros_like(x))
|
||||
block.ffn = lambda x: torch.zeros_like(x)
|
||||
# Local SP tokens include alignment padding; grid describes the full valid video.
|
||||
x = torch.zeros(1, 1152, 4)
|
||||
block(
|
||||
x,
|
||||
x,
|
||||
torch.zeros(1, 6, 4),
|
||||
None,
|
||||
hidden_states_mask=torch.ones(1, 1152, dtype=torch.bool),
|
||||
vsa_dit_seq_shape=(5, 16, 27),
|
||||
)
|
||||
metadata = block.attn1.call_args.args[2]
|
||||
assert metadata.video_layout.latent_grid == (5, 16, 27)
|
||||
assert metadata.video_layout.used_len == 2160
|
||||
assert metadata.video_layout.prefix_len == 0
|
||||
assert "max_seqlen_q" not in metadata.extra # CUDA must not infer incomplete packed varlen.
|
||||
assert block.attn2.call_args.args[2] is None
|
||||
@@ -9,7 +9,8 @@ import pytest
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import Wan22Pipeline
|
||||
from vllm_omni.diffusion.media import VideoTensorEncoding, VideoTensorLayout, VideoValueRange
|
||||
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import Wan22Pipeline, build_wan_scheduler
|
||||
from vllm_omni.diffusion.models.wan2_2.wan2_2_transformer import WanSelfAttention
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
||||
@@ -18,6 +19,14 @@ from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _cpu_pipeline_runtime(monkeypatch):
|
||||
# These lightweight pipelines have no accelerator or distributed groups.
|
||||
module = importlib.import_module("vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2")
|
||||
monkeypatch.setattr(module.current_omni_platform, "is_available", lambda: False)
|
||||
monkeypatch.setattr("vllm_omni.diffusion.distributed.parallel_state._PP", SimpleNamespace(world_size=1))
|
||||
|
||||
|
||||
class _StubTransformer(nn.Module):
|
||||
@property
|
||||
def dtype(self) -> torch.dtype:
|
||||
@@ -30,14 +39,30 @@ class _StubTextEncoder(nn.Module):
|
||||
return torch.float32
|
||||
|
||||
|
||||
class _StubVaeConfig:
|
||||
latents_mean = [0.0, 0.0, 0.0, 0.0]
|
||||
latents_std = [1.0, 1.0, 1.0, 1.0]
|
||||
z_dim = 4
|
||||
|
||||
|
||||
class _StubVae(nn.Module):
|
||||
dtype = torch.float32
|
||||
config = _StubVaeConfig()
|
||||
|
||||
def decode(self, latents, return_dict=False):
|
||||
del return_dict
|
||||
batch, _, frames, height, width = latents.shape
|
||||
return (torch.zeros(batch, 3, frames, height, width),)
|
||||
|
||||
|
||||
class _StubScheduler:
|
||||
def __init__(self, timesteps: list[int]) -> None:
|
||||
self.timesteps = torch.tensor(timesteps, dtype=torch.int64)
|
||||
self.config = SimpleNamespace(num_train_timesteps=1000)
|
||||
self.set_timesteps_calls: list[tuple[int, torch.device]] = []
|
||||
self.set_timesteps_calls: list[tuple[int, torch.device, float | None]] = []
|
||||
|
||||
def set_timesteps(self, num_steps: int, device: torch.device) -> None:
|
||||
self.set_timesteps_calls.append((num_steps, device))
|
||||
def set_timesteps(self, num_steps: int, device: torch.device, shift: float | None = None) -> None:
|
||||
self.set_timesteps_calls.append((num_steps, device, shift))
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -75,6 +100,7 @@ def _make_pipeline() -> Wan22Pipeline:
|
||||
pipeline.transformer = _StubTransformer()
|
||||
pipeline.transformer_2 = None
|
||||
pipeline.text_encoder = _StubTextEncoder()
|
||||
pipeline.vae = _StubVae()
|
||||
pipeline.transformer_config = SimpleNamespace(patch_size=(1, 2, 2), in_channels=4, out_channels=4)
|
||||
pipeline.scheduler = _StubScheduler([9, 5])
|
||||
pipeline.od_config = SimpleNamespace(flow_shift=5.0)
|
||||
@@ -166,7 +192,24 @@ def test_forward_delegates_denoising_to_diffuse(
|
||||
assert captured["boundary_timestep"] == pytest.approx(875.0)
|
||||
assert captured["latent_condition"] is None
|
||||
assert captured["first_frame_mask"] is None
|
||||
assert pipeline.scheduler.set_timesteps_calls == [(2, torch.device("cpu"))]
|
||||
assert pipeline.scheduler.set_timesteps_calls == [(2, torch.device("cpu"), 5.0)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("solver", ["unipc", "euler"])
|
||||
def test_forward_passes_request_shift_without_mutating_scheduler_config(solver: str) -> None:
|
||||
pipeline = _make_pipeline()
|
||||
pipeline.diffuse = lambda **kwargs: kwargs["latents"]
|
||||
for shift in (3.0, 12.0, 5.0):
|
||||
sampling = _make_sampling(num_inference_steps=5, extra_args={"sample_solver": solver, "flow_shift": shift})
|
||||
request = OmniDiffusionRequest(prompt="prompt", request_id="schedule", sampling_params=sampling)
|
||||
pipeline.forward(DiffusionRequestBatch(requests=[request]))
|
||||
reference = build_wan_scheduler(solver, shift)
|
||||
if solver == "unipc":
|
||||
reference.set_timesteps(5, device="cpu", shift=shift)
|
||||
assert pipeline.scheduler.config.shift == pipeline.scheduler.config["shift"] == 1.0
|
||||
else:
|
||||
reference.set_timesteps(5, device="cpu")
|
||||
torch.testing.assert_close(pipeline.scheduler.sigmas, reference.sigmas, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_forward_batches_text_generators_latents_and_splits_outputs() -> None:
|
||||
@@ -227,6 +270,99 @@ def test_forward_batches_text_generators_latents_and_splits_outputs() -> None:
|
||||
torch.testing.assert_close(outputs[1].output, latents_b)
|
||||
|
||||
|
||||
def test_forward_emits_request_local_typed_media_after_vae_decode() -> None:
|
||||
pipeline = _make_pipeline()
|
||||
|
||||
def _fake_diffuse(
|
||||
*,
|
||||
latents,
|
||||
timesteps,
|
||||
prompt_embeds,
|
||||
negative_prompt_embeds,
|
||||
guidance_low,
|
||||
guidance_high,
|
||||
boundary_timestep,
|
||||
dtype,
|
||||
attention_kwargs,
|
||||
latent_condition,
|
||||
first_frame_mask,
|
||||
generator,
|
||||
):
|
||||
del (
|
||||
timesteps,
|
||||
prompt_embeds,
|
||||
negative_prompt_embeds,
|
||||
guidance_low,
|
||||
guidance_high,
|
||||
boundary_timestep,
|
||||
dtype,
|
||||
attention_kwargs,
|
||||
latent_condition,
|
||||
first_frame_mask,
|
||||
generator,
|
||||
)
|
||||
return torch.zeros_like(latents)
|
||||
|
||||
pipeline.diffuse = _fake_diffuse # type: ignore[method-assign]
|
||||
batch = DiffusionRequestBatch(
|
||||
requests=[
|
||||
OmniDiffusionRequest(
|
||||
prompt="prompt",
|
||||
request_id="request-0",
|
||||
sampling_params=OmniDiffusionSamplingParams(
|
||||
num_frames=1,
|
||||
num_inference_steps=2,
|
||||
max_sequence_length=32,
|
||||
output_type="np",
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
outputs = pipeline.forward(batch)
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert outputs[0].output is None
|
||||
assert outputs[0].media is not None
|
||||
assert outputs[0].media.prepared_for_transport is False
|
||||
assert outputs[0].media.video.tensor.shape == (1, 3, 1, 8, 8)
|
||||
assert outputs[0].media.video.spec.layout is VideoTensorLayout.BCTHW
|
||||
assert outputs[0].media.video.spec.encoding is VideoTensorEncoding.NORMALIZED_FLOAT
|
||||
assert outputs[0].media.video.spec.value_range is VideoValueRange.NEGATIVE_ONE_TO_ONE
|
||||
|
||||
|
||||
def test_forward_keeps_legacy_output_on_non_owner_vae_rank() -> None:
|
||||
# Distributed VAE decode uses broadcast_result=False, so non-owner ranks get
|
||||
# an empty placeholder instead of the full video. Wrapping that as typed media
|
||||
# would fail split_diffusion_output_by_request's batch check on every non-owner
|
||||
# rank, so the pipeline must keep the placeholder on the legacy output field.
|
||||
pipeline = _make_pipeline()
|
||||
pipeline.vae.decode = lambda latents, return_dict=False: (torch.empty(0),) # type: ignore[assignment]
|
||||
pipeline.diffuse = lambda **kwargs: torch.zeros_like(kwargs["latents"]) # type: ignore[method-assign]
|
||||
|
||||
batch = DiffusionRequestBatch(
|
||||
requests=[
|
||||
OmniDiffusionRequest(
|
||||
prompt="prompt",
|
||||
request_id="request-0",
|
||||
sampling_params=OmniDiffusionSamplingParams(
|
||||
num_frames=1,
|
||||
num_inference_steps=2,
|
||||
max_sequence_length=32,
|
||||
output_type="np",
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
outputs = pipeline.forward(batch)
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert outputs[0].media is None
|
||||
assert outputs[0].output is not None
|
||||
assert outputs[0].output.numel() == 0
|
||||
|
||||
|
||||
def test_forward_batches_precomputed_prompt_embeddings() -> None:
|
||||
pipeline = _make_pipeline()
|
||||
diffuse_call = {}
|
||||
@@ -343,6 +479,100 @@ def test_diffuse_runs_prediction_and_scheduler_for_each_timestep() -> None:
|
||||
assert torch.equal(result, torch.full_like(latents, 10.0))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fail_first_request", [False, True])
|
||||
def test_runner_publishes_wan_step_context_across_requests(fail_first_request, monkeypatch):
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.forward_context import (
|
||||
ForwardContext,
|
||||
get_forward_context,
|
||||
is_forward_context_available,
|
||||
override_forward_context,
|
||||
)
|
||||
from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
|
||||
from vllm_omni.quantization.mxfp4_config import _is_w4a8_fallback_step
|
||||
|
||||
contexts: list[ForwardContext] = []
|
||||
events = []
|
||||
|
||||
class InjectedPredictionError(Exception):
|
||||
pass
|
||||
|
||||
class RecordingTransformer(_StubTransformer):
|
||||
def __init__(self, expert):
|
||||
super().__init__()
|
||||
self.expert = expert
|
||||
|
||||
def forward(self, hidden_states, encoder_hidden_states, **kwargs):
|
||||
context = get_forward_context()
|
||||
assert context is contexts[-1]
|
||||
request_index = len(contexts) - 1
|
||||
step = context.denoise_step_idx
|
||||
positive = bool(encoder_hidden_states[0, 0, 0] > 0)
|
||||
events.append((request_index, step, self.expert, positive, _is_w4a8_fallback_step([0, 2])))
|
||||
if fail_first_request and request_index == 0 and step == 1 and not positive:
|
||||
raise InjectedPredictionError
|
||||
return (torch.ones_like(hidden_states) * (1 if positive else -1),)
|
||||
|
||||
def encode_prompt(**kwargs):
|
||||
context = get_forward_context()
|
||||
assert context.denoise_step_idx is None
|
||||
contexts.append(context)
|
||||
embeds = torch.ones(1, 8, 8)
|
||||
return embeds, -embeds
|
||||
|
||||
pipeline = _make_pipeline()
|
||||
pipeline.transformer = RecordingTransformer("high")
|
||||
pipeline.transformer_2 = RecordingTransformer("low")
|
||||
pipeline.scheduler = _StubScheduler([900, 500, 100])
|
||||
monkeypatch.setattr(pipeline, "encode_prompt", encode_prompt)
|
||||
monkeypatch.setattr(pipeline, "scheduler_step_maybe_with_cfg", lambda pred, t, latents, cfg: latents)
|
||||
|
||||
# Use the real runner context manager, Wan forward/diffuse, and CFG
|
||||
# dispatch. Only model computation, scheduler math and unrelated KV I/O
|
||||
# are replaced; no test code publishes a denoise step.
|
||||
runner = object.__new__(DiffusionModelRunner)
|
||||
runner.pipeline = pipeline
|
||||
runner.od_config = OmniDiffusionConfig(model="", dtype=torch.float32)
|
||||
runner.vllm_config = None
|
||||
runner.cache_backend = None
|
||||
monkeypatch.setattr(runner, "_prepare_request_for_forward", lambda *args, **kwargs: None)
|
||||
with override_forward_context(None):
|
||||
for request_index in range(2):
|
||||
request = OmniDiffusionRequest(
|
||||
prompt="a fox walks",
|
||||
request_id=f"request-{request_index}",
|
||||
sampling_params=OmniDiffusionSamplingParams(
|
||||
num_frames=1, num_inference_steps=3, guidance_scale=2.0, output_type="latent"
|
||||
),
|
||||
)
|
||||
|
||||
def execute():
|
||||
return runner._execute_request_list(
|
||||
[request],
|
||||
od_config=runner.od_config,
|
||||
allow_single_output=True,
|
||||
require_request_batch_support=False,
|
||||
record_name="test_wan_step_context",
|
||||
record_output_peak_memory=False,
|
||||
)
|
||||
|
||||
if fail_first_request and request_index == 0:
|
||||
with pytest.raises(InjectedPredictionError):
|
||||
execute()
|
||||
else:
|
||||
execute()
|
||||
assert not is_forward_context_available()
|
||||
|
||||
assert len(contexts) == 2 and contexts[0] is not contexts[1]
|
||||
expected = [
|
||||
(request, step, "high" if step == 0 else "low", positive, step in (0, 2))
|
||||
for request in range(2)
|
||||
for step in range(2 if fail_first_request and request == 0 else 3)
|
||||
for positive in (True, False)
|
||||
]
|
||||
assert events == expected
|
||||
|
||||
|
||||
class _StubDMDScheduler:
|
||||
def __init__(self) -> None:
|
||||
self.predict_clean_calls: list[tuple[float, float, float]] = []
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Unit tests for Wan2.2 quant_config propagation through transformer creation.
|
||||
|
||||
Tests cover:
|
||||
@@ -196,7 +196,7 @@ class TestPatchWanRmsNorm:
|
||||
"""Test that patch_wan_rms_norm doesn't raise on concurrent module registration."""
|
||||
|
||||
def test_patches_modules_with_wan_rms_norm(self):
|
||||
from vllm_omni.diffusion.layers.norm import RMSNormVAE
|
||||
from vllm_omni.diffusion.models.wan2_2.norm import RMSNormVAE
|
||||
from vllm_omni.diffusion.models.wan2_2.patch_diffusers import patch_wan_rms_norm
|
||||
|
||||
# Create a fake module that has WanRMS_norm
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
@@ -7,6 +11,7 @@ import pytest
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from vllm_omni.diffusion.models.schedulers import FlowUniPCMultistepScheduler
|
||||
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_s2v import (
|
||||
Wan22S2VPipeline,
|
||||
_make_clip_generators,
|
||||
@@ -14,11 +19,32 @@ from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_s2v import (
|
||||
from vllm_omni.diffusion.models.wan2_2.wan2_2_s2v_transformer import WanS2VTransformer3DModel
|
||||
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("configured_shift", [None, 12.0])
|
||||
def test_s2v_constructor_preserves_unshifted_endpoints(configured_shift: float | None) -> None:
|
||||
module = "vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_s2v"
|
||||
config = SimpleNamespace(model="unused", flow_shift=configured_shift, enable_diffusion_pipeline_profiler=False)
|
||||
|
||||
def init_components(pipeline, *args):
|
||||
pipeline.vae = SimpleNamespace(config=SimpleNamespace(scale_factor_temporal=4, scale_factor_spatial=8))
|
||||
|
||||
with (
|
||||
patch(f"{module}.get_local_device", return_value=torch.device("cpu")),
|
||||
patch(f"{module}._resolve_model_path", return_value="unused"),
|
||||
patch(f"{module}._is_diffusers_format", return_value=True),
|
||||
patch.object(Wan22S2VPipeline, "_init_diffusers_format", init_components),
|
||||
patch.object(Wan22S2VPipeline, "setup_diffusion_pipeline_profiler"),
|
||||
):
|
||||
pipeline = Wan22S2VPipeline(od_config=config)
|
||||
assert pipeline._flow_shift == (3.0 if configured_shift is None else configured_shift)
|
||||
assert pipeline.scheduler.config.shift == pipeline.scheduler.config["shift"] == 1.0
|
||||
assert pipeline.scheduler.sigma_max == float(np.float32(0.999))
|
||||
|
||||
|
||||
def _make_s2v_sampling(**overrides):
|
||||
values = {
|
||||
values: dict[str, Any] = {
|
||||
"height": 16,
|
||||
"width": 16,
|
||||
"num_frames": 8,
|
||||
@@ -211,7 +237,8 @@ def test_s2v_forward_rejects_different_raw_audio_shapes() -> None:
|
||||
pipeline.forward(batch)
|
||||
|
||||
|
||||
def test_s2v_forward_batches_request_local_inputs_and_splits_outputs() -> None:
|
||||
@pytest.mark.parametrize("shift", [3.0, 12.0])
|
||||
def test_s2v_forward_batches_request_local_inputs_and_splits_outputs(shift: float) -> None:
|
||||
pipeline = object.__new__(Wan22S2VPipeline)
|
||||
nn.Module.__init__(pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
@@ -227,7 +254,8 @@ def test_s2v_forward_batches_request_local_inputs_and_splits_outputs() -> None:
|
||||
enable_cpu_offload=False,
|
||||
parallel_config=SimpleNamespace(use_hsdp=False),
|
||||
)
|
||||
pipeline.scheduler = MagicMock(timesteps=torch.tensor([1.0]))
|
||||
pipeline._flow_shift = shift
|
||||
pipeline.scheduler = FlowUniPCMultistepScheduler(shift=1.0)
|
||||
pipeline.vae_scale_factor_spatial = 8
|
||||
pipeline.resolution_divisor = 16
|
||||
pipeline.motion_frames = 7
|
||||
@@ -262,6 +290,7 @@ def test_s2v_forward_batches_request_local_inputs_and_splits_outputs() -> None:
|
||||
"multi_modal_data": {"image": image, "audio": audio_a},
|
||||
},
|
||||
sampling_params=_make_s2v_sampling(
|
||||
num_inference_steps=5,
|
||||
num_outputs_per_prompt=2,
|
||||
generator=generators_a,
|
||||
latents=latents_a,
|
||||
@@ -275,6 +304,7 @@ def test_s2v_forward_batches_request_local_inputs_and_splits_outputs() -> None:
|
||||
"multi_modal_data": {"image": image, "audio": audio_b},
|
||||
},
|
||||
sampling_params=_make_s2v_sampling(
|
||||
num_inference_steps=5,
|
||||
num_outputs_per_prompt=2,
|
||||
generator=generators_b,
|
||||
latents=latents_b,
|
||||
@@ -287,6 +317,12 @@ def test_s2v_forward_batches_request_local_inputs_and_splits_outputs() -> None:
|
||||
platform.is_available.return_value = False
|
||||
outputs = pipeline.forward(batch)
|
||||
|
||||
expected = np.linspace(float(np.float32(0.999)), 0.0, 6)[:-1]
|
||||
expected = shift * expected / (1.0 + (shift - 1.0) * expected)
|
||||
torch.testing.assert_close(
|
||||
pipeline.scheduler.sigmas, torch.tensor(np.append(expected, 0.0), dtype=torch.float32), rtol=0, atol=0
|
||||
)
|
||||
assert pipeline.scheduler.config.shift == pipeline.scheduler.config["shift"] == 1.0
|
||||
assert len(outputs) == 2
|
||||
assert outputs[0].output[0].shape[0] == 2
|
||||
assert outputs[1].output[0].shape[0] == 2
|
||||
@@ -519,6 +555,7 @@ def test_s2v_pipeline_hsdp_forward_complete_process():
|
||||
mock_scheduler.timesteps = torch.linspace(999, 0, 5)
|
||||
mock_scheduler.step = MagicMock(return_value=(torch.zeros(1, 16, 20, 88, 128),))
|
||||
pipeline.scheduler = mock_scheduler
|
||||
pipeline._flow_shift = 3.0
|
||||
|
||||
# -- Bind methods from the real class --
|
||||
pipeline.encode_prompt = Wan22S2VPipeline.encode_prompt.__get__(pipeline)
|
||||
@@ -611,3 +648,245 @@ def test_s2v_pipeline_hsdp_forward_complete_process():
|
||||
assert video.shape[1] == 3 # channels
|
||||
assert audio_waveform is not None
|
||||
assert audio_sr == 16000
|
||||
|
||||
|
||||
def _make_s2v_preencode_pipeline() -> Wan22S2VPipeline:
|
||||
"""Build the same stub pipeline the batching test drives, for preencode runs."""
|
||||
pipeline = object.__new__(Wan22S2VPipeline)
|
||||
nn.Module.__init__(pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline.transformer = MagicMock()
|
||||
pipeline.transformer.dtype = torch.float32
|
||||
# A fresh iterator per call: the clip loop reads this once per clip.
|
||||
pipeline.transformer.parameters.side_effect = lambda: iter([torch.zeros(1, dtype=torch.float32)])
|
||||
pipeline.transformer.casual_audio_encoder = None
|
||||
pipeline.transformer.encode_audio.side_effect = lambda audio, _motion: {"audio_emb": audio}
|
||||
pipeline.vae = MagicMock()
|
||||
pipeline.vae.dtype = torch.float32
|
||||
# The shared consumer reads the published pixel range off the VAE.
|
||||
pipeline.vae.chunk_value_range = (-1.0, 1.0)
|
||||
# One decoded clip: [B, C, T, H, W] for a batch of four (2 requests x 2 outputs).
|
||||
pipeline.vae.decode.return_value = (torch.zeros(4, 3, 8, 16, 16),)
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=False,
|
||||
parallel_config=SimpleNamespace(use_hsdp=False),
|
||||
)
|
||||
pipeline.scheduler = MagicMock(timesteps=torch.tensor([1.0]))
|
||||
pipeline._flow_shift = 3.0
|
||||
pipeline.vae_scale_factor_spatial = 8
|
||||
pipeline.resolution_divisor = 16
|
||||
pipeline.motion_frames = 7
|
||||
pipeline.drop_first_motion = True
|
||||
pipeline._DEFAULT_INFER_FRAMES = 8
|
||||
pipeline._guidance_scale = None
|
||||
pipeline._num_timesteps = None
|
||||
pipeline.check_inputs = lambda *args, **kwargs: None
|
||||
pipeline.encode_prompt = MagicMock(return_value=(torch.zeros(4, 2, 3), torch.ones(4, 2, 3)))
|
||||
pipeline.encode_audio = MagicMock(return_value=(torch.zeros(1, 1, 2, 8), 1, 8))
|
||||
pipeline.encode_ref_image = MagicMock(return_value=torch.zeros(1, 16, 1, 2, 2))
|
||||
pipeline.prepare_motion_latents = MagicMock(
|
||||
side_effect=lambda pixels, **_: torch.zeros(pixels.shape[0], 16, 2, 2, 2)
|
||||
)
|
||||
pipeline._denormalize_latents = lambda latents: latents
|
||||
pipeline.diffuse = MagicMock(side_effect=lambda **kwargs: kwargs["latents"])
|
||||
return pipeline
|
||||
|
||||
|
||||
def _make_s2v_preencode_batch(audio_a, audio_b, **sampling_overrides) -> DiffusionRequestBatch:
|
||||
image = PIL.Image.new("RGB", (16, 16))
|
||||
return DiffusionRequestBatch(
|
||||
requests=[
|
||||
SimpleNamespace(
|
||||
request_id="a",
|
||||
prompt={"prompt": "first", "multi_modal_data": {"image": image, "audio": audio_a}},
|
||||
sampling_params=_make_s2v_sampling(
|
||||
num_outputs_per_prompt=2,
|
||||
latents=torch.zeros(2, 16, 2, 2, 2),
|
||||
output_type="np",
|
||||
**sampling_overrides,
|
||||
),
|
||||
),
|
||||
SimpleNamespace(
|
||||
request_id="b",
|
||||
prompt={"prompt": "second", "multi_modal_data": {"image": image, "audio": audio_b}},
|
||||
sampling_params=_make_s2v_sampling(
|
||||
num_outputs_per_prompt=2,
|
||||
latents=torch.ones(2, 16, 2, 2, 2),
|
||||
output_type="np",
|
||||
**sampling_overrides,
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _decode_mp4(data: bytes):
|
||||
"""Return (video frame count, first audio samples) for one encoded container."""
|
||||
import io
|
||||
|
||||
import av
|
||||
|
||||
with av.open(io.BytesIO(data)) as container:
|
||||
frames = sum(1 for _ in container.decode(video=0))
|
||||
with av.open(io.BytesIO(data)) as container:
|
||||
assert container.streams.audio, "the worker must mux the waveform into the container"
|
||||
samples = np.concatenate(
|
||||
[frame.to_ndarray().reshape(-1) for frame in container.decode(audio=0)][:4],
|
||||
)
|
||||
return frames, samples
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch_frames", [1, 1000])
|
||||
def test_s2v_preencode_returns_playable_mp4_bytes_per_request(monkeypatch, batch_frames) -> None:
|
||||
"""The clip loop hands finished clips to the encoder instead of concatenating."""
|
||||
pipeline = _make_s2v_preencode_pipeline()
|
||||
# Two clips, so the autoregressive motion feedback runs between pushes.
|
||||
pipeline.encode_audio = MagicMock(return_value=(torch.zeros(1, 1, 2, 8), 2, 16))
|
||||
audio_a = np.zeros(16000, dtype=np.float32)
|
||||
audio_b = np.full(16000, 0.5, dtype=np.float32)
|
||||
batch = _make_s2v_preencode_batch(
|
||||
audio_a, audio_b, extra_args={"preencode_mp4": True, "preencode_batch_frames": batch_frames}
|
||||
)
|
||||
from vllm_omni.diffusion.utils import chunked_video
|
||||
|
||||
transfers = []
|
||||
quantize = chunked_video.quantize_chunk
|
||||
|
||||
def capture(chunk, value_range):
|
||||
transfers.append(chunk.shape[2])
|
||||
return quantize(chunk, value_range)
|
||||
|
||||
monkeypatch.setattr(chunked_video, "quantize_chunk", capture)
|
||||
|
||||
with patch("vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_s2v.current_omni_platform") as platform:
|
||||
platform.is_available.return_value = False
|
||||
outputs = pipeline.forward(batch)
|
||||
|
||||
assert len(outputs) == 2
|
||||
assert pipeline.vae.decode.call_count == 2, "both clips must reach the encoder"
|
||||
assert len(transfers) == (2 if batch_frames == 1 else 1)
|
||||
|
||||
decoded = []
|
||||
for request_output in outputs:
|
||||
# Two outputs per prompt, each already a complete container.
|
||||
assert isinstance(request_output.output, list)
|
||||
assert len(request_output.output) == 2
|
||||
for data in request_output.output:
|
||||
assert isinstance(data, bytes)
|
||||
frames, samples = _decode_mp4(data)
|
||||
assert frames > 0
|
||||
decoded.append(samples)
|
||||
|
||||
# Four entries: request a's waveform for its two outputs, then request b's.
|
||||
# A silent and a non-silent waveform must not land on the same request.
|
||||
assert np.abs(decoded[0]).max() == pytest.approx(0.0, abs=1e-3)
|
||||
assert np.abs(decoded[1]).max() == pytest.approx(0.0, abs=1e-3)
|
||||
assert np.abs(decoded[2]).max() > 0.1
|
||||
assert np.abs(decoded[3]).max() > 0.1
|
||||
|
||||
|
||||
def test_s2v_preencode_aborts_encoders_when_a_later_clip_fails(monkeypatch) -> None:
|
||||
"""A failure after the first push must not strand encoder worker threads."""
|
||||
pipeline = _make_s2v_preencode_pipeline()
|
||||
pipeline.encode_audio = MagicMock(return_value=(torch.zeros(1, 1, 2, 8), 2, 16))
|
||||
batch = _make_s2v_preencode_batch(
|
||||
np.zeros(16000, dtype=np.float32),
|
||||
np.ones(16000, dtype=np.float32),
|
||||
extra_args={"preencode_mp4": True, "preencode_batch_frames": 1},
|
||||
)
|
||||
sessions = []
|
||||
|
||||
from vllm_omni.diffusion.utils.chunked_video import ChunkedVideoMP4Session
|
||||
|
||||
def capture_session(**kwargs):
|
||||
session = ChunkedVideoMP4Session(**kwargs)
|
||||
sessions.append(session)
|
||||
return session
|
||||
|
||||
def fail_on_second_clip(**kwargs):
|
||||
if pipeline.vae.decode.call_count:
|
||||
raise RuntimeError("second clip failed")
|
||||
return kwargs["latents"]
|
||||
|
||||
pipeline.diffuse = fail_on_second_clip
|
||||
monkeypatch.setattr("vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_s2v.ChunkedVideoMP4Session", capture_session)
|
||||
|
||||
with patch("vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_s2v.current_omni_platform") as platform:
|
||||
platform.is_available.return_value = False
|
||||
with pytest.raises(RuntimeError, match="second clip failed"):
|
||||
pipeline.forward(batch)
|
||||
|
||||
assert len(sessions) == 1
|
||||
assert len(sessions[0]._encoders) == 4
|
||||
assert all(not encoder._thread.is_alive() for encoder in sessions[0]._encoders)
|
||||
|
||||
|
||||
def test_s2v_preencode_skips_encoding_on_a_vae_patch_parallel_peer(monkeypatch) -> None:
|
||||
"""A peer still receives every clip for the motion loop but encodes none of them."""
|
||||
pipeline = _make_s2v_preencode_pipeline()
|
||||
pipeline.encode_audio = MagicMock(return_value=(torch.zeros(1, 1, 2, 8), 2, 16))
|
||||
# A peer's patch-parallel decode returns an empty placeholder.
|
||||
pipeline.vae.decode.return_value = (torch.empty(0),)
|
||||
pipeline.vae._vae_pp_group = object()
|
||||
broadcasts = []
|
||||
monkeypatch.setattr(
|
||||
"torch.distributed.broadcast",
|
||||
lambda tensor, src, group: broadcasts.append(tensor.zero_()),
|
||||
)
|
||||
batch = _make_s2v_preencode_batch(
|
||||
np.zeros(16000, dtype=np.float32),
|
||||
np.ones(16000, dtype=np.float32),
|
||||
extra_args={"preencode_mp4": True, "preencode_batch_frames": 1},
|
||||
)
|
||||
sessions = []
|
||||
|
||||
from vllm_omni.diffusion.utils.chunked_video import ChunkedVideoMP4Session
|
||||
|
||||
def capture_session(**kwargs):
|
||||
session = ChunkedVideoMP4Session(**kwargs)
|
||||
sessions.append(session)
|
||||
return session
|
||||
|
||||
monkeypatch.setattr("vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_s2v.ChunkedVideoMP4Session", capture_session)
|
||||
|
||||
with patch("vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_s2v.current_omni_platform") as platform:
|
||||
platform.is_available.return_value = False
|
||||
outputs = pipeline.forward(batch)
|
||||
|
||||
assert len(broadcasts) == 2, "the peer must still join the broadcast for both clips"
|
||||
# Once for the initial motion latents, once more from the peer's broadcast clip.
|
||||
assert pipeline.prepare_motion_latents.call_count == 2, "the motion loop still consumes the peer's frames"
|
||||
assert sessions[0]._encoders == []
|
||||
assert [request_output.output for request_output in outputs] == [[], []]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("output_type", ["pil", "pt", "latent"])
|
||||
def test_s2v_preencode_rejects_request_output_types_it_cannot_serve(output_type) -> None:
|
||||
"""Pre-encoding returns MP4 bytes, so a request asking for frames must fail."""
|
||||
pipeline = _make_s2v_preencode_pipeline()
|
||||
batch = _make_s2v_preencode_batch(
|
||||
np.zeros(16000, dtype=np.float32),
|
||||
np.ones(16000, dtype=np.float32),
|
||||
extra_args={"preencode_mp4": True},
|
||||
)
|
||||
for request in batch.requests:
|
||||
request.sampling_params.output_type = output_type
|
||||
|
||||
with pytest.raises(ValueError, match="output_type"):
|
||||
pipeline.forward(batch)
|
||||
|
||||
|
||||
def test_s2v_preencode_keeps_the_full_decode_path_untouched() -> None:
|
||||
"""Without the flag the loop still returns the (video, audio, rate) tuple."""
|
||||
pipeline = _make_s2v_preencode_pipeline()
|
||||
audio_a = np.zeros(16000, dtype=np.float32)
|
||||
audio_b = np.ones(16000, dtype=np.float32)
|
||||
batch = _make_s2v_preencode_batch(audio_a, audio_b)
|
||||
|
||||
with patch("vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_s2v.current_omni_platform") as platform:
|
||||
platform.is_available.return_value = False
|
||||
outputs = pipeline.forward(batch)
|
||||
|
||||
assert outputs[0].output[0].shape[0] == 2
|
||||
np.testing.assert_array_equal(outputs[0].output[1], audio_a)
|
||||
np.testing.assert_array_equal(outputs[1].output[1], audio_b)
|
||||
@@ -0,0 +1,54 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Compare actual Wan schedules to native Wan outputs and independent sigma arithmetic."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import (
|
||||
FASTWAN_DMD_SCHEDULER_SHIFT,
|
||||
build_wan_scheduler,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def test_wan_unipc_matches_captured_native_full_schedule() -> None:
|
||||
# The fixture pins the official Wan source and records how to regenerate it.
|
||||
golden = json.loads((Path(__file__).parent / "fixtures/native_unipc_shift12.json").read_text())
|
||||
scheduler = build_wan_scheduler("unipc", golden["runtime_shift"])
|
||||
for steps in (golden["steps"], 20, golden["steps"]):
|
||||
scheduler.set_timesteps(steps, device="cpu", shift=golden["runtime_shift"])
|
||||
if steps == golden["steps"]:
|
||||
assert scheduler.timesteps.tolist() == golden["timesteps"]
|
||||
torch.testing.assert_close(scheduler.sigmas, torch.tensor(golden["sigmas"]), rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shift", [1.0, 3.0, 5.0, 12.0])
|
||||
@pytest.mark.parametrize("steps", [20, 40])
|
||||
def test_wan_unipc_shifts_unmodified_training_endpoints(shift: float, steps: int) -> None:
|
||||
# Native Wan: float32 training sigma_max=(1-1/1000), sigma_min=0;
|
||||
# interpolate in float64, apply the runtime shift, then cast sigma to FP32.
|
||||
unshifted = np.linspace(float(np.float32(0.999)), 0.0, steps + 1)[:-1]
|
||||
shifted = shift * unshifted / (1.0 + (shift - 1.0) * unshifted)
|
||||
expected_timesteps = (shifted * 1000).astype(np.int64)
|
||||
expected_sigmas: npt.NDArray[np.float32] = np.append(shifted, 0.0).astype(np.float32)
|
||||
scheduler = build_wan_scheduler("unipc", shift)
|
||||
scheduler.set_timesteps(steps, device="cpu", shift=shift)
|
||||
assert scheduler.config.shift == scheduler.config["shift"] == 1.0
|
||||
torch.testing.assert_close(scheduler.timesteps, torch.from_numpy(expected_timesteps), rtol=0, atol=0)
|
||||
torch.testing.assert_close(scheduler.sigmas, torch.from_numpy(expected_sigmas), rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_wan_euler_keeps_dmd_shift_semantics() -> None:
|
||||
scheduler = build_wan_scheduler("euler", FASTWAN_DMD_SCHEDULER_SHIFT)
|
||||
scheduler.set_timesteps(3, device="cpu")
|
||||
# The unchanged Euler branch starts at sigma=1, with shift=8 for DMD.
|
||||
expected = torch.tensor([1.0, 16.0 / 17.0, 0.8, 0.0])
|
||||
torch.testing.assert_close(scheduler.sigmas, expected, rtol=0, atol=1e-7)
|
||||
torch.testing.assert_close(scheduler.timesteps, expected[:-1] * 1000, rtol=0, atol=1e-4)
|
||||
@@ -1,15 +1,17 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
from torch import nn
|
||||
|
||||
from tests.diffusion.models.wan2_2.conftest import StubScheduler, StubTransformer, StubVAE, noop_progress_bar
|
||||
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import build_wan_scheduler
|
||||
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_vace import (
|
||||
Wan22VACEPipeline,
|
||||
create_vace_transformer_from_config,
|
||||
@@ -33,6 +35,8 @@ def test_vace_default_flow_shift_preserves_compact_offload_runtime_state(monkeyp
|
||||
)
|
||||
enable_layerwise_offload: bool = True
|
||||
post_init_calls: int = 0
|
||||
_resolved_diffusion_offload: object = None
|
||||
_diffusion_offload_flags_materialized: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.post_init_calls += 1
|
||||
@@ -41,7 +45,7 @@ def test_vace_default_flow_shift_preserves_compact_offload_runtime_state(monkeyp
|
||||
resolved_offload = object()
|
||||
config._resolved_diffusion_offload = resolved_offload
|
||||
config._diffusion_offload_flags_materialized = True
|
||||
captured = {}
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def capture_base_init(self, *, od_config, prefix="") -> None:
|
||||
captured.update(od_config=od_config, prefix=prefix)
|
||||
@@ -51,6 +55,7 @@ def test_vace_default_flow_shift_preserves_compact_offload_runtime_state(monkeyp
|
||||
Wan22VACEPipeline(od_config=config, prefix="stage")
|
||||
|
||||
forwarded = captured["od_config"]
|
||||
assert isinstance(forwarded, RuntimeConfig)
|
||||
assert forwarded is not config
|
||||
assert config.flow_shift is None
|
||||
assert forwarded.flow_shift == 3.0
|
||||
@@ -75,7 +80,7 @@ def _make_vace_pipeline() -> Wan22VACEPipeline:
|
||||
|
||||
|
||||
def _make_vace_sampling(**overrides):
|
||||
values = {
|
||||
values: dict[str, object] = {
|
||||
"height": 16,
|
||||
"width": 16,
|
||||
"num_frames": 5,
|
||||
@@ -123,7 +128,7 @@ def test_vace_preprocess_collects_reference_video_and_mask_inputs() -> None:
|
||||
|
||||
|
||||
def test_create_vace_transformer_from_config_maps_vace_specific_keys(monkeypatch) -> None:
|
||||
captured = {}
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeVACETransformer:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
@@ -203,7 +208,9 @@ def test_vace_diffuse_passes_context_and_scale_to_cfg_branches() -> None:
|
||||
torch.testing.assert_close(result, torch.ones_like(latents))
|
||||
|
||||
|
||||
def test_vace_forward_batches_random_inputs_and_splits_outputs(monkeypatch) -> None:
|
||||
@pytest.mark.parametrize("solver", ["unipc", "euler"])
|
||||
@pytest.mark.parametrize("shift", [3.0, 5.0, 12.0])
|
||||
def test_vace_forward_batches_random_inputs_and_splits_outputs(monkeypatch, solver: str, shift: float) -> None:
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_vace.current_omni_platform",
|
||||
SimpleNamespace(is_available=lambda: False),
|
||||
@@ -211,10 +218,10 @@ def test_vace_forward_batches_random_inputs_and_splits_outputs(monkeypatch) -> N
|
||||
pipeline = _make_vace_pipeline()
|
||||
pipeline.transformer.vace_patch_embedding = None
|
||||
pipeline.transformer_2 = None
|
||||
pipeline.scheduler = StubScheduler([9])
|
||||
pipeline.od_config = SimpleNamespace(flow_shift=3.0)
|
||||
pipeline._sample_solver = "unipc"
|
||||
pipeline._flow_shift = 3.0
|
||||
pipeline.scheduler = build_wan_scheduler(solver, shift)
|
||||
pipeline.od_config = SimpleNamespace(flow_shift=shift)
|
||||
pipeline._sample_solver = solver
|
||||
pipeline._flow_shift = shift
|
||||
pipeline.boundary_ratio = None
|
||||
pipeline._guidance_scale = None
|
||||
pipeline._num_timesteps = None
|
||||
@@ -247,6 +254,8 @@ def test_vace_forward_batches_random_inputs_and_splits_outputs(monkeypatch) -> N
|
||||
generator=gen_a,
|
||||
latents=latents_a,
|
||||
num_outputs_per_prompt=2,
|
||||
num_inference_steps=50,
|
||||
extra_args={"sample_solver": solver, "flow_shift": shift},
|
||||
),
|
||||
),
|
||||
SimpleNamespace(
|
||||
@@ -256,12 +265,25 @@ def test_vace_forward_batches_random_inputs_and_splits_outputs(monkeypatch) -> N
|
||||
generator=gen_b,
|
||||
latents=latents_b,
|
||||
num_outputs_per_prompt=2,
|
||||
num_inference_steps=50,
|
||||
extra_args={"sample_solver": solver, "flow_shift": shift},
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
outputs = pipeline.forward(batch)
|
||||
if solver == "unipc":
|
||||
sigmas = np.linspace(float(np.float32(0.999)), 0.0, 51)[:-1]
|
||||
sigmas = shift * sigmas / (1.0 + (shift - 1.0) * sigmas)
|
||||
torch.testing.assert_close(
|
||||
pipeline.scheduler.sigmas, torch.tensor(np.append(sigmas, 0.0), dtype=torch.float32), rtol=0, atol=0
|
||||
)
|
||||
assert pipeline.scheduler.timesteps.tolist() == (sigmas * 1000).astype(np.int64).tolist()
|
||||
else:
|
||||
reference = build_wan_scheduler("euler", shift)
|
||||
reference.set_timesteps(50, device="cpu")
|
||||
torch.testing.assert_close(pipeline.scheduler.sigmas, reference.sigmas, rtol=0, atol=0)
|
||||
|
||||
assert prepare_call["batch_size"] == 4
|
||||
assert prepare_call["generator"] == [gen_a, gen_a, gen_b, gen_b]
|
||||
|
||||
Reference in new issue
Block a user