[Model] Add TP and CFG parallelism to SANA-Video 2B (#5861)

Signed-off-by: cr-gao <gaochenrui@sjtu.edu.cn>
This commit is contained in:
vOv authored and GitHub committed 2026-09-02 17:44:41 +08:00
1 parent b6e72f3879
commit 57c08d34c3
18 files changed
+1621 -116

No files matched your search

@@ -0,0 +1,582 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""CFG dispatch tests for the native SANA-Video pipelines.
These exercise the CFG world-size-1 batch path, the CFG world-size-2 dispatch,
and the fp32 combine semantics on CPU / mocked distributed. The real all-gather
path is covered by the multi-process suite, not here.
"""
import pytest
import torch
from vllm_omni.diffusion.models.sana_video.pipeline_sana_video import SanaVideoPipeline
from vllm_omni.diffusion.models.sana_video.pipeline_sana_video_i2v import SanaImageToVideoPipeline
from vllm_omni.diffusion.models.sana_video.transformer_sana_video import (
SanaVideoTransformerOutput,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
def test_predict_noise_calls_transformer_with_return_dict_false():
"""predict_noise must call the transformer with return_dict=False.
The transformer returns a SanaVideoTransformerOutput dataclass when
return_dict is True (its default), which is not subscriptable. The CFG
mixin's default predict_noise does ``result[0]`` and would therefore
raise TypeError on SANA; the pipeline override must force return_dict=False
so the tuple form is returned and indexed.
"""
captured = {}
expected = torch.full((1, 2, 3), 7.0)
class _SpyTransformer:
def __call__(self, *args, return_dict=True, **kwargs):
captured["return_dict"] = return_dict
# Mirror the real transformer: tuple on False, dataclass on True.
return (expected,) if not return_dict else SanaVideoTransformerOutput(sample=expected)
pipe = SanaVideoPipeline.__new__(SanaVideoPipeline) # bypass heavy __init__
pipe.transformer = _SpyTransformer()
out = pipe.predict_noise(
torch.zeros(1, 2, 3),
encoder_hidden_states=torch.zeros(1, 1, 4),
encoder_attention_mask=None,
timestep=torch.zeros(1),
)
assert captured["return_dict"] is False
assert torch.equal(out, expected)
class _IdentityScheduler:
"""Scheduler stub whose step is a no-op, so diffuse tests isolate the CFG
dispatch from real scheduler math."""
order = 1
def step(self, noise_pred, t, latents, **kwargs):
return (latents,)
def _set_cfg_world_size(monkeypatch, n):
"""Pipelines always run inside an initialized CFG group; unit tests stub its
world size instead of spinning up torch.distributed. Patch both pipeline
modules since each resolves the symbol in its own namespace."""
import vllm_omni.diffusion.distributed.cfg_parallel as cfg
import vllm_omni.diffusion.models.sana_video.pipeline_sana_video as t2v
import vllm_omni.diffusion.models.sana_video.pipeline_sana_video_i2v as i2v
monkeypatch.setattr(t2v, "get_classifier_free_guidance_world_size", lambda: n, raising=False)
monkeypatch.setattr(i2v, "get_classifier_free_guidance_world_size", lambda: n, raising=False)
monkeypatch.setattr(cfg, "get_classifier_free_guidance_world_size", lambda: n, raising=False)
monkeypatch.setattr(t2v, "model_parallel_is_initialized", lambda: True, raising=False)
monkeypatch.setattr(i2v, "model_parallel_is_initialized", lambda: True, raising=False)
def _bare_t2v_pipeline(transformer, guidance_scale):
pipe = SanaVideoPipeline.__new__(SanaVideoPipeline) # bypass heavy __init__
pipe.transformer = transformer
pipe.scheduler = _IdentityScheduler()
pipe._interrupt = False
pipe._guidance_scale = guidance_scale
return pipe
def test_cfg_world_size_1_does_single_batch2_forward_per_step(monkeypatch):
"""CFG world size 1 with guidance must do exactly one batch-2 transformer
forward per step (the batch path), not the mixin's sequential two-forward
path."""
_set_cfg_world_size(monkeypatch, 1)
batch_dims = []
class _SpyTransformer:
dtype = torch.float32
def __call__(self, hidden_states, *, encoder_hidden_states, encoder_attention_mask, timestep, return_dict):
batch_dims.append(hidden_states.shape[0])
# out_channels == in_channels here (no learned-sigma split).
return (torch.zeros_like(hidden_states),)
b = 1
pipe = _bare_t2v_pipeline(_SpyTransformer(), guidance_scale=6.0)
out = pipe.diffuse(
latents=torch.randn(b, 4, 2, 4, 4),
timesteps=torch.tensor([1000.0]),
prompt_embeds=torch.randn(b, 3, 8),
prompt_attention_mask=torch.ones(b, 3),
negative_prompt_embeds=torch.randn(b, 3, 8),
negative_prompt_attention_mask=torch.ones(b, 3),
guidance_scale=6.0,
extra_step_kwargs={},
dtype=torch.float32,
output_slice=None,
)
assert len(batch_dims) == 1, f"expected 1 forward this step, got {len(batch_dims)}"
assert batch_dims[0] == 2 * b, f"expected batch-2 (neg+pos) forward, got batch {batch_dims[0]}"
assert out.shape == (b, 4, 2, 4, 4)
class _DeterministicTransformer:
"""Learned-sigma transformer whose output depends on the row's encoder
embeds and timestep, so uncond != text and the CFG combine is non-trivial."""
dtype = torch.float32
def __init__(self, latent_channels):
self.out_channels = 2 * latent_channels # learned sigma
def __call__(self, hidden_states, *, encoder_hidden_states, encoder_attention_mask, timestep, return_dict):
b, c = hidden_states.shape[0], hidden_states.shape[1]
sig = encoder_hidden_states.float().mean(dim=(1, 2)).view(b, 1, 1, 1, 1)
base = hidden_states.float() + sig + timestep.float().view(b, 1, 1, 1, 1)
return (base.repeat(1, self.out_channels // c, 1, 1, 1),)
class _LinearScheduler:
"""Deterministic stepping scheduler so multi-step divergence compounds."""
order = 1
def step(self, noise_pred, t, latents, return_dict=False, **kwargs):
return (latents - 0.1 * noise_pred,)
def _pr1_reference_loop(
transformer,
scheduler,
latents,
timesteps,
prompt_embeds,
prompt_mask,
negative_embeds,
negative_mask,
guidance_scale,
latent_channels,
dtype,
):
"""The original CFG1 denoise math, verbatim, as the parity oracle."""
prompt_embeds = torch.cat([negative_embeds, prompt_embeds], dim=0)
prompt_mask = torch.cat([negative_mask, prompt_mask], dim=0)
for t in timesteps:
latent_model_input = torch.cat([latents] * 2)
timestep = t.expand(latent_model_input.shape[0])
noise_pred = transformer(
latent_model_input.to(dtype=dtype),
encoder_hidden_states=prompt_embeds.to(dtype=dtype),
encoder_attention_mask=prompt_mask,
timestep=timestep,
return_dict=False,
)[0]
noise_pred = noise_pred.float()
noise_pred_uncond, noise_pred_text = noise_pred.chunk(2)
noise_pred = noise_pred_uncond + guidance_scale * (noise_pred_text - noise_pred_uncond)
if transformer.out_channels // 2 == latent_channels:
noise_pred = noise_pred.chunk(2, dim=1)[0]
latents = scheduler.step(noise_pred, t, latents, return_dict=False)[0]
return latents
def test_cfg1_refactored_diffuse_is_bit_identical_to_pr1_loop(monkeypatch):
"""The refactored diffuse must reproduce the original CFG1 latents bit-for-bit
across multiple steps, preserving the TP1/CFG1 golden output."""
_set_cfg_world_size(monkeypatch, 1)
torch.manual_seed(0)
b, c = 2, 4
latents0 = torch.randn(b, c, 2, 4, 4)
timesteps = torch.tensor([900.0, 600.0, 300.0])
prompt_embeds = torch.randn(b, 3, 8)
negative_embeds = torch.randn(b, 3, 8)
prompt_mask = torch.ones(b, 3)
negative_mask = torch.ones(b, 3)
guidance_scale = 6.0
transformer = _DeterministicTransformer(latent_channels=c)
scheduler = _LinearScheduler()
ref = _pr1_reference_loop(
transformer,
scheduler,
latents0.clone(),
timesteps,
prompt_embeds,
prompt_mask,
negative_embeds,
negative_mask,
guidance_scale,
latent_channels=c,
dtype=torch.float32,
)
pipe = _bare_t2v_pipeline(transformer, guidance_scale=guidance_scale)
pipe.scheduler = scheduler
got = pipe.diffuse(
latents=latents0.clone(),
timesteps=timesteps,
prompt_embeds=prompt_embeds,
prompt_attention_mask=prompt_mask,
negative_prompt_embeds=negative_embeds,
negative_prompt_attention_mask=negative_mask,
guidance_scale=guidance_scale,
extra_step_kwargs={},
dtype=torch.float32,
output_slice=c,
)
assert torch.equal(got, ref)
def test_combine_cfg_noise_upcasts_to_fp32_before_combine():
"""combine_cfg_noise must upcast positive/negative to fp32 before the CFG
formula, so the CFG2 (bf16 all-gather) combine is bit-identical to CFG1's
fp32 combine."""
pipe = SanaVideoPipeline.__new__(SanaVideoPipeline)
torch.manual_seed(0)
pos = torch.randn(1, 4, 2, 4, 4).bfloat16()
neg = torch.randn(1, 4, 2, 4, 4).bfloat16()
scale = 6.0
out = pipe.combine_cfg_noise(pos, neg, scale, cfg_normalize=False)
expected = neg.float() + scale * (pos.float() - neg.float())
assert out.dtype == torch.float32
assert torch.equal(out, expected)
def test_combine_cfg_noise_accepts_tuple_predictions_from_gather():
"""The CFG-parallel gather path hands combine_cfg_noise single-element
tuples, not bare tensors; the override must upcast and combine them."""
pipe = SanaVideoPipeline.__new__(SanaVideoPipeline)
torch.manual_seed(0)
pos = torch.randn(1, 4, 2, 4, 4).bfloat16()
neg = torch.randn(1, 4, 2, 4, 4).bfloat16()
scale = 6.0
out = pipe.combine_cfg_noise((pos,), (neg,), scale, cfg_normalize=False)
expected = neg.float() + scale * (pos.float() - neg.float())
assert out.dtype == torch.float32
assert torch.equal(out, expected)
def test_cfg2_dispatch_builds_independent_branch_kwargs(monkeypatch):
"""In CFG world size 2, diffuse must build separate batch-B positive/negative
kwargs (no pre-cat), and call the mixin with cfg_normalize=False and the
learned-sigma output_slice."""
_set_cfg_world_size(monkeypatch, 2)
b, c = 1, 4
captured = {}
def _spy_cfg(**kwargs):
captured.update(kwargs)
return torch.zeros(b, c, 2, 4, 4)
prompt_embeds = torch.randn(b, 3, 8)
negative_embeds = torch.randn(b, 3, 8)
dtype = torch.float32
pipe = _bare_t2v_pipeline(transformer=None, guidance_scale=6.0)
pipe.predict_noise_maybe_with_cfg = _spy_cfg
pipe.diffuse(
latents=torch.randn(b, c, 2, 4, 4),
timesteps=torch.tensor([1000.0]),
prompt_embeds=prompt_embeds,
prompt_attention_mask=torch.ones(b, 3),
negative_prompt_embeds=negative_embeds,
negative_prompt_attention_mask=torch.zeros(b, 3),
guidance_scale=6.0,
extra_step_kwargs={},
dtype=dtype,
output_slice=c,
)
assert captured, "predict_noise_maybe_with_cfg was not called in CFG world size 2"
assert captured["do_true_cfg"] is True
assert captured["true_cfg_scale"] == 6.0
assert captured["cfg_normalize"] is False
assert captured["output_slice"] == c
pos = captured["positive_kwargs"]
neg = captured["negative_kwargs"]
assert pos["hidden_states"].shape[0] == b, "positive branch must be batch-B, not pre-concatenated"
assert neg["hidden_states"].shape[0] == b, "negative branch must be batch-B, not pre-concatenated"
assert pos["timestep"].shape[0] == b
assert torch.equal(pos["encoder_hidden_states"], prompt_embeds.to(dtype))
assert torch.equal(neg["encoder_hidden_states"], negative_embeds.to(dtype))
# ── I2V CFG dispatch ──
class _I2VSpyScheduler:
"""Records the frame count it steps on and mutates non-first frames so
first-frame preservation is observable."""
order = 1
def __init__(self):
self.stepped_frame_counts = []
def step(self, noise_pred, t, latents, return_dict=False, **kwargs):
self.stepped_frame_counts.append(latents.shape[2])
return (latents + 1.0,)
def _bare_i2v_pipeline(transformer, scheduler, guidance_scale):
pipe = SanaImageToVideoPipeline.__new__(SanaImageToVideoPipeline)
pipe.transformer = transformer
pipe.scheduler = scheduler
pipe._interrupt = False
pipe._guidance_scale = guidance_scale
return pipe
def _conditioning_mask(b, frames):
mask = torch.zeros(b, 1, frames, 1, 1)
mask[:, :, 0] = 1
return mask
def test_i2v_cfg1_preserves_first_frame_and_steps_only_tail(monkeypatch):
"""I2V CFG1 must keep the conditioning first frame bit-exact and only run the
scheduler on frames [1:]."""
_set_cfg_world_size(monkeypatch, 1)
b, c, frames = 1, 4, 3
class _SpyTransformer:
dtype = torch.float32
out_channels = 2 * c
def __call__(self, hidden_states, *, encoder_hidden_states, encoder_attention_mask, timestep, return_dict):
return (torch.zeros(hidden_states.shape[0], 2 * c, frames, 4, 4),)
scheduler = _I2VSpyScheduler()
pipe = _bare_i2v_pipeline(_SpyTransformer(), scheduler, guidance_scale=6.0)
latents0 = torch.randn(b, c, frames, 4, 4)
out = pipe.diffuse(
latents=latents0.clone(),
timesteps=torch.tensor([1000.0]),
prompt_embeds=torch.randn(b, 3, 8),
prompt_attention_mask=torch.ones(b, 3),
negative_prompt_embeds=torch.randn(b, 3, 8),
negative_prompt_attention_mask=torch.ones(b, 3),
guidance_scale=6.0,
conditioning_mask=_conditioning_mask(b, frames),
extra_step_kwargs={},
dtype=torch.float32,
output_slice=c,
)
assert torch.equal(out[:, :, :1], latents0[:, :, :1]), "first (conditioning) frame must be unchanged"
assert torch.equal(out[:, :, 1:], latents0[:, :, 1:] + 1.0), "tail frames must be the scheduler output"
assert scheduler.stepped_frame_counts == [frames - 1], "scheduler must step only on frames [1:]"
def test_i2v_cfg2_dispatch_builds_masked_branch_kwargs(monkeypatch):
"""I2V CFG world size 2 must build batch-B per-branch kwargs with the
conditioning-frame timestep zeroed (mask not concatenated) and preserve the
first frame."""
_set_cfg_world_size(monkeypatch, 2)
b, c, frames = 1, 4, 3
captured = {}
def _spy_cfg(**kwargs):
captured.update(kwargs)
return torch.zeros(b, c, frames, 4, 4)
scheduler = _I2VSpyScheduler()
pipe = _bare_i2v_pipeline(transformer=None, scheduler=scheduler, guidance_scale=6.0)
pipe.predict_noise_maybe_with_cfg = _spy_cfg
prompt_embeds = torch.randn(b, 3, 8)
negative_embeds = torch.randn(b, 3, 8)
latents0 = torch.randn(b, c, frames, 4, 4)
mask = _conditioning_mask(b, frames)
out = pipe.diffuse(
latents=latents0.clone(),
timesteps=torch.tensor([1000.0]),
prompt_embeds=prompt_embeds,
prompt_attention_mask=torch.ones(b, 3),
negative_prompt_embeds=negative_embeds,
negative_prompt_attention_mask=torch.zeros(b, 3),
guidance_scale=6.0,
conditioning_mask=mask,
extra_step_kwargs={},
dtype=torch.float32,
output_slice=c,
)
assert captured["do_true_cfg"] is True
assert captured["cfg_normalize"] is False
assert captured["output_slice"] == c
pos = captured["positive_kwargs"]
neg = captured["negative_kwargs"]
assert pos["hidden_states"].shape[0] == b, "positive branch must be batch-B"
assert neg["hidden_states"].shape[0] == b, "negative branch must be batch-B"
assert tuple(pos["timestep"].shape) == tuple(mask.shape), "mask must not be concatenated in CFG2"
assert torch.equal(pos["timestep"][:, :, 0], torch.zeros(b, 1, 1, 1)), "conditioning frame timestep must be 0"
assert torch.equal(pos["encoder_hidden_states"], prompt_embeds)
assert torch.equal(neg["encoder_hidden_states"], negative_embeds)
assert torch.equal(out[:, :, :1], latents0[:, :, :1]), "first frame must be preserved"
def test_diffuse_runs_cfg_parallel_validity_check(monkeypatch, mocker):
"""diffuse must run the CFG-parallel validity check so a scale<=1 or missing
negative prompt warns the user instead of silently wasting a rank."""
_set_cfg_world_size(monkeypatch, 2)
b = 1
pipe = _bare_t2v_pipeline(transformer=None, guidance_scale=6.0)
pipe.predict_noise_maybe_with_cfg = lambda **kwargs: torch.zeros(b, 4, 2, 4, 4)
check = mocker.patch.object(SanaVideoPipeline, "check_cfg_parallel_validity", return_value=True)
pipe.diffuse(
latents=torch.randn(b, 4, 2, 4, 4),
timesteps=torch.tensor([1000.0]),
prompt_embeds=torch.randn(b, 3, 8),
prompt_attention_mask=torch.ones(b, 3),
negative_prompt_embeds=torch.randn(b, 3, 8),
negative_prompt_attention_mask=torch.ones(b, 3),
guidance_scale=6.0,
extra_step_kwargs={},
dtype=torch.float32,
output_slice=None,
)
check.assert_called_once_with(6.0, True)
def test_i2v_diffuse_runs_cfg_parallel_validity_check(monkeypatch, mocker):
"""I2V has its own diffuse; it must also run the CFG-parallel validity check."""
_set_cfg_world_size(monkeypatch, 2)
b, c, frames = 1, 4, 3
pipe = _bare_i2v_pipeline(None, _I2VSpyScheduler(), guidance_scale=6.0)
pipe.predict_noise_maybe_with_cfg = lambda **kwargs: torch.zeros(b, c, frames, 4, 4)
check = mocker.patch.object(SanaVideoPipeline, "check_cfg_parallel_validity", return_value=True)
pipe.diffuse(
latents=torch.randn(b, c, frames, 4, 4),
timesteps=torch.tensor([1000.0]),
prompt_embeds=torch.randn(b, 3, 8),
prompt_attention_mask=torch.ones(b, 3),
negative_prompt_embeds=torch.randn(b, 3, 8),
negative_prompt_attention_mask=torch.ones(b, 3),
guidance_scale=6.0,
conditioning_mask=_conditioning_mask(b, frames),
extra_step_kwargs={},
dtype=torch.float32,
output_slice=c,
)
check.assert_called_once_with(6.0, True)
def test_diffuse_runs_without_initialized_parallel_groups():
"""Single-process runs (unit tests, offline scripts) never initialize the
distributed groups; diffuse must fall back to the serial path instead of
tripping the parallel-state assertion. No world-size patching on purpose."""
class _SpyTransformer:
dtype = torch.float32
def __call__(self, hidden_states, *, encoder_hidden_states, encoder_attention_mask, timestep, return_dict):
return (torch.zeros_like(hidden_states),)
b = 1
pipe = _bare_t2v_pipeline(_SpyTransformer(), guidance_scale=6.0)
out = pipe.diffuse(
latents=torch.randn(b, 4, 2, 4, 4),
timesteps=torch.tensor([1000.0]),
prompt_embeds=torch.randn(b, 3, 8),
prompt_attention_mask=torch.ones(b, 3),
negative_prompt_embeds=torch.randn(b, 3, 8),
negative_prompt_attention_mask=torch.ones(b, 3),
guidance_scale=6.0,
extra_step_kwargs={},
dtype=torch.float32,
output_slice=None,
)
assert out.shape == (b, 4, 2, 4, 4)
def test_diffuse_propagates_group_error_when_parallel_initialized(monkeypatch):
"""Inside an initialized parallel run a failing CFG-group lookup is a real
error and must propagate instead of silently flipping to the serial path."""
import vllm_omni.diffusion.models.sana_video.pipeline_sana_video as t2v
monkeypatch.setattr(t2v, "model_parallel_is_initialized", lambda: True, raising=False)
def _broken_group_lookup():
raise AssertionError("classifier_free_guidance group is not initialized")
monkeypatch.setattr(t2v, "get_classifier_free_guidance_world_size", _broken_group_lookup)
b = 1
pipe = _bare_t2v_pipeline(None, guidance_scale=6.0)
with pytest.raises(AssertionError, match="classifier_free_guidance"):
pipe.diffuse(
latents=torch.randn(b, 4, 2, 4, 4),
timesteps=torch.tensor([1000.0]),
prompt_embeds=torch.randn(b, 3, 8),
prompt_attention_mask=torch.ones(b, 3),
negative_prompt_embeds=torch.randn(b, 3, 8),
negative_prompt_attention_mask=torch.ones(b, 3),
guidance_scale=6.0,
extra_step_kwargs={},
dtype=torch.float32,
output_slice=None,
)
def test_t2v_forwards_eta_and_generator_into_scheduler(monkeypatch):
"""T2V must keep passing eta/generator (extra_step_kwargs) into scheduler.step."""
_set_cfg_world_size(monkeypatch, 1)
captured = {}
class _KwargScheduler:
order = 1
def step(self, noise_pred, t, latents, **kwargs):
captured.update(kwargs)
return (latents,)
class _SpyTransformer:
dtype = torch.float32
def __call__(self, hidden_states, **kwargs):
return (torch.zeros_like(hidden_states),)
b = 1
pipe = _bare_t2v_pipeline(_SpyTransformer(), guidance_scale=6.0)
pipe.scheduler = _KwargScheduler()
generator = torch.Generator().manual_seed(0)
pipe.diffuse(
latents=torch.randn(b, 4, 2, 4, 4),
timesteps=torch.tensor([1000.0]),
prompt_embeds=torch.randn(b, 3, 8),
prompt_attention_mask=torch.ones(b, 3),
negative_prompt_embeds=torch.randn(b, 3, 8),
negative_prompt_attention_mask=torch.ones(b, 3),
guidance_scale=6.0,
extra_step_kwargs={"eta": 0.5, "generator": generator},
dtype=torch.float32,
output_slice=None,
)
assert captured.get("eta") == 0.5
assert captured.get("generator") is generator
assert captured.get("return_dict") is False
@@ -0,0 +1,90 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Topology validation for SANA-Video parallelism.
The native pipeline only supports tensor/CFG parallel of size 1 or 2 and rejects
sequence parallel and text-encoder TP before any large weight is allocated.
"""
from types import SimpleNamespace
import pytest
from vllm_omni.diffusion.models.sana_video.pipeline_sana_video import SanaVideoPipeline
from vllm_omni.diffusion.models.sana_video.pipeline_sana_video_i2v import SanaImageToVideoPipeline
from vllm_omni.diffusion.models.sana_video.transformer_sana_video import (
validate_sana_video_parallel_config,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
def _pc(
tp: int = 1,
cfg: int = 1,
sp: int | None = None,
text_tp: int = 1,
pp: int = 1,
hsdp: bool = False,
) -> SimpleNamespace:
return SimpleNamespace(
tensor_parallel_size=tp,
cfg_parallel_size=cfg,
sequence_parallel_size=sp,
text_encoder_tp_size=text_tp,
pipeline_parallel_size=pp,
use_hsdp=hsdp,
)
@pytest.mark.parametrize("tp", [1, 2])
def test_supported_tp_sizes_pass(tp: int) -> None:
validate_sana_video_parallel_config(_pc(tp=tp))
@pytest.mark.parametrize("tp", [3, 4, 8])
def test_unsupported_tp_sizes_rejected(tp: int) -> None:
with pytest.raises(NotImplementedError, match="tensor_parallel_size"):
validate_sana_video_parallel_config(_pc(tp=tp))
def test_cfg3_rejected() -> None:
with pytest.raises(NotImplementedError, match="cfg_parallel_size"):
validate_sana_video_parallel_config(_pc(cfg=3))
def test_sequence_parallel_rejected() -> None:
with pytest.raises(NotImplementedError, match="[Ss]equence parallel"):
validate_sana_video_parallel_config(_pc(sp=2))
def test_pipeline_parallel_rejected() -> None:
with pytest.raises(NotImplementedError, match="pipeline parallel"):
validate_sana_video_parallel_config(_pc(pp=2))
def test_hsdp_rejected() -> None:
with pytest.raises(NotImplementedError, match="HSDP"):
validate_sana_video_parallel_config(_pc(hsdp=True))
def test_text_encoder_tp_rejected() -> None:
with pytest.raises(NotImplementedError, match="text encoder"):
validate_sana_video_parallel_config(_pc(text_tp=2))
def test_tp2_cfg2_combo_passes() -> None:
validate_sana_video_parallel_config(_pc(tp=2, cfg=2))
@pytest.mark.parametrize("pipeline_cls", [SanaVideoPipeline, SanaImageToVideoPipeline])
def test_pipeline_rejects_unsupported_topology_before_loading(pipeline_cls, mocker) -> None:
"""The pipeline must reject an unsupported topology before it loads weights."""
load = mocker.patch.object(SanaVideoPipeline, "_load_components")
od_config = SimpleNamespace(parallel_config=_pc(tp=3))
with pytest.raises(NotImplementedError, match="tensor_parallel_size"):
pipeline_cls(od_config=od_config)
load.assert_not_called()
@@ -1,11 +1,37 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import os
import pytest
import torch
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
@pytest.fixture(autouse=True)
def _init_distributed(monkeypatch):
"""The native transformer uses vLLM parallel linear layers, which require a
tensor-parallel group; initialize a single-process group for CPU tests."""
from vllm.distributed.parallel_state import (
cleanup_dist_env_and_memory,
init_distributed_environment,
initialize_model_parallel,
)
from vllm.model_executor.layers.utils import default_unquantized_gemm
monkeypatch.setattr(
"vllm.model_executor.layers.linear.dispatch_unquantized_gemm",
lambda: default_unquantized_gemm,
)
os.environ.setdefault("MASTER_ADDR", "localhost")
os.environ.setdefault("MASTER_PORT", "29501")
init_distributed_environment(world_size=1, rank=0, local_rank=0, distributed_init_method="env://")
initialize_model_parallel()
yield
cleanup_dist_env_and_memory()
_TINY_CONFIG = {
"in_channels": 4,
"out_channels": 4,
@@ -323,6 +349,7 @@ def test_tiny_transformer_matches_diffusers_and_frozen_output():
from vllm_omni.diffusion.attention.layer import Attention
from vllm_omni.diffusion.models.sana_video import SanaVideoTransformer3DModel
from vllm_omni.diffusion.models.sana_video.transformer_sana_video import (
SanaDistributedRMSNorm,
SanaLinearAttention,
SanaRMSNorm,
SanaVideoTransformerConfig,
@@ -342,10 +369,10 @@ def test_tiny_transformer_matches_diffusers_and_frozen_output():
assert isinstance(block.attn1, SanaLinearAttention)
assert isinstance(block.attn2.attn, Attention)
assert block.attn2.attn.role == "cross"
assert isinstance(block.attn1.norm_q, SanaRMSNorm)
assert isinstance(block.attn1.norm_k, SanaRMSNorm)
assert isinstance(block.attn2.norm_q, SanaRMSNorm)
assert isinstance(block.attn2.norm_k, SanaRMSNorm)
assert isinstance(block.attn1.norm_q, SanaDistributedRMSNorm)
assert isinstance(block.attn1.norm_k, SanaDistributedRMSNorm)
assert isinstance(block.attn2.norm_q, SanaDistributedRMSNorm)
assert isinstance(block.attn2.norm_k, SanaDistributedRMSNorm)
assert isinstance(model.caption_norm, SanaRMSNorm)
torch.manual_seed(11)
@@ -0,0 +1,445 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""TP unit tests for SANA-Video: distributed RMSNorm, sharded attention and
sharded GLUMB temporal conv.
CPU-only. The TP world size and the collective are mocked so a single process
simulates the shard/reduce logic a real multi-rank run depends on.
"""
import os
import pytest
import torch
from vllm_omni.diffusion.models.sana_video.transformer_sana_video import (
GLUMBTempConv,
SanaCrossAttention,
SanaDistributedRMSNorm,
SanaLinearAttention,
SanaRMSNorm,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
_MODULE = "vllm_omni.diffusion.models.sana_video.transformer_sana_video"
@pytest.fixture
def tp1_group():
"""Real single-process TP group so the parallel linear layers construct and
run on CPU at tensor_parallel_size=1."""
from vllm.distributed.parallel_state import (
cleanup_dist_env_and_memory,
init_distributed_environment,
initialize_model_parallel,
)
os.environ.setdefault("MASTER_ADDR", "localhost")
os.environ.setdefault("MASTER_PORT", "29501")
init_distributed_environment(world_size=1, rank=0, local_rank=0, distributed_init_method="env://")
initialize_model_parallel()
yield
cleanup_dist_env_and_memory()
@pytest.fixture
def force_default_gemm(monkeypatch):
"""Force CPU-compatible GEMM dispatch for the parallel linear layers."""
from vllm.model_executor.layers.utils import default_unquantized_gemm
monkeypatch.setattr(
"vllm.model_executor.layers.linear.dispatch_unquantized_gemm",
lambda: default_unquantized_gemm,
)
def test_distributed_rms_norm_tp1_matches_sana_rms_norm(mocker) -> None:
"""TP1 distributed norm must be bit-identical to the checkpoint SanaRMSNorm."""
dim = 24
torch.manual_seed(0)
dense = SanaRMSNorm(dim, eps=1e-5, elementwise_affine=True)
dense.weight.data.normal_()
dist = SanaDistributedRMSNorm(dim, eps=1e-5)
dist.weight.data.copy_(dense.weight.data)
x = torch.randn(2, 5, dim)
mocker.patch(f"{_MODULE}.get_tensor_model_parallel_world_size", return_value=1)
out = dist(x)
ref = dense(x)
torch.testing.assert_close(out, ref, rtol=0, atol=0)
def test_distributed_rms_norm_tp1_bf16_weight_matches_sana_rms_norm(mocker) -> None:
"""bf16 weight must follow SanaRMSNorm's cast-to-weight-dtype branch.
An unconditional cast back to the input dtype would diverge here; this pins
the fp16/bf16-only cast that SanaRMSNorm performs.
"""
dim = 24
torch.manual_seed(0)
dense = SanaRMSNorm(dim, eps=1e-5, elementwise_affine=True)
dense.weight.data.normal_()
dense.weight.data = dense.weight.data.to(torch.bfloat16)
dist = SanaDistributedRMSNorm(dim, eps=1e-5)
dist.weight.data = dense.weight.data.clone()
x = torch.randn(2, 5, dim, dtype=torch.bfloat16)
mocker.patch(f"{_MODULE}.get_tensor_model_parallel_world_size", return_value=1)
out = dist(x)
ref = dense(x)
assert out.dtype == torch.bfloat16
torch.testing.assert_close(out, ref, rtol=0, atol=0)
def test_distributed_rms_norm_tp2_shards_aggregate_to_dense(mocker) -> None:
"""Two TP2 shards must reproduce dense SanaRMSNorm.
The RMS must use the full (unsharded) hidden size: each rank reduces its
local sum-of-squares across ranks, not a per-shard mean over its half.
"""
full_dim, tp_size = 24, 2
half = full_dim // tp_size
torch.manual_seed(0)
dense = SanaRMSNorm(full_dim, eps=1e-5, elementwise_affine=True)
dense.weight.data.normal_()
dist0 = SanaDistributedRMSNorm(half, eps=1e-5)
dist1 = SanaDistributedRMSNorm(half, eps=1e-5)
dist0.weight.data.copy_(dense.weight.data[:half])
dist1.weight.data.copy_(dense.weight.data[half:])
x = torch.randn(2, 5, full_dim)
x0, x1 = x[..., :half], x[..., half:]
# A real all-reduce returns the sum of every rank's local sum-of-squares.
global_sum_sq = x.to(torch.float32).pow(2).sum(-1, keepdim=True)
def fake_all_reduce(tensor: torch.Tensor) -> torch.Tensor:
return global_sum_sq
mocker.patch(f"{_MODULE}.get_tensor_model_parallel_world_size", return_value=tp_size)
mocker.patch(f"{_MODULE}.tensor_model_parallel_all_reduce", side_effect=fake_all_reduce)
out0 = dist0(x0)
out1 = dist1(x1)
out = torch.cat([out0, out1], dim=-1)
ref = dense(x)
torch.testing.assert_close(out, ref, rtol=0, atol=0)
def test_distributed_rms_norm_weight_loader_shards_dim0(mocker) -> None:
"""Each rank loads its own dim-0 slice of the global affine weight."""
full_dim, tp_size, rank = 24, 2, 1
half = full_dim // tp_size
norm = SanaDistributedRMSNorm(half, eps=1e-5)
full_weight = torch.randn(full_dim)
mocker.patch(f"{_MODULE}.get_tensor_model_parallel_world_size", return_value=tp_size)
mocker.patch(f"{_MODULE}.get_tensor_model_parallel_rank", return_value=rank)
norm.weight.weight_loader(norm.weight, full_weight)
torch.testing.assert_close(norm.weight.data, full_weight[rank * half : (rank + 1) * half])
def test_distributed_rms_norm_weight_loader_tp1_copies_full() -> None:
dim = 24
norm = SanaDistributedRMSNorm(dim, eps=1e-5)
full_weight = torch.randn(dim)
norm.weight.weight_loader(norm.weight, full_weight)
torch.testing.assert_close(norm.weight.data, full_weight)
_SELF_ATTN_GOLDEN = torch.tensor(
[
5.0944157,
-16.9981823,
55.4457436,
42.5174408,
-9.3055859,
19.3744793,
-47.4345360,
-5.8110709,
-14.0679598,
-22.3440742,
-8.3834534,
8.9109459,
]
)
def _fill_by_name(module: torch.nn.Module) -> None:
"""Deterministic per-parameter fill that is stable across TP refactors."""
torch.manual_seed(0)
for _, param in sorted(module.named_parameters()):
param.data.normal_()
def _self_attn_inputs(seq_len: int, dim: int, head_dim: int):
torch.manual_seed(1)
x = torch.randn(2, seq_len, dim)
freqs_cos = torch.randn(1, seq_len, 1, head_dim)
freqs_sin = torch.randn(1, seq_len, 1, head_dim)
return x, (freqs_cos, freqs_sin)
def test_self_attention_tp1_matches_golden_and_keeps_param_names(tp1_group, force_default_gemm) -> None:
"""TP1 self-attention stays bit-identical to PR1 and preserves checkpoint keys."""
dim, num_heads, head_dim, seq_len = 24, 4, 6, 8
attn = SanaLinearAttention(
dim=dim, num_heads=num_heads, head_dim=head_dim, dropout=0.0, bias=False, qk_norm="rms_norm_across_heads"
)
attn.eval()
_fill_by_name(attn)
x, rotary_emb = _self_attn_inputs(seq_len, dim, head_dim)
with torch.no_grad():
out = attn(x, rotary_emb=rotary_emb)
assert {name for name, _ in attn.named_parameters()} == {
"to_q.weight",
"to_k.weight",
"to_v.weight",
"norm_q.weight",
"norm_k.weight",
"to_out.0.weight",
"to_out.0.bias",
}
torch.testing.assert_close(out.flatten()[:12], _SELF_ATTN_GOLDEN, rtol=1e-5, atol=1e-5)
def _mock_tp2(mocker):
"""Mock a TP world size of 2 for module construction (no real group)."""
mocker.patch(f"{_MODULE}.get_tensor_model_parallel_world_size", return_value=2)
mocker.patch(
"vllm.model_executor.layers.linear.get_tensor_model_parallel_world_size",
return_value=2,
)
tp_group = mocker.MagicMock()
tp_group.world_size = 2
mocker.patch("vllm.distributed.parallel_state.get_tp_group", return_value=tp_group)
def test_self_attention_tp2_shards_projections_and_heads(mocker) -> None:
"""TP2 splits heads column-wise (q/k/v) and the output projection row-wise."""
dim, num_heads, head_dim = 24, 4, 6
inner_dim = num_heads * head_dim
_mock_tp2(mocker)
attn = SanaLinearAttention(
dim=dim, num_heads=num_heads, head_dim=head_dim, dropout=0.0, bias=False, qk_norm="rms_norm_across_heads"
)
assert attn.heads == num_heads // 2
assert attn.to_q.weight.shape == (inner_dim // 2, dim)
assert attn.to_k.weight.shape == (inner_dim // 2, dim)
assert attn.to_v.weight.shape == (inner_dim // 2, dim)
assert attn.to_out[0].weight.shape == (dim, inner_dim // 2)
assert attn.norm_q.weight.shape == (inner_dim // 2,)
assert attn.norm_k.weight.shape == (inner_dim // 2,)
def test_cross_attention_tp2_shards_projections_and_heads(mocker) -> None:
"""TP2 cross-attention shards q/k/v column-wise, to_out row-wise, heads locally."""
dim, cross_dim, num_heads, head_dim = 24, 16, 4, 6
inner_dim = num_heads * head_dim
_mock_tp2(mocker)
attn = SanaCrossAttention(
dim=dim,
cross_attention_dim=cross_dim,
num_heads=num_heads,
head_dim=head_dim,
dropout=0.0,
bias=False,
out_bias=True,
qk_norm="rms_norm_across_heads",
prefix="attn2",
)
assert attn.heads == num_heads // 2
assert attn.to_q.weight.shape == (inner_dim // 2, dim)
assert attn.to_k.weight.shape == (inner_dim // 2, cross_dim)
assert attn.to_v.weight.shape == (inner_dim // 2, cross_dim)
assert attn.to_out[0].weight.shape == (dim, inner_dim // 2)
assert isinstance(attn.norm_q, SanaDistributedRMSNorm)
assert attn.norm_q.weight.shape == (inner_dim // 2,)
_TINY_TRANSFORMER_CONFIG = {
"in_channels": 4,
"out_channels": 4,
"num_attention_heads": 2,
"attention_head_dim": 12,
"num_layers": 1,
"num_cross_attention_heads": 2,
"cross_attention_head_dim": 12,
"cross_attention_dim": 24,
"caption_channels": 8,
"mlp_ratio": 2.0,
"patch_size": (1, 2, 2),
"sample_size": 4,
"rope_max_seq_len": 32,
}
def test_tp2_load_weights_shards_full_checkpoint_without_missing(mocker) -> None:
"""The full checkpoint loads under TP2 via weight_loaders with no missing/unexpected keys."""
from diffusers import SanaVideoTransformer3DModel as DiffusersTransformer
from vllm_omni.diffusion.models.sana_video import SanaVideoTransformer3DModel
_mock_tp2(mocker)
mocker.patch(f"{_MODULE}.get_tensor_model_parallel_rank", return_value=0)
mocker.patch("vllm.model_executor.layers.linear.get_tensor_model_parallel_rank", return_value=0)
reference = DiffusersTransformer(**_TINY_TRANSFORMER_CONFIG)
model = SanaVideoTransformer3DModel(**_TINY_TRANSFORMER_CONFIG)
loaded = model.load_weights(list(reference.state_dict().items()))
assert loaded == set(model.state_dict())
# ── GLUMB temporal conv TP ──
# hidden_channels = expand_ratio * in_channels = 8, so conv_inverted emits 2h = 16.
_GLUMB_IN, _GLUMB_OUT, _GLUMB_RATIO = 4, 4, 2.0
_GLUMB_GOLDEN = torch.tensor(
[
51.4757385,
-10.9912930,
-4.1482553,
-26.1580830,
-19.7727261,
17.9901562,
-1.6007471,
13.3993073,
-8.6459389,
-10.4008112,
-7.4962354,
1.5446399,
]
)
def _glumb_inputs() -> torch.Tensor:
torch.manual_seed(1)
return torch.randn(1, 2, 3, 3, _GLUMB_IN) # [B, F, H, W, C]
def test_glumb_tp1_matches_golden_and_keeps_param_names(tp1_group) -> None:
"""TP1 GLUMB stays bit-identical to the PR1 conv output and preserves keys."""
glumb = GLUMBTempConv(_GLUMB_IN, _GLUMB_OUT, _GLUMB_RATIO, norm_type=None, residual_connection=False)
glumb.eval()
_fill_by_name(glumb)
x = _glumb_inputs()
with torch.no_grad():
out = glumb(x)
assert {name for name, _ in glumb.named_parameters()} == {
"conv_inverted.weight",
"conv_inverted.bias",
"conv_depth.weight",
"conv_depth.bias",
"conv_point.weight",
"conv_temp.weight",
}
torch.testing.assert_close(out.flatten()[:12], _GLUMB_GOLDEN, rtol=1e-5, atol=1e-5)
def _random_glumb_checkpoint(hidden_channels: int) -> dict[str, torch.Tensor]:
"""Checkpoint-format (nn.Conv2d) GLUMB weights with per-segment asymmetry.
conv_inverted / conv_depth carry two h-channel segments (value then gate).
Filling the gate half from a different distribution than the value half is
what exposes a naive contiguous dim-0 split: that bug would place the whole
value segment on rank 0 and the whole gate segment on rank 1, so each rank's
local chunk(2) would slice value-vs-value instead of value-vs-gate.
"""
torch.manual_seed(0)
h = hidden_channels
value_w = torch.randn(h, _GLUMB_IN, 1, 1)
gate_w = torch.randn(h, _GLUMB_IN, 1, 1) * 3.0 + 5.0
value_b = torch.randn(h)
gate_b = torch.randn(h) * 3.0 + 5.0
value_dw = torch.randn(h, 1, 3, 3)
gate_dw = torch.randn(h, 1, 3, 3) * 3.0 + 5.0
value_db = torch.randn(h)
gate_db = torch.randn(h) * 3.0 + 5.0
return {
"conv_inverted.weight": torch.cat([value_w, gate_w], dim=0),
"conv_inverted.bias": torch.cat([value_b, gate_b], dim=0),
"conv_depth.weight": torch.cat([value_dw, gate_dw], dim=0),
"conv_depth.bias": torch.cat([value_db, gate_db], dim=0),
"conv_point.weight": torch.randn(_GLUMB_OUT, h, 1, 1),
"conv_temp.weight": torch.randn(_GLUMB_OUT, _GLUMB_OUT, 3, 1),
}
def _mock_glumb_tp(mocker, world_size: int, rank: int) -> None:
"""Mock a TP group of ``world_size`` at ``rank`` with an identity all-reduce,
so a single process can build and run one rank's shard in isolation."""
mocker.patch(f"{_MODULE}.get_tensor_model_parallel_world_size", return_value=world_size)
mocker.patch(f"{_MODULE}.get_tensor_model_parallel_rank", return_value=rank)
# Identity conv_point all-reduce returns each rank's local partial for the
# test to sum.
mocker.patch(f"{_MODULE}.tensor_model_parallel_all_reduce", side_effect=lambda t: t)
def _load_glumb_checkpoint(glumb: GLUMBTempConv, checkpoint: dict[str, torch.Tensor]) -> None:
"""Load like AutoWeightsLoader: custom weight_loader when present (the sharded
convs), plain copy for replicated params (conv_temp)."""
params = dict(glumb.named_parameters())
for name, tensor in checkpoint.items():
param = params[name]
loader = getattr(param, "weight_loader", None)
if loader is None:
param.data.copy_(tensor)
else:
loader(param, tensor)
def test_glumb_tp2_shards_aggregate_to_dense(mocker) -> None:
"""The merged value/gate two-segment shard must aggregate to the dense conv.
Everything after conv_point (temporal conv + residual) is linear and
bias-free, so summing each rank's full forward (with the conv_point
all-reduce mocked to identity) reproduces the replicated dense output.
"""
hidden_channels = int(_GLUMB_RATIO * _GLUMB_IN)
checkpoint = _random_glumb_checkpoint(hidden_channels)
x = _glumb_inputs()
_mock_glumb_tp(mocker, world_size=1, rank=0)
dense = GLUMBTempConv(_GLUMB_IN, _GLUMB_OUT, _GLUMB_RATIO, norm_type=None, residual_connection=False)
dense.eval()
_load_glumb_checkpoint(dense, checkpoint)
with torch.no_grad():
ref = dense(x)
partials = []
for rank in (0, 1):
_mock_glumb_tp(mocker, world_size=2, rank=rank)
shard = GLUMBTempConv(_GLUMB_IN, _GLUMB_OUT, _GLUMB_RATIO, norm_type=None, residual_connection=False)
shard.eval()
_load_glumb_checkpoint(shard, checkpoint)
with torch.no_grad():
partials.append(shard(x))
aggregated = partials[0] + partials[1]
torch.testing.assert_close(aggregated, ref, rtol=1e-5, atol=1e-5)