|
| 1 | +""" |
| 2 | +Equivalence test for batched-CFG vs. sequential-CFG in |
| 3 | +``DenoisingStage.forward``. |
| 4 | +
|
| 5 | +The transformer stub is a pure deterministic function of |
| 6 | +``(hidden_states, encoder_hidden_states[0].mean(), timestep)``. With no |
| 7 | +batch-coupled layers and a no-op scheduler step, running the same |
| 8 | +denoise loop with ``use_batched_cfg=True`` and ``use_batched_cfg=False`` |
| 9 | +must produce identical final latents. |
| 10 | +
|
| 11 | +This catches the easy ways to break the port — wrong cat order |
| 12 | +(neg vs pos), wrong chunk(2) split direction, broken CFG combine math, |
| 13 | +list-of-encoders convention drops — without needing a real DiT. |
| 14 | +""" |
| 15 | +import types |
| 16 | +from typing import Any |
| 17 | + |
| 18 | +import pytest |
| 19 | +import torch |
| 20 | + |
| 21 | +from fastvideo.pipelines.pipeline_batch_info import ForwardBatch |
| 22 | +from fastvideo.pipelines.stages.denoising import DenoisingStage |
| 23 | + |
| 24 | + |
| 25 | +class _StubTransformer(torch.nn.Module): |
| 26 | + """Deterministic per-sample noise prediction. |
| 27 | +
|
| 28 | + Output element-wise depends only on the per-batch-item slice of |
| 29 | + ``hidden_states``, the per-batch-item ``encoder_hidden_states`` mean, |
| 30 | + and the per-batch-item ``timestep``. No cross-batch coupling, so |
| 31 | + ``f(cat([uncond, cond]))`` chunked back equals |
| 32 | + ``[f(uncond), f(cond)]`` bit-for-bit. |
| 33 | + """ |
| 34 | + |
| 35 | + def __init__(self) -> None: |
| 36 | + super().__init__() |
| 37 | + self.config = types.SimpleNamespace(use_meanflow=False) |
| 38 | + self.register_parameter("_p", torch.nn.Parameter(torch.zeros(1))) |
| 39 | + |
| 40 | + def forward(self, |
| 41 | + hidden_states: torch.Tensor, |
| 42 | + encoder_hidden_states: torch.Tensor | list[torch.Tensor], |
| 43 | + timestep: torch.Tensor, |
| 44 | + guidance: torch.Tensor | None = None, |
| 45 | + encoder_hidden_states_2: Any = None, |
| 46 | + encoder_attention_mask: Any = None, |
| 47 | + encoder_hidden_states_image: Any = None, |
| 48 | + mask_strategy: Any = None, |
| 49 | + mouse_cond: torch.Tensor | None = None, |
| 50 | + keyboard_cond: torch.Tensor | None = None, |
| 51 | + c2ws_plucker_emb: torch.Tensor | None = None, |
| 52 | + camera_states: torch.Tensor | None = None, |
| 53 | + timestep_r: torch.Tensor | None = None) -> torch.Tensor: |
| 54 | + if encoder_hidden_states is not None and not isinstance(encoder_hidden_states, torch.Tensor): |
| 55 | + encoder_hidden_states = encoder_hidden_states[0] |
| 56 | + ctx = encoder_hidden_states.mean(dim=(1, 2)).view(-1, 1, 1, 1, 1) |
| 57 | + ts = timestep.reshape(-1, 1, 1, 1, 1).to(hidden_states.dtype) |
| 58 | + return hidden_states * 0.1 + ctx.to(hidden_states.dtype) * 0.5 + ts * 1e-4 |
| 59 | + |
| 60 | + |
| 61 | +class _IdentityScheduler: |
| 62 | + """No-op scheduler: ``step`` returns the input ``latents`` unchanged. |
| 63 | +
|
| 64 | + Keeps the per-step noise_pred isolated so the final-latents |
| 65 | + comparison reduces to: did the cond/uncond combine math match? |
| 66 | + """ |
| 67 | + |
| 68 | + num_train_timesteps = 1000 |
| 69 | + order = 1 |
| 70 | + |
| 71 | + def scale_model_input(self, sample: torch.Tensor, t: torch.Tensor) -> torch.Tensor: |
| 72 | + return sample |
| 73 | + |
| 74 | + def step(self, noise_pred: torch.Tensor, t: torch.Tensor, latents: torch.Tensor, |
| 75 | + return_dict: bool = True) -> tuple[torch.Tensor]: |
| 76 | + # Return latents - noise_pred so the per-step contribution is |
| 77 | + # observable in the final latents (otherwise CFG differences |
| 78 | + # would be invisible). |
| 79 | + return (latents - noise_pred, ) |
| 80 | + |
| 81 | + |
| 82 | +def _make_fastvideo_args(use_batched_cfg: bool) -> Any: |
| 83 | + return types.SimpleNamespace( |
| 84 | + model_loaded={"transformer": True}, |
| 85 | + pipeline_config=types.SimpleNamespace( |
| 86 | + embedded_cfg_scale=None, |
| 87 | + ti2v_task=False, |
| 88 | + vae_config=types.SimpleNamespace(arch_config=types.SimpleNamespace(scale_factor_temporal=1, |
| 89 | + scale_factor_spatial=1)), |
| 90 | + dit_config=types.SimpleNamespace(boundary_ratio=None, |
| 91 | + arch_config=types.SimpleNamespace(patch_size=(1, 1, 1))), |
| 92 | + ), |
| 93 | + disable_autocast=True, |
| 94 | + use_batched_cfg=use_batched_cfg, |
| 95 | + dit_cpu_offload=False, |
| 96 | + dit_layerwise_offload=False, |
| 97 | + use_fsdp_inference=False, |
| 98 | + VSA_sparsity=0.0, |
| 99 | + moba_config={}, |
| 100 | + ) |
| 101 | + |
| 102 | + |
| 103 | +def _make_batch(do_cfg: bool) -> ForwardBatch: |
| 104 | + torch.manual_seed(0) |
| 105 | + latents = torch.randn(1, 4, 2, 4, 4) |
| 106 | + # Distinct pos / neg embeddings so the CFG combine actually matters. |
| 107 | + prompt_embeds = [torch.randn(1, 6, 8)] |
| 108 | + negative_prompt_embeds = [torch.randn(1, 6, 8)] |
| 109 | + timesteps = torch.tensor([100, 50, 10], dtype=torch.float32) |
| 110 | + return ForwardBatch( |
| 111 | + data_type="video", |
| 112 | + latents=latents, |
| 113 | + prompt_embeds=prompt_embeds, |
| 114 | + negative_prompt_embeds=negative_prompt_embeds if do_cfg else None, |
| 115 | + do_classifier_free_guidance=do_cfg, |
| 116 | + timesteps=timesteps, |
| 117 | + num_inference_steps=len(timesteps), |
| 118 | + guidance_scale=7.5, |
| 119 | + guidance_scale_2=7.5, |
| 120 | + guidance_rescale=0.0, |
| 121 | + ) |
| 122 | + |
| 123 | + |
| 124 | +def _make_stage() -> DenoisingStage: |
| 125 | + """Bypass ``__init__`` (which calls into ``get_attn_backend``) and |
| 126 | + set the fields ``forward`` actually reads.""" |
| 127 | + stage = DenoisingStage.__new__(DenoisingStage) |
| 128 | + torch.nn.Module.__init__(stage) |
| 129 | + stage.transformer = _StubTransformer() |
| 130 | + stage.transformer_2 = None |
| 131 | + stage.scheduler = _IdentityScheduler() |
| 132 | + stage.vae = None |
| 133 | + stage.pipeline = None |
| 134 | + # attn_backend is checked only against VSA / VMOBA backend types in |
| 135 | + # forward; any sentinel object that doesn't match those works. |
| 136 | + stage.attn_backend = object() |
| 137 | + return stage |
| 138 | + |
| 139 | + |
| 140 | +def _run(use_batched_cfg: bool, do_cfg: bool = True) -> torch.Tensor: |
| 141 | + stage = _make_stage() |
| 142 | + batch = _make_batch(do_cfg=do_cfg) |
| 143 | + fastvideo_args = _make_fastvideo_args(use_batched_cfg=use_batched_cfg) |
| 144 | + out = stage.forward(batch, fastvideo_args) |
| 145 | + assert out.latents is not None |
| 146 | + return out.latents |
| 147 | + |
| 148 | + |
| 149 | +def test_batched_cfg_matches_sequential_cfg() -> None: |
| 150 | + """Core equivalence: batched and sequential paths must produce |
| 151 | + identical final latents on a pure deterministic transformer.""" |
| 152 | + latents_batched = _run(use_batched_cfg=True, do_cfg=True) |
| 153 | + latents_sequential = _run(use_batched_cfg=False, do_cfg=True) |
| 154 | + assert torch.equal(latents_batched, latents_sequential), ( |
| 155 | + f"batched-CFG diverged from sequential-CFG; " |
| 156 | + f"max abs diff = {(latents_batched - latents_sequential).abs().max()}") |
| 157 | + |
| 158 | + |
| 159 | +def test_batched_cfg_flag_off_is_legacy_path() -> None: |
| 160 | + """With CFG off, both paths reduce to a single forward and must |
| 161 | + match regardless of the ``use_batched_cfg`` flag.""" |
| 162 | + a = _run(use_batched_cfg=True, do_cfg=False) |
| 163 | + b = _run(use_batched_cfg=False, do_cfg=False) |
| 164 | + assert torch.equal(a, b) |
| 165 | + |
| 166 | + |
| 167 | +@pytest.mark.parametrize("disabling_field, value", [ |
| 168 | + ("video_latent", torch.zeros(1, 4, 2, 4, 4)), |
| 169 | + ("image_latent", torch.zeros(1, 4, 2, 4, 4)), |
| 170 | + ("image_embeds", [torch.zeros(1, 4)]), |
| 171 | + ("mouse_cond", torch.zeros(1, 2, 2)), |
| 172 | + ("keyboard_cond", torch.zeros(1, 2, 4)), |
| 173 | + ("c2ws_plucker_emb", torch.zeros(1, 6, 2, 4, 4)), |
| 174 | + ("camera_states", torch.zeros(1, 2, 6, 4, 4)), |
| 175 | +]) |
| 176 | +def test_batched_cfg_autodetect_disables_on_conditioning(disabling_field: str, value: Any) -> None: |
| 177 | + """When V2V/I2V/action/camera conditioning is present, the batched |
| 178 | + path must auto-disable. We verify by checking that the result with |
| 179 | + ``use_batched_cfg=True`` equals the result with the flag forced off |
| 180 | + — i.e. the autodetect routed us through the sequential path.""" |
| 181 | + stage = _make_stage() |
| 182 | + fastvideo_args_batched = _make_fastvideo_args(use_batched_cfg=True) |
| 183 | + fastvideo_args_seq = _make_fastvideo_args(use_batched_cfg=False) |
| 184 | + |
| 185 | + def _batch_with_field() -> ForwardBatch: |
| 186 | + b = _make_batch(do_cfg=True) |
| 187 | + setattr(b, disabling_field, value) |
| 188 | + return b |
| 189 | + |
| 190 | + # The V2V / I2V latent paths trigger extra cat()s in the |
| 191 | + # transformer call that the stub can't simulate (the stub returns a |
| 192 | + # noise tensor shaped like the input, so a wider latent_model_input |
| 193 | + # would just propagate). For the autodetect test we only care that |
| 194 | + # the *gate* picks the sequential branch — we run with the flag in |
| 195 | + # both states and assert equality, even when video/image latents |
| 196 | + # trip the inner cat (the cat is the same on both runs). |
| 197 | + out_a = stage.forward(_batch_with_field(), fastvideo_args_batched) |
| 198 | + # Fresh stage to avoid state carry-over. |
| 199 | + stage_b = _make_stage() |
| 200 | + out_b = stage_b.forward(_batch_with_field(), fastvideo_args_seq) |
| 201 | + assert out_a.latents is not None and out_b.latents is not None |
| 202 | + assert torch.equal(out_a.latents, out_b.latents) |
0 commit comments