Skip to content

Commit 0192396

Browse files
[perf] Add Adaptive Guidance (CFG gating) for stale-uncond reuse (#1372)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
1 parent d6119c1 commit 0192396

4 files changed

Lines changed: 343 additions & 18 deletions

File tree

docs/inference/optimizations.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ This page describes the various options for speeding up generation times in Fast
1111
- [Sliding Tile Attention (Archived)](#sliding-tile-attention-archived)
1212
- [Sage Attention](#sage-attention)
1313
- [Sage Attention 3](#sage-attention-3)
14+
- [Adaptive Guidance (CFG gating)](#adaptive-guidance-cfg-gating)
1415

1516
- [torch.compile](#torch-compile)
1617

@@ -298,3 +299,37 @@ for backend in ["TORCH_SDPA", "FLASH_ATTN", "SAGE_ATTN"]:
298299
```
299300

300301
Note: reinstantiate `VideoGenerator` after changing `FASTVIDEO_ATTENTION_BACKEND`.
302+
303+
## Adaptive Guidance (CFG gating)
304+
305+
CFG gating accelerates classifier-free guidance by reusing the cached
306+
`noise_pred_cond - noise_pred_uncond` delta after a configurable fraction of
307+
the denoising schedule, skipping the unconditional model forward for the
308+
remaining steps. The technique is the LinearAG variant of Adaptive Guidance
309+
(Castillo et al. 2023, [arXiv:2312.12487](https://arxiv.org/abs/2312.12487)).
310+
311+
### Enabling
312+
313+
Set the `FASTVIDEO_CFG_GATE_STEP` environment variable to a float in `[0, 1]`:
314+
315+
| Value | Behavior |
316+
|-------|----------|
317+
| `1.0` (default) | Disabled — legacy two-pass CFG every step. |
318+
| `0.5` | Cache the delta after `len(timesteps) * 0.5` steps; reuse for the rest. |
319+
| `0.0` | Cache from the very first step (most aggressive). |
320+
321+
```bash
322+
export FASTVIDEO_CFG_GATE_STEP=0.5
323+
```
324+
325+
### Trade-offs
326+
327+
- **Memory**: one extra model-output-sized tensor per rank held during the
328+
gating window.
329+
- **Quality**: VBench-measured quality is preserved within noise on 4 of 5
330+
dimensions at `FASTVIDEO_CFG_GATE_STEP=0.5` for Wan T2V 1.3B per the PR's
331+
reported numbers (see [#1372](https://github.com/hao-ai-lab/FastVideo/pull/1372)).
332+
- **Speed**: ~22% e2e on 4xL40S and ~24% on 1xH100 at the same settings.
333+
334+
Default behavior is byte-for-byte equivalent to the legacy two-pass CFG path;
335+
the feature is fully opt-in.

fastvideo/envs.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
FASTVIDEO_TRACE_STEPS: str = ""
4343
FASTVIDEO_SERVER_DEV_MODE: bool = False
4444
FASTVIDEO_STAGE_LOGGING: bool = False
45+
FASTVIDEO_CFG_GATE_STEP: float = 1.0
4546
FASTVIDEO_HOST_IP: str = ""
4647
FASTVIDEO_LOOPBACK_IP: str = ""
4748

@@ -283,6 +284,32 @@ def maybe_convert_int(value: str | None) -> int | None:
283284
# taken for each stage
284285
"FASTVIDEO_STAGE_LOGGING":
285286
lambda: bool(int(os.getenv("FASTVIDEO_STAGE_LOGGING", "0"))),
287+
288+
# CFG gating fraction for stale-uncond reuse (Adaptive Guidance / LinearAG
289+
# variant — Castillo et al. 2023, arXiv:2312.12487). Float in [0, 1].
290+
# Interpretation: for step index `i < len(timesteps) * X`, run both
291+
# cond and uncond forwards and refresh delta_cached = cond - uncond.
292+
# Once `i >= len(timesteps) * X`, skip the uncond forward and reuse
293+
# the cached delta: noise_pred = cond + (guidance_scale - 1) * delta.
294+
#
295+
# Edge cases:
296+
# 1.0 (default) : disables gating; identical to baseline two-pass CFG.
297+
# 0.5 : run uncond for the first half of steps, reuse delta
298+
# for the second half (~25% inference time saved on
299+
# bandwidth-bound SP setups).
300+
# 0.0 : step 0 still computes uncond fresh (cache is empty
301+
# at start) — all subsequent steps reuse the step-0
302+
# delta. This is the most aggressive setting; does
303+
# NOT mean "no uncond forward ever."
304+
#
305+
# Caveats:
306+
# - Algorithmically approximate; not bit-exact vs baseline CFG.
307+
# Validate per-pipeline with SSIM / VBench before lowering below 1.0.
308+
# - Interaction with `guidance_rescale > 0` is unvalidated; the
309+
# denoising stage logs a warning when both are active.
310+
# - Wan2.2 high/low-noise expert switch invalidates the cache.
311+
"FASTVIDEO_CFG_GATE_STEP":
312+
lambda: float(os.getenv("FASTVIDEO_CFG_GATE_STEP", "1.0")),
286313
}
287314

288315
# end-env-vars-definition

fastvideo/pipelines/stages/denoising.py

Lines changed: 110 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import torch
1212
from tqdm.auto import tqdm
1313

14+
import fastvideo.envs as envs
1415
from fastvideo.attention import get_attn_backend
1516
from fastvideo.distributed import (get_local_torch_device, get_world_group)
1617
from 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

Comments
 (0)