Skip to content

Commit 78a6373

Browse files
committed
[test]: unit tests for batched-CFG equivalence
Stub-transformer equivalence between batched and sequential paths, CFG-off equivalence, autodetect-off on 7 conditioning fields (V2V, I2V, TI2V, action, camera, image embeds, etc.).
1 parent de53bfc commit 78a6373

1 file changed

Lines changed: 202 additions & 0 deletions

File tree

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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

Comments
 (0)