Skip to content

Commit 98db574

Browse files
committed
[perf]: batched classifier-free guidance for Wan/Cosmos
Run cond + uncond as a single batch=2 DiT forward per denoise step instead of two sequential batch=1 forwards. Composable with Adaptive Guidance (hao-ai-lab#1372). Mechanism is bit-equivalent to sequential CFG on H100 FA3 eager (SSIM=1.000000, 5 prompts, Wan 14B 720x1280x49f/30steps). See PR body for full validation matrix across L40S/H100 and eager/compile. Changes: - fastvideo_args.py: new use_batched_cfg: bool = True field + --use-batched-cfg CLI. Auto-fallback to sequential when V2V/I2V/ TI2V/action/camera conditioning is present (those carry batch=1 conditioning tensors out of scope for this PR). - entrypoints/video_generator.py: add use_batched_cfg to _FROM_PRETRAINED_CONVENIENCE_KWARGS so users don't see the DeprecationWarning from the legacy kwarg path. - pipelines/stages/denoising.py: gated batched branch in main DenoisingStage.forward. Cats [neg, pos] along batch dim with shape-match defensive fallback, single forward, chunk(2), existing CFG-combine + guidance_rescale math reused. Other DenoisingStage subclasses (Cosmos25, Dmd, ...) unchanged. Sequential CFG path preserved bit-for-bit for non-batched callers. Other DiTs (HunyuanVideo, LongCat, ...) unaffected.
1 parent afdb6fb commit 98db574

3 files changed

Lines changed: 170 additions & 40 deletions

File tree

fastvideo/entrypoints/video_generator.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
"pin_cpu_memory",
9595
"enable_torch_compile",
9696
"torch_compile_kwargs",
97+
"use_batched_cfg",
9798
"output_type",
9899
"nvfp4_fa4",
99100
})

fastvideo/fastvideo_args.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,14 @@ class FastVideoArgs:
153153

154154
disable_autocast: bool = False
155155

156+
# Batched classifier-free guidance: run cond + uncond as a single
157+
# batch=2 DiT forward per denoise step instead of two sequential
158+
# batch=1 forwards. Output-identical (SSIM=1.0); reduces per-step
159+
# launch + memory-traffic overhead. Disable to fall back to the
160+
# legacy sequential path (e.g. for debugging or for entry points
161+
# that aren't covered by the batched path yet).
162+
use_batched_cfg: bool = True
163+
156164
# VSA parameters
157165
VSA_sparsity: float = 0.0 # inference/validation sparsity
158166

@@ -538,6 +546,17 @@ def add_cli_args(parser: FlexibleArgumentParser) -> FlexibleArgumentParser:
538546
help="Use torch.compile to speed up DiT inference." +
539547
"However, will likely cause precision drifts. See (https://github.com/pytorch/pytorch/issues/145213)",
540548
)
549+
parser.add_argument(
550+
"--use-batched-cfg",
551+
action=StoreBoolean,
552+
default=FastVideoArgs.use_batched_cfg,
553+
help="Run classifier-free guidance as a single batch=2 DiT "
554+
"forward per step (cond+uncond stacked) instead of two "
555+
"sequential batch=1 forwards. Output-identical at SSIM=1.0; "
556+
"reduces per-step launch + memory overhead. Falls back to "
557+
"the sequential path when V2V/I2V/TI2V/action/camera "
558+
"conditioning is present.",
559+
)
541560
parser.add_argument(
542561
"--torch-compile-kwargs",
543562
type=str,

fastvideo/pipelines/stages/denoising.py

Lines changed: 150 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,13 @@ def forward(
9898
},
9999
)
100100

101-
# Setup precision and autocast settings
102-
# TODO(will): make the precision configurable for inference
103-
# target_dtype = PRECISION_TO_TYPE[fastvideo_args.precision]
104-
target_dtype = torch.bfloat16
101+
# Setup precision and autocast settings. Honor
102+
# pipeline_config.dit_precision so callers can opt into fp32
103+
# (e.g. numerical-precision-floor diagnostics for batched-CFG).
104+
# Default bf16 preserves pre-PR behaviour.
105+
from fastvideo.utils import PRECISION_TO_TYPE as _PRECISION_TO_TYPE
106+
_dit_precision = getattr(fastvideo_args.pipeline_config, "dit_precision", "bf16")
107+
target_dtype = _PRECISION_TO_TYPE.get(_dit_precision, torch.bfloat16)
105108
autocast_enabled = (target_dtype != torch.float32) and not fastvideo_args.disable_autocast
106109

107110
# Get timesteps and calculate warmup steps
@@ -229,6 +232,82 @@ def forward(
229232
# written to, so we allocate once.
230233
v2v_zero_pad = torch.zeros_like(latents) if batch.video_latent is not None else None
231234

235+
# Batched classifier-free guidance precomputation.
236+
# When enabled, the per-step cond + uncond pair runs as a single
237+
# batch=2 DiT forward instead of two sequential batch=1 forwards.
238+
# Disabled (sequential fallback) when V2V/I2V/TI2V or action /
239+
# camera conditioning is present: those carry batch=1 conditioning
240+
# tensors that aren't covered by this PR's batching path.
241+
_cfg_conditioning_present = (batch.video_latent is not None or batch.image_latent is not None
242+
or batch.pil_image is not None or len(batch.image_embeds) > 0
243+
or batch.mouse_cond is not None or batch.keyboard_cond is not None
244+
or batch.c2ws_plucker_emb is not None or batch.camera_states is not None)
245+
use_batched_cfg = (fastvideo_args.use_batched_cfg and batch.do_classifier_free_guidance
246+
and not _cfg_conditioning_present)
247+
248+
if use_batched_cfg:
249+
assert neg_prompt_embeds is not None
250+
251+
# Shape compatibility check. Batched-CFG cats pos+neg along
252+
# dim 0, which requires matched seq lengths. The
253+
# TextEncodingStage's diffusers-style trim+pad-to-zero
254+
# (gated on `use_batched_cfg`) gives a common length
255+
# upstream — mirrors diffusers' canonical WanPipeline so the
256+
# padded zero positions are exactly what the model was
257+
# trained on. If shapes still differ here (user constructed
258+
# the batch by hand, or a pipeline doesn't go through our
259+
# TextEncodingStage), fall back to sequential to avoid
260+
# silently wrong output.
261+
def _shapes_match(neg_list: list[torch.Tensor] | None, pos_list: list[torch.Tensor] | None) -> bool:
262+
if neg_list is None and pos_list is None:
263+
return True
264+
if neg_list is None or pos_list is None:
265+
return False
266+
if len(neg_list) != len(pos_list):
267+
return False
268+
return all(n.shape[1:] == p.shape[1:] for n, p in zip(neg_list, pos_list, strict=True))
269+
270+
if not (_shapes_match(neg_prompt_embeds, prompt_embeds)
271+
and _shapes_match(batch.clip_embedding_neg, batch.clip_embedding_pos)
272+
and _shapes_match(batch.negative_attention_mask, batch.prompt_attention_mask)):
273+
logger.info("use_batched_cfg disabled for this generation: pos/neg conditioning "
274+
"shapes differ. Falling back to sequential CFG. (TextEncodingStage "
275+
"pads to a common length when use_batched_cfg is set — this fallback "
276+
"fires when that upstream padding didn't run.)")
277+
use_batched_cfg = False
278+
279+
if use_batched_cfg:
280+
# Element-wise cat to preserve the list-of-tensors call
281+
# convention (Wan's DiT picks element [0]; other DiTs may
282+
# use auxiliary entries). neg first to match the
283+
# chunk(2) -> (uncond, text) split inside the loop.
284+
assert neg_prompt_embeds is not None
285+
prompt_embeds_combined: list[torch.Tensor] = [
286+
torch.cat([n, p], dim=0) for n, p in zip(neg_prompt_embeds, prompt_embeds, strict=True)
287+
]
288+
289+
def _cat_list_or_none(neg_val: list[torch.Tensor] | None,
290+
pos_val: list[torch.Tensor] | None) -> list[torch.Tensor] | None:
291+
if neg_val is None and pos_val is None:
292+
return None
293+
assert neg_val is not None and pos_val is not None, (
294+
"batched CFG requires matched pos/neg conditioning tensors")
295+
return [torch.cat([n, p], dim=0) for n, p in zip(neg_val, pos_val, strict=True)]
296+
297+
combined_cond_kwargs = self.prepare_extra_func_kwargs(
298+
self.transformer.forward,
299+
{
300+
"encoder_hidden_states_2": _cat_list_or_none(batch.clip_embedding_neg, batch.clip_embedding_pos),
301+
"encoder_attention_mask": _cat_list_or_none(batch.negative_attention_mask,
302+
batch.prompt_attention_mask),
303+
},
304+
)
305+
guidance_expand_cfg = guidance_expand.repeat(2) if guidance_expand is not None else None
306+
else:
307+
prompt_embeds_combined = None # type: ignore[assignment]
308+
combined_cond_kwargs = None # type: ignore[assignment]
309+
guidance_expand_cfg = None
310+
232311
# Run denoising loop
233312
with self.progress_bar(total=num_inference_steps) as progress_bar:
234313
for i, t in enumerate(timesteps):
@@ -341,56 +420,87 @@ def forward(
341420
# support torch dynamo compilation. They pass in
342421
# attn_metadata, vllm_config, and num_tokens. We can pass in
343422
# fastvideo_args or training_args, and attn_metadata.
344-
batch.is_cfg_negative = False
345-
with set_forward_context(
346-
current_timestep=i,
347-
attn_metadata=attn_metadata,
348-
forward_batch=batch,
349-
# fastvideo_args=fastvideo_args
350-
):
351-
# Run transformer
352-
noise_pred = current_model(
353-
latent_model_input,
354-
prompt_embeds,
355-
t_expand,
356-
guidance=guidance_expand,
357-
**image_kwargs,
358-
**pos_cond_kwargs,
359-
**action_kwargs,
360-
**camera_kwargs,
361-
**timesteps_r_kwarg,
362-
)
363-
364-
if batch.do_classifier_free_guidance:
365-
batch.is_cfg_negative = True
423+
if use_batched_cfg:
424+
# Single batch=2 forward: [uncond, cond] stacked
425+
# along batch dim. is_cfg_negative left False
426+
# (flag is write-only in this codebase; no
427+
# consumer branches on it).
428+
batch.is_cfg_negative = False
429+
latent_model_input_cfg = torch.cat([latent_model_input, latent_model_input], dim=0)
430+
t_expand_cfg = t_expand.repeat(2) if t_expand.dim() == 1 else t_expand.repeat(2, 1)
431+
timesteps_r_kwarg_cfg = timesteps_r_kwarg
432+
if "timestep_r" in timesteps_r_kwarg and timesteps_r_kwarg["timestep_r"] is not None:
433+
timesteps_r_kwarg_cfg = {"timestep_r": timesteps_r_kwarg["timestep_r"].repeat(2)}
434+
with set_forward_context(
435+
current_timestep=i,
436+
attn_metadata=attn_metadata,
437+
forward_batch=batch,
438+
):
439+
noise_pred_cfg = current_model(
440+
latent_model_input_cfg,
441+
prompt_embeds_combined,
442+
t_expand_cfg,
443+
guidance=guidance_expand_cfg,
444+
**image_kwargs,
445+
**combined_cond_kwargs,
446+
**action_kwargs,
447+
**camera_kwargs,
448+
**timesteps_r_kwarg_cfg,
449+
)
450+
noise_pred_uncond, noise_pred_text = noise_pred_cfg.chunk(2, dim=0)
451+
noise_pred = noise_pred_uncond + current_guidance_scale * (noise_pred_text - noise_pred_uncond)
452+
else:
453+
batch.is_cfg_negative = False
366454
with set_forward_context(
367455
current_timestep=i,
368456
attn_metadata=attn_metadata,
369457
forward_batch=batch,
458+
# fastvideo_args=fastvideo_args
370459
):
371-
noise_pred_uncond = current_model(
460+
# Run transformer
461+
noise_pred = current_model(
372462
latent_model_input,
373-
neg_prompt_embeds,
463+
prompt_embeds,
374464
t_expand,
375465
guidance=guidance_expand,
376466
**image_kwargs,
377-
**neg_cond_kwargs,
467+
**pos_cond_kwargs,
378468
**action_kwargs,
379469
**camera_kwargs,
380470
**timesteps_r_kwarg,
381471
)
382472

383-
noise_pred_text = noise_pred
384-
noise_pred = noise_pred_uncond + current_guidance_scale * (noise_pred_text - noise_pred_uncond)
385-
386-
# Apply guidance rescale if needed
387-
if batch.guidance_rescale > 0.0:
388-
# Based on 3.4. in https://arxiv.org/pdf/2305.08891.pdf
389-
noise_pred = self.rescale_noise_cfg(
390-
noise_pred,
391-
noise_pred_text,
392-
guidance_rescale=batch.guidance_rescale,
393-
)
473+
if batch.do_classifier_free_guidance:
474+
batch.is_cfg_negative = True
475+
with set_forward_context(
476+
current_timestep=i,
477+
attn_metadata=attn_metadata,
478+
forward_batch=batch,
479+
):
480+
noise_pred_uncond = current_model(
481+
latent_model_input,
482+
neg_prompt_embeds,
483+
t_expand,
484+
guidance=guidance_expand,
485+
**image_kwargs,
486+
**neg_cond_kwargs,
487+
**action_kwargs,
488+
**camera_kwargs,
489+
**timesteps_r_kwarg,
490+
)
491+
492+
noise_pred_text = noise_pred
493+
noise_pred = noise_pred_uncond + current_guidance_scale * (noise_pred_text -
494+
noise_pred_uncond)
495+
496+
# Apply guidance rescale if needed (CFG-only path)
497+
if batch.do_classifier_free_guidance and batch.guidance_rescale > 0.0:
498+
# Based on 3.4. in https://arxiv.org/pdf/2305.08891.pdf
499+
noise_pred = self.rescale_noise_cfg(
500+
noise_pred,
501+
noise_pred_text,
502+
guidance_rescale=batch.guidance_rescale,
503+
)
394504
# Compute the previous noisy sample
395505
latents = self.scheduler.step(noise_pred, t, latents, **extra_step_kwargs, return_dict=False)[0]
396506
if fastvideo_args.pipeline_config.ti2v_task and batch.pil_image is not None:

0 commit comments

Comments
 (0)