[FEAT]Add FastVideo VSA backend for Wan2.2 (#4820)
Signed-off-by: bjf-frz <frz123db@gmail.com> Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
This commit is contained in:
1 parent
3d035bfa19
commit
072bfc02dd
28 files changed
+1316
-107
No files matched your search
@@ -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
|
||||
|
||||
"""Tests for per-role attention backend configuration (RFC: per-role-attention-backend).
|
||||
|
||||
@@ -90,6 +90,14 @@ class TestAttentionSpec:
|
||||
with pytest.raises(ValueError, match=match):
|
||||
AttentionSpec(**spec)
|
||||
|
||||
def test_fastvideo_vsa_topk_serialized(self):
|
||||
spec = AttentionSpec(backend="FASTVIDEO_VSA", fastvideo_vsa_topk=96)
|
||||
assert spec.backend_kwargs() == {"topk": 96}
|
||||
|
||||
def test_fastvideo_vsa_topk_rejected_for_other_backend(self):
|
||||
with pytest.raises(ValueError, match="only supported by the FASTVIDEO_VSA"):
|
||||
AttentionSpec(backend="TORCH_SDPA", fastvideo_vsa_topk=96)
|
||||
|
||||
def test_block_sparse_defaults_applied_when_backend_selected(self):
|
||||
spec = AttentionSpec(backend="RAINFUSION_ATTN")
|
||||
assert spec.block_sparse.sparsity == 0.8
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import sys
|
||||
import types
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
|
||||
from vllm_omni.diffusion.attention.backends.fastvideo_vsa import (
|
||||
FastVideoVSAImpl,
|
||||
)
|
||||
from vllm_omni.diffusion.attention.backends.registry import (
|
||||
DiffusionAttentionBackendEnum,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_fastvideo_vsa_backend_is_registered():
|
||||
assert DiffusionAttentionBackendEnum.FASTVIDEO_VSA.get_path().endswith("fastvideo_vsa.FastVideoVSABackend")
|
||||
|
||||
|
||||
def test_fastvideo_vsa_tiles_3d_sequence_and_untiles(monkeypatch):
|
||||
calls: dict[str, Any] = {}
|
||||
fake_module = types.ModuleType("fastvideo_kernel")
|
||||
|
||||
def fake_video_sparse_attn_bshd(
|
||||
q,
|
||||
k,
|
||||
v,
|
||||
variable_block_sizes,
|
||||
q_variable_block_sizes,
|
||||
compress_attn_weight,
|
||||
topk,
|
||||
block_size,
|
||||
):
|
||||
calls["q_shape"] = tuple(q.shape)
|
||||
calls["vbs"] = variable_block_sizes.detach().cpu().tolist()
|
||||
calls["q_vbs"] = q_variable_block_sizes.detach().cpu().tolist()
|
||||
calls["compress_sum"] = float(compress_attn_weight.detach().cpu().sum())
|
||||
calls["compress_shape"] = tuple(compress_attn_weight.shape)
|
||||
calls["topk"] = topk
|
||||
calls["block_size"] = block_size
|
||||
return q + k + v
|
||||
|
||||
setattr(fake_module, "video_sparse_attn_bshd", fake_video_sparse_attn_bshd)
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel", fake_module)
|
||||
|
||||
impl = FastVideoVSAImpl(
|
||||
num_heads=2,
|
||||
head_size=8,
|
||||
softmax_scale=8**-0.5,
|
||||
causal=False,
|
||||
backend_kwargs={
|
||||
"topk": 1,
|
||||
"block_size": (4, 8, 8),
|
||||
"min_seq_len": 1,
|
||||
"disable_when_sp_active": False,
|
||||
},
|
||||
)
|
||||
query = torch.randn(1, 300, 2, 8)
|
||||
key = torch.randn_like(query)
|
||||
value = torch.randn_like(query)
|
||||
attn_metadata = AttentionMetadata(extra={"vsa_dit_seq_shape": (3, 10, 10)})
|
||||
monkeypatch.setattr(impl, "_fallback_reason", lambda *args, **kwargs: None)
|
||||
|
||||
output = impl.forward_cuda(query, key, value, attn_metadata)
|
||||
|
||||
assert output.shape == query.shape
|
||||
assert calls["q_shape"] == (1, 1024, 2, 8)
|
||||
assert calls["vbs"] == [192, 48, 48, 12]
|
||||
assert calls["q_vbs"] == [192, 48, 48, 12]
|
||||
assert calls["compress_shape"] == (1, 1024, 2, 8)
|
||||
assert calls["compress_sum"] == 0.0
|
||||
assert calls["topk"] == 1
|
||||
assert calls["block_size"] == (4, 8, 8)
|
||||
|
||||
|
||||
def test_fastvideo_vsa_uses_learned_gate_when_provided(monkeypatch):
|
||||
calls: dict[str, Any] = {}
|
||||
fake_module = types.ModuleType("fastvideo_kernel")
|
||||
|
||||
def fake_video_sparse_attn_bshd(
|
||||
q,
|
||||
k,
|
||||
v,
|
||||
variable_block_sizes,
|
||||
q_variable_block_sizes,
|
||||
compress_attn_weight,
|
||||
topk,
|
||||
block_size,
|
||||
):
|
||||
calls["compress_sum"] = float(compress_attn_weight.detach().cpu().sum())
|
||||
calls["compress_shape"] = tuple(compress_attn_weight.shape)
|
||||
return q + k + v
|
||||
|
||||
setattr(fake_module, "video_sparse_attn_bshd", fake_video_sparse_attn_bshd)
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel", fake_module)
|
||||
|
||||
impl = FastVideoVSAImpl(
|
||||
num_heads=2,
|
||||
head_size=8,
|
||||
softmax_scale=8**-0.5,
|
||||
causal=False,
|
||||
backend_kwargs={
|
||||
"topk": 1,
|
||||
"block_size": (4, 8, 8),
|
||||
"min_seq_len": 1,
|
||||
"disable_when_sp_active": False,
|
||||
},
|
||||
)
|
||||
query = torch.randn(1, 300, 2, 8)
|
||||
gate = torch.ones_like(query)
|
||||
metadata = AttentionMetadata(extra={"vsa_dit_seq_shape": (3, 10, 10), "gate_compress": gate})
|
||||
monkeypatch.setattr(impl, "_fallback_reason", lambda *args, **kwargs: None)
|
||||
|
||||
output = impl.forward_cuda(query, query, query, metadata)
|
||||
|
||||
assert output.shape == query.shape
|
||||
assert calls["compress_shape"] == (1, 1024, 2, 8)
|
||||
# Only the 300 real tokens carry ones; padded gate slots stay zero.
|
||||
assert calls["compress_sum"] == gate.numel()
|
||||
|
||||
|
||||
def test_fastvideo_vsa_falls_back_without_dit_shape(monkeypatch):
|
||||
calls: dict[str, Any] = {}
|
||||
|
||||
impl = FastVideoVSAImpl(
|
||||
num_heads=2,
|
||||
head_size=8,
|
||||
softmax_scale=8**-0.5,
|
||||
causal=False,
|
||||
backend_kwargs={
|
||||
"topk": 1,
|
||||
"block_size": (4, 8, 8),
|
||||
"min_seq_len": 1,
|
||||
"disable_when_sp_active": False,
|
||||
},
|
||||
)
|
||||
|
||||
def fake_fallback(query, key, value, attn_metadata, reason):
|
||||
calls["reason"] = reason
|
||||
return torch.zeros_like(query)
|
||||
|
||||
monkeypatch.setattr(impl, "_fallback", fake_fallback)
|
||||
|
||||
query = torch.randn(1, 512, 2, 8)
|
||||
output = impl.forward_cuda(query, query, query, AttentionMetadata())
|
||||
|
||||
assert output.shape == query.shape
|
||||
assert calls["reason"] == "vsa_dit_seq_shape metadata is required"
|
||||
|
||||
|
||||
def test_fastvideo_vsa_falls_back_for_mask(monkeypatch):
|
||||
calls: dict[str, Any] = {}
|
||||
|
||||
impl = FastVideoVSAImpl(
|
||||
num_heads=2,
|
||||
head_size=8,
|
||||
softmax_scale=8**-0.5,
|
||||
causal=False,
|
||||
backend_kwargs={"min_seq_len": 1},
|
||||
)
|
||||
|
||||
def fake_fallback(query, key, value, attn_metadata, reason):
|
||||
calls["reason"] = reason
|
||||
return torch.zeros_like(query)
|
||||
|
||||
monkeypatch.setattr(impl, "_fallback", fake_fallback)
|
||||
|
||||
query = torch.randn(1, 512, 2, 8)
|
||||
mask = torch.ones(1, 512, dtype=torch.bool)
|
||||
output = impl.forward_cuda(query, query, query, AttentionMetadata(attn_mask=mask))
|
||||
|
||||
assert output.shape == query.shape
|
||||
assert calls["reason"]
|
||||
|
||||
|
||||
def test_fastvideo_vsa_allows_topk_equal_to_num_blocks():
|
||||
query = torch.randn(1, 512, 2, 8)
|
||||
metadata = AttentionMetadata(extra={"vsa_dit_seq_shape": (4, 8, 16)})
|
||||
|
||||
all_blocks = FastVideoVSAImpl(
|
||||
num_heads=2,
|
||||
head_size=8,
|
||||
softmax_scale=8**-0.5,
|
||||
causal=False,
|
||||
backend_kwargs={"topk": 2, "block_size": (4, 8, 8), "min_seq_len": 1},
|
||||
)
|
||||
too_many = FastVideoVSAImpl(
|
||||
num_heads=2,
|
||||
head_size=8,
|
||||
softmax_scale=8**-0.5,
|
||||
causal=False,
|
||||
backend_kwargs={"topk": 3, "block_size": (4, 8, 8), "min_seq_len": 1},
|
||||
)
|
||||
|
||||
# CPU/float32 is rejected later, but k=N itself must not trigger fallback.
|
||||
assert all_blocks._fallback_reason(query, query, query, metadata) == "dtype torch.float32 is not supported"
|
||||
assert too_many._fallback_reason(query, query, query, metadata) == "topk 3 > num_blocks 2"
|
||||
@@ -1,6 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import importlib
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -9,6 +10,7 @@ import torch
|
||||
from torch import nn
|
||||
|
||||
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import Wan22Pipeline
|
||||
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
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
@@ -82,6 +84,7 @@ def _make_pipeline() -> Wan22Pipeline:
|
||||
pipeline.vae_scale_factor_spatial = 8
|
||||
pipeline.boundary_ratio = 0.875
|
||||
pipeline.expand_timesteps = False
|
||||
pipeline.is_dmd = False
|
||||
pipeline._guidance_scale = None
|
||||
pipeline._guidance_scale_2 = None
|
||||
pipeline._num_timesteps = None
|
||||
@@ -94,7 +97,7 @@ def _make_pipeline() -> Wan22Pipeline:
|
||||
|
||||
|
||||
def _make_sampling(**overrides):
|
||||
values = {
|
||||
values: dict[str, object] = {
|
||||
"height": None,
|
||||
"width": None,
|
||||
"num_frames": 1,
|
||||
@@ -338,3 +341,125 @@ def test_diffuse_runs_prediction_and_scheduler_for_each_timestep() -> None:
|
||||
(3.0, 3, 28.0, False),
|
||||
]
|
||||
assert torch.equal(result, torch.full_like(latents, 10.0))
|
||||
|
||||
|
||||
class _StubDMDScheduler:
|
||||
def __init__(self) -> None:
|
||||
self.predict_clean_calls: list[tuple[float, float, float]] = []
|
||||
self.add_noise_calls: list[tuple[float, float, float]] = []
|
||||
|
||||
def predict_clean(self, model_output, sample, timestep):
|
||||
self.predict_clean_calls.append((float(model_output.mean()), float(sample.mean()), float(timestep)))
|
||||
return sample - model_output
|
||||
|
||||
def add_noise(self, clean_sample, noise, timestep):
|
||||
self.add_noise_calls.append((float(clean_sample.mean()), float(noise.mean()), float(timestep)))
|
||||
return clean_sample + 10.0
|
||||
|
||||
|
||||
def test_diffuse_dmd_predicts_clean_and_renoises_between_steps(monkeypatch) -> None:
|
||||
pipeline = _make_pipeline()
|
||||
pipeline.is_dmd = True
|
||||
pipeline.scheduler = _StubDMDScheduler()
|
||||
latents = torch.zeros((1, 1, 1, 1, 1), dtype=torch.float32)
|
||||
timesteps = torch.tensor([1000.0, 757.0, 522.0])
|
||||
|
||||
pipeline.predict_noise_maybe_with_cfg = lambda **kwargs: torch.ones_like(latents) # type: ignore[method-assign]
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2.randn_tensor",
|
||||
lambda *args, **kwargs: torch.full(args[0], 2.0, dtype=kwargs["dtype"]),
|
||||
)
|
||||
|
||||
result = pipeline.diffuse(
|
||||
latents=latents,
|
||||
timesteps=timesteps,
|
||||
prompt_embeds=torch.zeros(1, 8),
|
||||
negative_prompt_embeds=None,
|
||||
guidance_low=1.0,
|
||||
guidance_high=1.0,
|
||||
boundary_timestep=None,
|
||||
dtype=torch.float32,
|
||||
attention_kwargs={},
|
||||
generator=torch.Generator(device="cpu").manual_seed(1),
|
||||
)
|
||||
|
||||
assert pipeline.scheduler.predict_clean_calls == [
|
||||
(1.0, 0.0, 1000.0),
|
||||
(1.0, 9.0, 757.0),
|
||||
(1.0, 18.0, 522.0),
|
||||
]
|
||||
assert pipeline.scheduler.add_noise_calls == [
|
||||
(-1.0, 2.0, 757.0),
|
||||
(8.0, 2.0, 522.0),
|
||||
]
|
||||
torch.testing.assert_close(result, torch.tensor([[[[[17.0]]]]]))
|
||||
|
||||
|
||||
def _make_gate_loading_pipeline():
|
||||
pipeline = Wan22Pipeline.__new__(Wan22Pipeline)
|
||||
nn.Module.__init__(pipeline)
|
||||
gate = WanSelfAttention.__new__(WanSelfAttention)
|
||||
nn.Module.__init__(gate)
|
||||
gate.to_gate_compress = nn.Linear(1, 1)
|
||||
pipeline.gate_holder = gate
|
||||
return pipeline, gate
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("module_name", "class_name"),
|
||||
[
|
||||
("pipeline_wan2_2", "Wan22Pipeline"),
|
||||
("pipeline_wan2_2_i2v", "Wan22I2VPipeline"),
|
||||
("pipeline_wan2_2_s2v", "Wan22S2VPipeline"),
|
||||
("pipeline_wan2_2_vace", "Wan22VACEPipeline"),
|
||||
],
|
||||
)
|
||||
def test_wan_pipeline_loaders_share_optional_gate_cleanup(monkeypatch, module_name, class_name) -> None:
|
||||
module = importlib.import_module(f"vllm_omni.diffusion.models.wan2_2.{module_name}")
|
||||
pipeline_cls = getattr(module, class_name)
|
||||
pipeline = pipeline_cls.__new__(pipeline_cls)
|
||||
expected = {"loaded"}
|
||||
|
||||
def fake_loader(model, weights):
|
||||
assert model is pipeline
|
||||
assert list(weights) == [("weight", torch.ones(1))]
|
||||
return expected
|
||||
|
||||
monkeypatch.setattr(module, "load_wan_weights_with_optional_gate", fake_loader)
|
||||
|
||||
assert pipeline_cls.load_weights(pipeline, iter((("weight", torch.ones(1)),))) is expected
|
||||
|
||||
|
||||
def test_load_weights_removes_unloaded_vsa_gate(monkeypatch) -> None:
|
||||
pipeline, gate = _make_gate_loading_pipeline()
|
||||
|
||||
class _Loader:
|
||||
def __init__(self, model):
|
||||
del model
|
||||
|
||||
def load_weights(self, weights):
|
||||
return {name for name, _ in weights}
|
||||
|
||||
monkeypatch.setattr("vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2.AutoWeightsLoader", _Loader)
|
||||
pipeline.load_weights(iter((("other.weight", torch.ones(1)),)))
|
||||
|
||||
assert pipeline.has_gate_compress_weights is False
|
||||
assert gate.to_gate_compress is None
|
||||
|
||||
|
||||
def test_load_weights_keeps_trained_vsa_gate(monkeypatch) -> None:
|
||||
pipeline, gate = _make_gate_loading_pipeline()
|
||||
original_gate = gate.to_gate_compress
|
||||
|
||||
class _Loader:
|
||||
def __init__(self, model):
|
||||
del model
|
||||
|
||||
def load_weights(self, weights):
|
||||
return {name for name, _ in weights}
|
||||
|
||||
monkeypatch.setattr("vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2.AutoWeightsLoader", _Loader)
|
||||
pipeline.load_weights(iter((("gate_holder.to_gate_compress.weight", torch.ones(1)),)))
|
||||
|
||||
assert pipeline.has_gate_compress_weights is True
|
||||
assert gate.to_gate_compress is original_gate
|
||||
@@ -0,0 +1,32 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.wan2_2.scheduling_wan_euler import WanEulerScheduler
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def test_dmd_predict_clean_and_add_noise_match_flow_formulas() -> None:
|
||||
scheduler = WanEulerScheduler(num_train_timesteps=1000, shift=8.0)
|
||||
timestep = torch.tensor(757.0)
|
||||
sigma = scheduler.sigma_for_timestep(timestep)
|
||||
sample = torch.tensor([2.0, -1.0])
|
||||
model_output = torch.tensor([0.5, 0.25])
|
||||
noise = torch.tensor([-0.5, 1.5])
|
||||
|
||||
clean = scheduler.predict_clean(model_output, sample, timestep)
|
||||
expected_clean = sample - sigma * model_output
|
||||
torch.testing.assert_close(clean, expected_clean)
|
||||
|
||||
renoised = scheduler.add_noise(clean, noise, timestep)
|
||||
expected_renoised = (1.0 - sigma) * expected_clean + sigma * noise
|
||||
torch.testing.assert_close(renoised, expected_renoised)
|
||||
|
||||
|
||||
def test_dmd_sigma_requires_scalar_timestep() -> None:
|
||||
scheduler = WanEulerScheduler(num_train_timesteps=1000, shift=8.0)
|
||||
with pytest.raises(ValueError, match="scalar timestep"):
|
||||
scheduler.sigma_for_timestep(torch.tensor([757.0, 522.0]))
|
||||
Reference in new issue
Block a user