1111import torch
1212from tqdm .auto import tqdm
1313
14+ import fastvideo .envs as envs
1415from fastvideo .attention import get_attn_backend
1516from fastvideo .distributed import (get_local_torch_device , get_world_group )
1617from fastvideo .fastvideo_args import FastVideoArgs
@@ -229,6 +230,49 @@ def forward(
229230 # written to, so we allocate once.
230231 v2v_zero_pad = torch .zeros_like (latents ) if batch .video_latent is not None else None
231232
233+ # CFG gating / stale-uncond reuse setup (Adaptive Guidance LinearAG
234+ # variant, Castillo et al. 2023). When envs.FASTVIDEO_CFG_GATE_STEP
235+ # < 1.0, the uncond forward is skipped after the gating step and the
236+ # guidance delta (cond - uncond) is reused from the last fresh
237+ # compute. See envs.py for semantics. delta_cached_model_id tracks
238+ # which underlying transformer produced the cache so we invalidate on
239+ # Wan2.2 expert switch.
240+ _cfg_gate_fraction = envs .FASTVIDEO_CFG_GATE_STEP
241+ if not 0.0 <= _cfg_gate_fraction <= 1.0 :
242+ raise ValueError (f"FASTVIDEO_CFG_GATE_STEP must be in [0.0, 1.0], got { _cfg_gate_fraction !r} . "
243+ "Use 1.0 (default) to disable; lower values trade quality for speed." )
244+ _cfg_gate_active = _cfg_gate_fraction < 1.0 and batch .do_classifier_free_guidance
245+ _is_rank0 = get_world_group ().local_rank == 0
246+ if _cfg_gate_active :
247+ # Use len(timesteps), not num_inference_steps: the loop iterates
248+ # over timesteps directly, and for schedulers with order > 1
249+ # (e.g. DPM-Solver++ 2M, Heun) len(timesteps) is a multiple of
250+ # num_inference_steps. Using num_inference_steps would cause the
251+ # gate to fire at fraction/order of the loop instead of fraction.
252+ _cfg_gate_step_idx = int (len (timesteps ) * _cfg_gate_fraction )
253+ if _is_rank0 :
254+ logger .info ("CFG gating enabled: fraction=%.3f, gate_step=%d/%d" , _cfg_gate_fraction ,
255+ _cfg_gate_step_idx , len (timesteps ))
256+ if batch .guidance_rescale > 0.0 and _is_rank0 :
257+ # guidance_rescale rescales CFG output stats to match cond
258+ # stats (Lin et al. §3.4). When `delta_cached` goes stale,
259+ # the rescaling still computes but is no longer guaranteed
260+ # to preserve the original quality semantics. Warn so the
261+ # caller knows this combo is unvalidated; tighten or
262+ # fallback once VBench data lands.
263+ logger .warning (
264+ "CFG gating (fraction=%.3f) combined with guidance_rescale=%.3f is unvalidated; "
265+ "quality may degrade beyond CFG-gating-alone expectations." , _cfg_gate_fraction ,
266+ batch .guidance_rescale )
267+ else :
268+ _cfg_gate_step_idx = len (timesteps ) + 1 # never gates
269+ delta_cached : torch .Tensor | None = None
270+ delta_cached_model_id : int | None = None
271+ # Telemetry — logged at end of denoising loop on rank 0.
272+ _cfg_gate_fresh_uncond = 0
273+ _cfg_gate_reused_delta = 0
274+ _cfg_gate_invalidations = 0
275+
232276 # Run denoising loop
233277 with self .progress_bar (total = num_inference_steps ) as progress_bar :
234278 for i , t in enumerate (timesteps ):
@@ -362,26 +406,58 @@ def forward(
362406 )
363407
364408 if batch .do_classifier_free_guidance :
365- batch .is_cfg_negative = True
366- with set_forward_context (
367- current_timestep = i ,
368- attn_metadata = attn_metadata ,
369- forward_batch = batch ,
370- ):
371- noise_pred_uncond = current_model (
372- latent_model_input ,
373- neg_prompt_embeds ,
374- t_expand ,
375- guidance = guidance_expand ,
376- ** image_kwargs ,
377- ** neg_cond_kwargs ,
378- ** action_kwargs ,
379- ** camera_kwargs ,
380- ** timesteps_r_kwarg ,
381- )
409+ # CFG gating: invalidate cached delta when the underlying
410+ # transformer changes (Wan2.2 high/low-noise expert
411+ # switch at `boundary_timestep`). delta_cached is tied
412+ # to the model that produced it; reusing it across the
413+ # boundary is silently wrong.
414+ if delta_cached_model_id is not None and delta_cached_model_id != id (current_model ):
415+ delta_cached = None
416+ delta_cached_model_id = None
417+ _cfg_gate_invalidations += 1
418+
419+ _use_cached_delta = (i >= _cfg_gate_step_idx and delta_cached is not None )
382420
383421 noise_pred_text = noise_pred
384- noise_pred = noise_pred_uncond + current_guidance_scale * (noise_pred_text - noise_pred_uncond )
422+ if _use_cached_delta :
423+ # Reuse frozen delta = cond - uncond from the last
424+ # fresh compute. Algebra:
425+ # pred = uncond + s * (cond - uncond)
426+ # = cond + (s - 1) * (cond - uncond)
427+ # = cond + (s - 1) * delta_cached
428+ noise_pred = noise_pred_text + (current_guidance_scale - 1.0 ) * delta_cached
429+ _cfg_gate_reused_delta += 1
430+ else :
431+ batch .is_cfg_negative = True
432+ with set_forward_context (
433+ current_timestep = i ,
434+ attn_metadata = attn_metadata ,
435+ forward_batch = batch ,
436+ ):
437+ noise_pred_uncond = current_model (
438+ latent_model_input ,
439+ neg_prompt_embeds ,
440+ t_expand ,
441+ guidance = guidance_expand ,
442+ ** image_kwargs ,
443+ ** neg_cond_kwargs ,
444+ ** action_kwargs ,
445+ ** camera_kwargs ,
446+ ** timesteps_r_kwarg ,
447+ )
448+ _cfg_gate_fresh_uncond += 1
449+
450+ # Refresh cache only when gating is active; under the
451+ # default (FASTVIDEO_CFG_GATE_STEP=1.0, _cfg_gate_step_idx
452+ # > len(timesteps)) we never reuse, so skip the
453+ # tensor allocation.
454+ if _cfg_gate_step_idx <= len (timesteps ):
455+ delta_cached = noise_pred_text - noise_pred_uncond
456+ delta_cached_model_id = id (current_model )
457+ noise_pred = noise_pred_uncond + current_guidance_scale * delta_cached
458+ else :
459+ noise_pred = noise_pred_uncond + current_guidance_scale * (noise_pred_text -
460+ noise_pred_uncond )
385461
386462 # Apply guidance rescale if needed
387463 if batch .guidance_rescale > 0.0 :
@@ -408,6 +484,22 @@ def forward(
408484 (i + 1 ) % self .scheduler .order == 0 and progress_bar is not None ):
409485 progress_bar .update ()
410486
487+ # CFG gating telemetry — log once on rank 0 after the loop ends. When
488+ # gating is disabled (or CFG itself is off) fresh_uncond equals the
489+ # number of CFG-on steps and reused/invalidations are zero; we still
490+ # emit the line so users can confirm the env var is wired through.
491+ if _is_rank0 and batch .do_classifier_free_guidance :
492+ logger .info (
493+ "CFG gating summary: fraction=%.3f gate_step=%d/%d "
494+ "fresh_uncond=%d reused=%d invalidations=%d" ,
495+ _cfg_gate_fraction ,
496+ _cfg_gate_step_idx if _cfg_gate_active else - 1 ,
497+ len (timesteps ),
498+ _cfg_gate_fresh_uncond ,
499+ _cfg_gate_reused_delta ,
500+ _cfg_gate_invalidations ,
501+ )
502+
411503 trajectory_tensor : torch .Tensor | None = None
412504 if trajectory_latents :
413505 trajectory_tensor = torch .stack (trajectory_latents , dim = 1 )
0 commit comments