chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:40 +08:00
commit 4df75a30cd
234 changed files with 41407 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
from .dmd import DMD
from .diffusion import CausalDiffusion
__all__ = [
"DMD",
"CausalDiffusion",
]
+545
View File
@@ -0,0 +1,545 @@
# Adopted from https://github.com/guandeh17/Self-Forcing
# SPDX-License-Identifier: Apache-2.0
from typing import Tuple
from einops import rearrange
from torch import nn
import torch.distributed as dist
import torch
import math
from pipeline import SelfForcingTrainingPipeline
from utils.config import section_get
from utils.loss import get_denoising_loss
from utils.wan_5b_wrapper import WanDiffusionWrapper, WanTextEncoder, WanVAEWrapper
def build_default_denoising_step_list(sampling_steps, num_train_timesteps=1000, shift=1.0, include_zero=True):
sigmas = torch.linspace(1.0, 0.0, int(sampling_steps) + 1, dtype=torch.float32)[:-1]
sigmas = shift * sigmas / (1 + (shift - 1) * sigmas)
timesteps = (sigmas * num_train_timesteps).to(torch.long)
if include_zero:
timesteps = torch.cat([timesteps, torch.zeros(1, dtype=torch.long)])
return timesteps
class BaseModel(nn.Module):
def __init__(self, args, device):
super().__init__()
print("args.model_kwargs.model_name", args.model_kwargs.model_name)
self._initialize_models(args, device)
self.device = device
self.args = args
self.independent_first_frame = getattr(args, "independent_first_frame", False)
self.dtype = torch.bfloat16 if args.mixed_precision else torch.float32
self.denoising_step_list = None
if getattr(args, "denoising_step_list", None) is not None:
self.denoising_step_list = torch.tensor(args.denoising_step_list, dtype=torch.long)
if getattr(args, "warp_denoising_step", False):
timesteps = torch.cat((self.scheduler.timesteps.cpu(), torch.tensor([0], dtype=torch.float32)))
self.denoising_step_list = timesteps[1000 - self.denoising_step_list]
elif getattr(args, "sampling_steps", None) is not None:
self.denoising_step_list = build_default_denoising_step_list(
sampling_steps=args.sampling_steps,
num_train_timesteps=getattr(args, "num_train_timestep", self.scheduler.num_train_timesteps),
shift=getattr(args, "timestep_shift", self.scheduler.shift),
include_zero=True,
)
def _initialize_models(self, args, device):
self.real_model_name = getattr(args, "real_name", "Wan2.2-TI2V-5B")
self.fake_model_name = getattr(args, "fake_name", "Wan2.2-TI2V-5B")
self.local_attn_size = section_get(
args,
"inference",
"local_attn_size",
getattr(args, "model_kwargs", {}).get("local_attn_size", -1),
aliases=("inference_local_attn_size",),
)
all_causal = getattr(args, "all_causal", False)
score_is_causal = all_causal
model_name = args.model_kwargs.get("model_name", "Wan2.2-TI2V-5B")
if "5B" not in model_name:
raise ValueError(f"Only Wan2.2-TI2V-5B is supported in this release, got {model_name}")
if not dist.is_initialized() or dist.get_rank() == 0:
tag = "all-causal 5B mode" if all_causal else "Wan2.2-TI2V-5B"
print(f"Using {tag}")
# Generator
generator_is_causal = getattr(args, "generator_is_causal", True)
self.generator = WanDiffusionWrapper(**getattr(args, "model_kwargs", {}), is_causal=generator_is_causal)
self.generator.model.requires_grad_(True)
# Real Score
real_kwargs = getattr(args, "real_model_kwargs", {"model_name": self.real_model_name})
self.real_score = WanDiffusionWrapper(**real_kwargs, is_causal=score_is_causal)
self.real_score.model.requires_grad_(False)
# Fake Score
fake_kwargs = getattr(args, "fake_model_kwargs", {"model_name": self.fake_model_name})
self.fake_score = WanDiffusionWrapper(**fake_kwargs, is_causal=score_is_causal)
self.fake_score.model.requires_grad_(True)
# Text Encoder & VAE
self.text_encoder = WanTextEncoder()
self.text_encoder.requires_grad_(False)
self.vae = WanVAEWrapper()
self.vae.requires_grad_(False)
self.scheduler = self.generator.get_scheduler()
self.scheduler.timesteps = self.scheduler.timesteps.to(device)
def _get_timestep(
self,
min_timestep: int,
max_timestep: int,
batch_size: int,
num_frame: int,
num_frame_per_block: int,
uniform_timestep: bool = False
) -> torch.Tensor:
"""
Randomly generate a timestep tensor based on the generator's task type. It uniformly samples a timestep
from the range [min_timestep, max_timestep], and returns a tensor of shape [batch_size, num_frame].
- If uniform_timestep, it will use the same timestep for all frames.
- If not uniform_timestep, it will use a different timestep for each block.
"""
if uniform_timestep:
timestep = torch.randint(
min_timestep,
max_timestep,
[batch_size, 1],
device=self.device,
dtype=torch.long
).repeat(1, num_frame)
return timestep
else:
timestep = torch.randint(
min_timestep,
max_timestep,
[batch_size, num_frame],
device=self.device,
dtype=torch.long
)
# make the noise level the same within every block
if self.independent_first_frame and not getattr(self.args, "i2v", False):
# the first frame is always kept the same
timestep_from_second = timestep[:, 1:]
timestep_from_second = timestep_from_second.reshape(
timestep_from_second.shape[0], -1, num_frame_per_block)
timestep_from_second[:, :, 1:] = timestep_from_second[:, :, 0:1]
timestep_from_second = timestep_from_second.reshape(
timestep_from_second.shape[0], -1)
timestep = torch.cat([timestep[:, 0:1], timestep_from_second], dim=1)
else:
timestep = timestep.reshape(
timestep.shape[0], -1, num_frame_per_block)
timestep[:, :, 1:] = timestep[:, :, 0:1]
timestep = timestep.reshape(timestep.shape[0], -1)
return timestep
class SelfForcingModel(BaseModel):
def __init__(self, args, device):
super().__init__(args, device)
self.denoising_loss_func = get_denoising_loss(getattr(args, "denoising_loss_type", "flow"))()
def _run_generator(
self,
image_or_video_shape,
conditional_dict: dict,
initial_latent: torch.tensor = None,
slice_last_frames: int = 21,
noise=None,
clean_latent: torch.Tensor = None,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""
Optionally simulate the generator's input from noise using backward simulation
and then run the generator for one-step.
Input:
- image_or_video_shape: a list containing the shape of the image or video [B, F, C, H, W].
- conditional_dict: a dictionary containing the conditional information (e.g. text embeddings, image embeddings).
- initial_latent: a tensor containing the initial latents [B, F, C, H, W].
- slice_last_frames: number of frames to keep from the end.
- noise: optional pre-sampled noise.
- clean_latent: a tensor [B, F, C, H, W] for off-policy mode (backward_simulation=False).
Output:
- pred_image: a tensor with shape [B, F, C, H, W].
- gradient_mask: boolean tensor or None.
- denoised_timestep_from: int or None.
- denoised_timestep_to: int or None.
"""
use_backward_simulation = getattr(self.args, "backward_simulation", True)
if use_backward_simulation:
return self._run_generator_backward_simulation(
image_or_video_shape=image_or_video_shape,
conditional_dict=conditional_dict,
initial_latent=initial_latent,
slice_last_frames=slice_last_frames,
noise=noise,
)
else:
assert clean_latent is not None, "clean_latent is required when backward_simulation=False"
return self._run_generator_off_policy(
image_or_video_shape=image_or_video_shape,
conditional_dict=conditional_dict,
clean_latent=clean_latent,
initial_latent=initial_latent,
)
def _run_generator_off_policy(
self,
image_or_video_shape,
conditional_dict: dict,
clean_latent: torch.Tensor,
initial_latent: torch.Tensor = None,
) -> Tuple[torch.Tensor, torch.Tensor, int, int]:
"""
Off-policy generator: add noise to clean_latent at each timestep in
denoising_step_list, randomly pick one, and run the generator for a
single forward pass. Returns (pred_x0, gradient_mask,
denoised_timestep_from, denoised_timestep_to).
"""
batch_size, num_frame = image_or_video_shape[:2]
denoising_step_list = self.denoising_step_list.to(self.device)
# Build noisy versions at every timestep in the schedule
simulated_noisy_input = []
for ts in denoising_step_list:
rand_noise = torch.randn_like(clean_latent)
noisy_timestep = ts * torch.ones(
[batch_size, num_frame], device=self.device, dtype=torch.long)
if ts.item() != 0:
noisy_image = self.scheduler.add_noise(
clean_latent.flatten(0, 1),
rand_noise.flatten(0, 1),
noisy_timestep.flatten(0, 1),
).unflatten(0, (batch_size, num_frame))
else:
noisy_image = clean_latent
simulated_noisy_input.append(noisy_image)
simulated_noisy_input = torch.stack(simulated_noisy_input, dim=1) # [B, T, F, C, H, W]
# Randomly sample a step index [B, F], uniform within each block
num_steps = len(denoising_step_list)
generator_is_causal = getattr(self.args, "generator_is_causal", True)
if not generator_is_causal:
# Bidirectional generator: all frames must share the same timestep
index = torch.randint(0, num_steps, [batch_size, 1],
device=self.device, dtype=torch.long).expand(-1, num_frame).contiguous()
else:
index = torch.randint(0, num_steps, [batch_size, num_frame],
device=self.device, dtype=torch.long)
# Make the index the same within every block
if self.independent_first_frame and not getattr(self.args, "i2v", False):
idx_rest = index[:, 1:]
idx_rest = idx_rest.reshape(batch_size, -1, self.num_frame_per_block)
idx_rest[:, :, 1:] = idx_rest[:, :, 0:1]
index = torch.cat([index[:, :1], idx_rest.reshape(batch_size, -1)], dim=1)
else:
index = index.reshape(batch_size, -1, self.num_frame_per_block)
index[:, :, 1:] = index[:, :, 0:1]
index = index.reshape(batch_size, -1)
# Gather the noisy input corresponding to the sampled index
noisy_input = torch.gather(
simulated_noisy_input, dim=1,
index=index.reshape(batch_size, 1, num_frame, 1, 1, 1).expand(
-1, -1, -1, *image_or_video_shape[2:])
).squeeze(1) # [B, F, C, H, W]
timestep = denoising_step_list[index] # [B, F]
context_frames = int(initial_latent.shape[1]) if initial_latent is not None else 0
if context_frames > 0:
if context_frames >= num_frame:
raise ValueError(
f"initial_latent has {context_frames} frames but training clip has {num_frame}."
)
noisy_input[:, :context_frames] = initial_latent.to(
device=noisy_input.device,
dtype=noisy_input.dtype,
)
timestep[:, :context_frames] = 0
# Single forward pass through the generator
_, pred_x0 = self.generator(
noisy_image_or_video=noisy_input,
conditional_dict=conditional_dict,
timestep=timestep.float(),
clean_x=clean_latent if getattr(self.args, "teacher_forcing", False) else None,
)
pred_x0 = pred_x0.to(self.dtype)
# Derive denoised_timestep_from / to from the sampled index for ts_schedule
# Use the first batch element's first block index as the representative scalar
rep_idx = index[0, 0].item()
denoised_timestep_from = denoising_step_list[rep_idx].item()
if rep_idx + 1 < num_steps:
denoised_timestep_to = denoising_step_list[rep_idx + 1].item()
else:
denoised_timestep_to = 0
gradient_mask = None
if context_frames > 0:
pred_x0[:, :context_frames] = initial_latent.to(
device=pred_x0.device,
dtype=pred_x0.dtype,
)
gradient_mask = torch.ones_like(pred_x0, dtype=torch.bool)
gradient_mask[:, :context_frames] = False
return pred_x0, gradient_mask, denoised_timestep_from, denoised_timestep_to
def _run_generator_backward_simulation(
self,
image_or_video_shape,
conditional_dict: dict,
initial_latent: torch.tensor = None,
slice_last_frames: int = 21,
noise=None,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""
On-policy generator via backward simulation (original path).
"""
if initial_latent is not None:
conditional_dict["initial_latent"] = initial_latent
noise_shape = image_or_video_shape.copy()
separate_first_frame = self.independent_first_frame and not getattr(self.args, "i2v", False)
min_num_frames = (self.min_num_training_frames - 1) if separate_first_frame else self.min_num_training_frames
max_num_frames = self.num_training_frames - 1 if separate_first_frame else self.num_training_frames
assert max_num_frames % self.num_frame_per_block == 0
assert min_num_frames % self.num_frame_per_block == 0
max_num_blocks = max_num_frames // self.num_frame_per_block
min_num_blocks = min_num_frames // self.num_frame_per_block
num_generated_blocks = torch.randint(min_num_blocks, max_num_blocks + 1, (1,), device=self.device)
dist.broadcast(num_generated_blocks, src=0)
num_generated_blocks = num_generated_blocks.item()
num_generated_frames = num_generated_blocks * self.num_frame_per_block
if separate_first_frame and initial_latent is None:
num_generated_frames += 1
min_num_frames += 1
noise_shape[1] = num_generated_frames
if noise is not None:
noise = noise[:, :num_generated_frames]
else:
noise = torch.randn(noise_shape, device=self.device, dtype=self.dtype)
pred_image_or_video, denoised_timestep_from, denoised_timestep_to = self._consistency_backward_simulation(
noise=noise,
slice_last_frames=slice_last_frames,
**conditional_dict,
)
if slice_last_frames != -1 and pred_image_or_video.shape[1] > slice_last_frames:
with torch.no_grad():
if slice_last_frames > 1:
latent_to_decode = pred_image_or_video[:, :-(slice_last_frames - 1), ...]
else:
latent_to_decode = pred_image_or_video
pixels = self.vae.decode_to_pixel(latent_to_decode)
frame = pixels[:, -1:, ...].to(self.dtype)
frame = rearrange(frame, "b t c h w -> b c t h w")
image_latent = self.vae.encode_to_latent(frame).to(self.dtype)
if slice_last_frames > 1:
last_frames = pred_image_or_video[:, -(slice_last_frames - 1):, ...]
pred_image_or_video_sliced = torch.cat([image_latent, last_frames], dim=1)
else:
pred_image_or_video_sliced = image_latent
if num_generated_frames != min_num_frames:
gradient_mask = torch.ones_like(pred_image_or_video_sliced, dtype=torch.bool)
if self.independent_first_frame:
gradient_mask[:, :1] = False
else:
gradient_mask[:, :self.num_frame_per_block] = False
else:
gradient_mask = None
else:
pred_image_or_video_sliced = pred_image_or_video
if num_generated_frames != min_num_frames:
gradient_mask = torch.ones_like(pred_image_or_video_sliced, dtype=torch.bool)
if self.independent_first_frame:
gradient_mask[:, :1] = False
else:
gradient_mask[:, :self.num_frame_per_block] = False
else:
gradient_mask = None
pred_image_or_video_sliced = pred_image_or_video_sliced.to(self.dtype)
return pred_image_or_video_sliced, gradient_mask, denoised_timestep_from, denoised_timestep_to
def _consistency_backward_simulation(
self,
noise: torch.Tensor,
slice_last_frames: int = 21,
**conditional_dict: dict
) -> torch.Tensor:
"""
Simulate the generator's input from noise to avoid training/inference mismatch.
See Sec 4.5 of the DMD2 paper (https://arxiv.org/abs/2405.14867) for details.
Here we use the consistency sampler (https://arxiv.org/abs/2303.01469)
Input:
- noise: a tensor sampled from N(0, 1) with shape [B, F, C, H, W] where the number of frame is 1 for images.
- conditional_dict: a dictionary containing the conditional information (e.g. text embeddings, image embeddings).
Output:
- output: a tensor with shape [B, T, F, C, H, W].
T is the total number of timesteps. output[0] is a pure noise and output[i] and i>0
represents the x0 prediction at each timestep.
"""
generator_is_causal = getattr(self.args, "generator_is_causal", True)
if not generator_is_causal:
return self._bidirectional_backward_simulation(
noise=noise,
slice_last_frames=slice_last_frames,
**conditional_dict,
)
if self.inference_pipeline is None:
self._initialize_inference_pipeline()
return self.inference_pipeline.inference_with_trajectory(
noise=noise, **conditional_dict, slice_last_frames=slice_last_frames
)
def _bidirectional_backward_simulation(
self,
noise: torch.Tensor,
slice_last_frames: int = 21,
**conditional_dict: dict
) -> Tuple[torch.Tensor, int, int]:
"""
Backward simulation for bidirectional (non-causal) generator.
All frames are processed at once at each denoising step — no KV cache,
no block-by-block processing.
"""
from wan_5b.utils.fm_solvers_unipc import FlowUniPCMultistepScheduler
batch_size, num_frames = noise.shape[:2]
# Resolve single-segment prompt for bidirectional model
prompt_embeds = conditional_dict["prompt_embeds"]
num_segments = prompt_embeds.shape[0] // batch_size
if num_segments > 1:
prompt_embeds = prompt_embeds.reshape(
batch_size, num_segments, *prompt_embeds.shape[1:])[:, 0]
cond = {**conditional_dict, "prompt_embeds": prompt_embeds}
# Setup UniPC scheduler
sampling_steps = getattr(self.args, "sampling_steps", None) or len(self.denoising_step_list)
shift = self.scheduler.shift
num_train_timesteps = self.scheduler.num_train_timesteps
sample_scheduler = FlowUniPCMultistepScheduler(
num_train_timesteps=num_train_timesteps, shift=1, use_dynamic_shifting=False)
sample_scheduler.set_timesteps(sampling_steps, device=noise.device, shift=shift)
unipc_timesteps = sample_scheduler.timesteps
num_denoising_steps = len(unipc_timesteps)
# Pick a random exit step (synchronized across ranks)
last_step_only = getattr(self.args, "last_step_only", False)
if last_step_only:
exit_step = num_denoising_steps - 1
else:
exit_step_t = torch.randint(0, num_denoising_steps, (1,), device=self.device)
dist.broadcast(exit_step_t, src=0)
exit_step = exit_step_t.item()
# Multi-step denoising loop (full-sequence bidirectional forward each step)
latents = noise
for index, t in enumerate(unipc_timesteps):
timestep = t * torch.ones(
[batch_size, num_frames], device=noise.device, dtype=torch.float32)
if index < exit_step:
with torch.no_grad():
flow_pred, _ = self.generator(
noisy_image_or_video=latents,
conditional_dict=cond,
timestep=timestep,
)
latents = sample_scheduler.step(
flow_pred, t, latents, return_dict=False)[0]
else:
# Exit step: forward with gradient
flow_pred, denoised_pred = self.generator(
noisy_image_or_video=latents,
conditional_dict=cond,
timestep=timestep,
)
break
# Compute denoised_timestep_from / to
if exit_step == num_denoising_steps - 1:
denoised_timestep_to = 0
denoised_timestep_from = 1000 - torch.argmin(
(self.scheduler.timesteps.cuda() - unipc_timesteps[exit_step].cuda()).abs(), dim=0).item()
else:
denoised_timestep_to = 1000 - torch.argmin(
(self.scheduler.timesteps.cuda() - unipc_timesteps[exit_step + 1].cuda()).abs(), dim=0).item()
denoised_timestep_from = 1000 - torch.argmin(
(self.scheduler.timesteps.cuda() - unipc_timesteps[exit_step].cuda()).abs(), dim=0).item()
return denoised_pred, denoised_timestep_from, denoised_timestep_to
def _initialize_inference_pipeline(self):
"""
Lazy initialize the inference pipeline during the first backward simulation run.
Here we encapsulate the inference code with a model-dependent outside function.
We pass our FSDP-wrapped modules into the pipeline to save memory.
"""
local_attn_size = section_get(
self.args,
"inference",
"local_attn_size",
getattr(self.args, "model_kwargs", {}).get("local_attn_size", -1),
aliases=("inference_local_attn_size",),
)
sink_size = section_get(
self.args,
"inference",
"sink_size",
getattr(self.args, "model_kwargs", {}).get("sink_size", 0),
aliases=("inference_sink_size",),
)
multi_shot_sink = section_get(self.args, "inference", "multi_shot_sink", False)
multi_shot_rope_offset = section_get(
self.args,
"inference",
"multi_shot_rope_offset",
0.0,
)
scene_cut_prefix = section_get(self.args, "inference", "scene_cut_prefix", "[SCENE_CUT]")
slice_last_frames = getattr(self.args, "slice_last_frames", 21)
# do not use self.num_training_frames, because it is changed by generator_loss and critic_loss
num_training_frames = getattr(self.args, "num_training_frames")
if local_attn_size == -1:
kv_cache_size = num_training_frames
else:
kv_cache_size = min(local_attn_size + slice_last_frames, num_training_frames)
frame_seq_length = math.prod(self.args.image_or_video_shape[-2:]) // 4
self.inference_pipeline = SelfForcingTrainingPipeline(
denoising_step_list=self.denoising_step_list,
scheduler=self.scheduler,
generator=self.generator,
num_frame_per_block=self.num_frame_per_block,
independent_first_frame=self.independent_first_frame,
same_step_across_blocks=getattr(
self.args, "same_step_across_blocks", getattr(self, "same_step_across_blocks", False)
),
last_step_only=getattr(self.args, "last_step_only", False),
num_max_frames=kv_cache_size,
context_noise=getattr(self.args, "context_noise", 0),
sampling_steps=getattr(self.args, "sampling_steps", None),
local_attn_size=local_attn_size,
sink_size=sink_size,
multi_shot_sink=multi_shot_sink,
scene_cut_prefix=scene_cut_prefix,
multi_shot_rope_offset=multi_shot_rope_offset,
slice_last_frames=slice_last_frames,
num_training_frames=num_training_frames,
model_name=getattr(self.args, "model_kwargs", {}).get("model_name", None),
frame_seq_length=frame_seq_length,
)
+574
View File
@@ -0,0 +1,574 @@
# Adopted from https://github.com/guandeh17/Self-Forcing
# SPDX-License-Identifier: Apache-2.0
from typing import Tuple
import random
import torch
from model.base import BaseModel
from pipeline import CausalDiffusionInferencePipeline
from utils.i2v_conditioning import _overwrite_i2v_context, _zero_i2v_context_timestep
from utils.wan_5b_wrapper import WanDiffusionWrapper, WanTextEncoder, WanVAEWrapper
class CausalDiffusion(BaseModel):
def __init__(self, args, device):
"""
Initialize the Diffusion loss module.
"""
super().__init__(args, device)
self.num_frame_per_block = getattr(args, "num_frame_per_block", 1)
if self.num_frame_per_block > 1:
self.generator.model.num_frame_per_block = self.num_frame_per_block
self.independent_first_frame = getattr(args, "independent_first_frame", False)
if self.independent_first_frame and not getattr(args, "i2v", False):
self.generator.model.independent_first_frame = True
if args.gradient_checkpointing:
self.generator.enable_gradient_checkpointing()
# Step 2: Initialize all hyperparameters
self.num_train_timestep = args.num_train_timestep
self.min_step = int(0.02 * self.num_train_timestep)
self.max_step = int(0.98 * self.num_train_timestep)
self.guidance_scale = args.guidance_scale
self.timestep_shift = getattr(args, "timestep_shift", 1.0)
self.teacher_forcing = getattr(args, "teacher_forcing", False)
# Noise augmentation in teacher forcing, we add small noise to clean context latents
self.noise_augmentation_max_timestep = getattr(args, "noise_augmentation_max_timestep", 0)
self.args = args
self.device = device
self.inference_pipeline = None
# Error recycling (SVI-style error buffer)
# When ``enable_position_bucketing`` is true, each rank holds a 2D
# buffer ``(local_block_position × timestep)``. The pos dimension only
# covers the LOCAL slice of the sequence this rank is responsible for
# (no cross-SP-rank pos sharing — those positions are simply not
# reachable by this rank during forward), so memory cost scales as
# ``num_blocks_global / sp_size`` instead of ``num_blocks_global``.
# ``global_block_offset`` is recorded for logging only.
# During the first ``buffer_warmup_iter`` global steps, errors are
# all-gathered across the DP group (ranks with the same SP rank but
# different DP replicas), so each rank's local pos buckets fill up
# ``dp_size`` × faster without any wasted bandwidth.
self.error_buffer = None
self.noise_error_buffer = None
self.er_num_blocks = 0 # local; >0 means 2D position-bucketed
self.er_block_offset = 0 # global block offset for THIS rank
er_cfg = getattr(args, "error_recycling", None)
if er_cfg is not None and getattr(er_cfg, "enabled", False):
from utils.error_buffer import build_error_buffer
cfg_dict = er_cfg if isinstance(er_cfg, dict) else dict(er_cfg)
cfg_dict.setdefault("num_train_timesteps", self.num_train_timestep)
sp_size = int(getattr(args, "sequence_parallel_size", 1) or 1)
if cfg_dict.get("enable_position_bucketing", False):
shape = list(getattr(args, "image_or_video_shape", [1, 0]))
total_frames = int(shape[1]) if len(shape) > 1 else 0
assert total_frames > 0 and self.num_frame_per_block > 0, (
"enable_position_bucketing=true requires "
"image_or_video_shape[1] and num_frame_per_block to be set."
)
num_blocks_global = total_frames // self.num_frame_per_block
assert num_blocks_global % sp_size == 0, (
f"num_blocks_global ({num_blocks_global}) must be divisible "
f"by sequence_parallel_size ({sp_size})."
)
self.er_num_blocks = num_blocks_global // sp_size # local
# Determine this rank's SP index → global block offset.
if sp_size > 1:
import torch.distributed as dist
if dist.is_initialized():
sp_rank = dist.get_rank() % sp_size
else:
sp_rank = 0
else:
sp_rank = 0
self.er_block_offset = sp_rank * self.er_num_blocks
# Shard timestep buckets across SP ranks: each SP rank only
# stores t_bucket % sp_size == sp_rank, cutting per-rank CPU
# memory by ~sp_size. This uses the same SP dimension that
# already splits positions in 2D mode, so both 1D and 2D
# follow one save/load pattern (per sp_rank).
import torch.distributed as dist
if sp_size > 1 and dist.is_initialized():
er_shard_rank = dist.get_rank() % sp_size
er_shard_size = sp_size
else:
er_shard_rank = 0
er_shard_size = 1
self.error_buffer = build_error_buffer(
cfg_dict, num_blocks=self.er_num_blocks,
global_block_offset=self.er_block_offset,
shard_rank=er_shard_rank, shard_size=er_shard_size,
)
self.noise_error_buffer = build_error_buffer(
cfg_dict, num_blocks=self.er_num_blocks,
global_block_offset=self.er_block_offset,
shard_rank=er_shard_rank, shard_size=er_shard_size,
)
self.er_context_inject_prob = float(cfg_dict.get("context_inject_prob", 0.9))
self.er_latent_inject_prob = float(cfg_dict.get("latent_inject_prob", 0.0))
self.er_noise_inject_prob = float(cfg_dict.get("noise_inject_prob", 0.0))
self.er_clean_prob = float(cfg_dict.get("clean_prob", 0.0))
self.er_clean_buffer_update_prob = float(cfg_dict.get("clean_buffer_update_prob", 0.1))
self.er_start_step = int(cfg_dict.get("start_step", 0))
self.er_buffer_warmup_iter = int(cfg_dict.get("buffer_warmup_iter", 50))
self.er_skip_block_0 = bool(cfg_dict.get("skip_block_0", False))
def _initialize_models(self, args, device):
model_name = getattr(args.model_kwargs, "model_name", "Wan2.2-TI2V-5B")
if "5B" not in model_name:
raise ValueError(f"Only Wan2.2-TI2V-5B is supported in this release, got {model_name}")
self.generator = WanDiffusionWrapper(**getattr(args, "model_kwargs", {}), is_causal=True)
self.generator.model.requires_grad_(True)
self.text_encoder = WanTextEncoder()
self.text_encoder.requires_grad_(False)
self.vae = WanVAEWrapper()
self.vae.requires_grad_(False)
self.scheduler = self.generator.get_scheduler()
self.scheduler.timesteps = self.scheduler.timesteps.to(device)
def generator_loss(
self,
image_or_video_shape,
conditional_dict: dict,
unconditional_dict: dict,
clean_latent: torch.Tensor,
initial_latent: torch.Tensor = None,
loss_mask: torch.Tensor = None,
loss_mask_global_valid_count: torch.Tensor = None,
global_step: int = None,
) -> Tuple[torch.Tensor, dict]:
"""
Generate image/videos from noise and compute the DMD loss.
The noisy input to the generator is backward simulated.
This removes the need of any datasets during distillation.
See Sec 4.5 of the DMD2 paper (https://arxiv.org/abs/2405.14867) for details.
Input:
- image_or_video_shape: a list containing the shape of the image or video [B, F, C, H, W].
- conditional_dict: a dictionary containing the conditional information (e.g. text embeddings, image embeddings).
- unconditional_dict: a dictionary containing the unconditional information (e.g. null/negative text embeddings, null/negative image embeddings).
- clean_latent: a tensor containing the clean latents [B, F, C, H, W]. Need to be passed when no backward simulation is used.
- loss_mask: optional tensor of shape [B, F] with 1.0 for valid frames and 0.0 for padded frames.
Under Sequence Parallel this is already the local chunk.
- loss_mask_global_valid_count: optional scalar tensor with the total valid count across all SP ranks.
When provided (SP mode), used as the denominator instead of the local loss_mask.sum().
- global_step: current training step, used for error recycling delayed start.
Output:
- loss: a scalar tensor representing the generator loss.
- generator_log_dict: a dictionary containing the intermediate tensors for logging.
"""
batch_size, num_frame = image_or_video_shape[:2]
noise = torch.randn_like(clean_latent)
# Step 2: Randomly sample a timestep and add noise to denoiser inputs
index = self._get_timestep(
0,
self.scheduler.num_train_timesteps,
image_or_video_shape[0],
image_or_video_shape[1],
self.num_frame_per_block,
uniform_timestep=False
)
timestep = self.scheduler.timesteps[index].to(dtype=self.dtype, device=self.device)
context_latent = (
initial_latent
if getattr(self.args, "i2v", False) and initial_latent is not None
else None
)
context_frames = int(context_latent.shape[1]) if context_latent is not None else 0
if context_frames > 0:
if context_frames >= num_frame:
raise ValueError(
f"initial_latent has {context_frames} frames but training clip has {num_frame}."
)
timestep[:, :context_frames] = 0
# Step 2.5 & 3.5: Error recycling — clean_prob acts as a master switch.
# When clean_prob fires, skip ALL error injection and use pristine input;
# otherwise each injection type rolls its own probability and is gated
# only by whether the corresponding buffer has any samples (SVI behavior).
#
# NOTE on rank-sync: random.random() below is INTENTIONALLY independent
# across ranks. None of these decisions guard a collective call (we use
# SVI's pattern of unconditional all_gather + local random replay in
# Step 5), so per-rank divergence here only affects which slice of data
# gets corrupted on which rank — perfectly safe under DP+SP, and matches
# SVI's behavior exactly.
er_latent_injected = False
er_noise_injected = False
er_injected = False
er_use_clean = False
er_ready = (
self.error_buffer is not None
and (global_step is None or global_step >= self.er_start_step)
)
if er_ready and self.er_clean_prob > 0 and random.random() < self.er_clean_prob:
er_use_clean = True
# Noise error injection (SVI's noise_prob): corrupt noise input.
# training_target is then computed with the corrupted noise so the
# model learns to predict a self-correcting velocity (SVI Eq. logic).
noise_for_train = noise
if (
er_ready and not er_use_clean
and self.er_noise_inject_prob > 0
and not self.noise_error_buffer.is_empty()
and random.random() < self.er_noise_inject_prob
):
noise_for_train = self._inject_noise_error_buffer(
noise, index, batch_size, num_frame
)
er_noise_injected = True
# Latent error injection (SVI's latent_prob): corrupt clean_latent
# before noising. training_target keeps pointing to ORIGINAL clean_latent.
clean_latent_for_noise = clean_latent
if (
er_ready and not er_use_clean
and self.er_latent_inject_prob > 0
and not self.error_buffer.is_empty()
and random.random() < self.er_latent_inject_prob
):
clean_latent_for_noise = self._inject_latent_error_buffer(
clean_latent, index, batch_size, num_frame
)
er_latent_injected = True
noisy_latents = self.scheduler.add_noise(
clean_latent_for_noise.flatten(0, 1),
noise_for_train.flatten(0, 1),
timestep.flatten(0, 1)
).unflatten(0, (batch_size, num_frame))
training_target = self.scheduler.training_target(clean_latent, noise_for_train, timestep)
if context_frames > 0:
noisy_latents[:, :context_frames] = context_latent.to(
device=noisy_latents.device,
dtype=noisy_latents.dtype,
)
training_target[:, :context_frames] = 0
# Step 3: Noise augmentation, also add small noise to clean context latents
if self.noise_augmentation_max_timestep > 0:
index_clean_aug = self._get_timestep(
0,
self.noise_augmentation_max_timestep,
image_or_video_shape[0],
image_or_video_shape[1],
self.num_frame_per_block,
uniform_timestep=False
)
timestep_clean_aug = self.scheduler.timesteps[index_clean_aug].to(dtype=self.dtype, device=self.device)
clean_latent_aug = self.scheduler.add_noise(
clean_latent.flatten(0, 1),
noise.flatten(0, 1),
timestep_clean_aug.flatten(0, 1)
).unflatten(0, (batch_size, num_frame))
else:
clean_latent_aug = clean_latent
timestep_clean_aug = None
# Step 3.5: Error recycling — inject sampled errors into clean prefix.
# 2D mode: per-position (random timestep). 1D mode: SVI global sampling.
if (
er_ready and not er_use_clean
and not self.error_buffer.is_empty()
and random.random() < self.er_context_inject_prob
):
clean_latent_aug = self._inject_error_buffer(
clean_latent_aug, index, batch_size, num_frame
)
er_injected = True
if context_frames > 0:
clean_latent_aug = _overwrite_i2v_context(
clean_latent_aug, context_latent, context_frames
)
if timestep_clean_aug is not None:
timestep_clean_aug = _zero_i2v_context_timestep(
timestep_clean_aug, context_frames
)
# Compute loss
flow_pred, x0_pred = self.generator(
noisy_image_or_video=noisy_latents,
conditional_dict=conditional_dict,
timestep=timestep,
clean_x=clean_latent_aug if self.teacher_forcing else None,
aug_t=timestep_clean_aug if self.teacher_forcing else None,
)
loss = torch.nn.functional.mse_loss(
flow_pred.float(), training_target.float(), reduction='none'
).mean(dim=(2, 3, 4))
loss = loss * self.scheduler.training_weight(timestep).unflatten(0, (batch_size, num_frame))
if context_frames > 0:
if loss_mask is None:
loss_mask = torch.ones(
(batch_size, num_frame),
device=loss.device,
dtype=loss.dtype,
)
loss_mask[:, :context_frames] = 0
if loss_mask is not None:
loss = loss * loss_mask
valid_count = loss_mask_global_valid_count if loss_mask_global_valid_count is not None else loss_mask.sum()
loss = loss.sum() / valid_count.clamp(min=1.0)
else:
loss = loss.mean()
log_dict = {
"x0": clean_latent.detach(),
"x0_pred": x0_pred.detach()
}
# Step 5: Store prediction errors into error buffer.
#
# SVI-style two-phase pattern (avoids the rank-divergent collective
# deadlock that an ``if random.random() < p: all_gather()`` would
# introduce):
# PHASE A (collective, UNCONDITIONAL): every rank reaches the
# all_gather call together so NCCL stays in sync.
# PHASE B (local, GATED): each rank independently decides whether
# to actually replay the gathered items into its buffer. The
# gate uses random.random() per rank — divergence here is fine
# because no further collective follows.
if self.error_buffer is not None:
with torch.no_grad():
# latent error: x0_pred - clean_latent ≡ -σ(v_pred - v_gt) — used for context/latent injection
latent_err = x0_pred.detach() - clean_latent.detach()
# noise error: SVI definition is (1-σ)(v_pred - v_gt) so the buffer entry,
# when later added directly to noise, equals ε_pred - ε_gt.
sigma = self.scheduler.sigmas.to(flow_pred.device)[index].reshape(
batch_size, num_frame, 1, 1, 1
).to(flow_pred.dtype)
noise_err = (flow_pred.detach() - training_target.detach()) * (1.0 - sigma)
use_distributed = (
global_step is not None
and global_step <= self.er_buffer_warmup_iter
)
# === PHASE A: collective — runs on EVERY rank, no gating ===
if use_distributed:
lat_items = self._gather_errors_for_buffer(
self.error_buffer, latent_err, index, batch_size, num_frame
)
noise_items = self._gather_errors_for_buffer(
self.noise_error_buffer, noise_err, index, batch_size, num_frame
)
else:
lat_items = self._collect_local_items(
self.error_buffer, latent_err, index, batch_size, num_frame
)
noise_items = self._collect_local_items(
self.noise_error_buffer, noise_err, index, batch_size, num_frame
)
# === PHASE B: local replay — random.random() per-rank is OK ===
# When the input was clean (low-error), only update buffer with
# small probability to avoid flooding it with near-zero samples
# (SVI: clean_buffer_update_prob).
should_update = True
if er_use_clean and random.random() >= self.er_clean_buffer_update_prob:
should_update = False
if should_update:
self._apply_gathered_items(self.error_buffer, lat_items)
self._apply_gathered_items(self.noise_error_buffer, noise_items)
buf_stats = self.error_buffer.stats()
noise_buf_stats = self.noise_error_buffer.stats()
log_dict["er_total_added"] = buf_stats["total_added"]
log_dict["er_filled_buckets"] = buf_stats["filled_buckets"]
log_dict["er_total_entries"] = buf_stats["total_entries"]
log_dict["er_noise_total_entries"] = noise_buf_stats["total_entries"]
log_dict["er_injected"] = er_injected
log_dict["er_latent_injected"] = er_latent_injected
log_dict["er_noise_injected"] = er_noise_injected
return loss, log_dict
def _inject_error_buffer(self, clean_latent_aug, index, batch_size, num_frame):
"""Inject errors into the clean prefix (E_img).
2D (position-bucketed): the i-th LOCAL prefix block draws from
``buckets[(i, *)]`` with a RANDOM timestep — the clean prefix is
the product of full ODE integration so its accumulated error can
come from any noise level, but its magnitude scales with the
block's global position. Note ``skip_block_0`` is interpreted in
the GLOBAL frame: only the very first SP rank may skip its block 0.
1D (timestep-bucketed): falls back to SVI ``sample_global``.
"""
block_size = self.num_frame_per_block
num_blocks = num_frame // block_size
result = clean_latent_aug.clone()
for b in range(batch_size):
for blk in range(num_blocks):
if self.er_skip_block_0 and (self.er_block_offset + blk) == 0:
continue
if self.er_num_blocks > 0:
err = self.error_buffer.sample_pos_any_t(
blk, device=result.device, dtype=result.dtype
)
else:
err = self.error_buffer.sample_global(
device=result.device, dtype=result.dtype
)
if err is not None:
start = blk * block_size
end = start + block_size
result[b, start:end] = result[b, start:end] + err
return result
def _inject_latent_error_buffer(self, clean_latent, index, batch_size, num_frame):
"""Inject errors into clean_latent before noising (E_vid).
Matches BOTH block_position (LOCAL) and timestep when the buffer is
2D, else only timestep (SVI default).
"""
block_size = self.num_frame_per_block
num_blocks = num_frame // block_size
index_per_block = index[:, ::block_size]
result = clean_latent.clone()
for b in range(batch_size):
for blk in range(num_blocks):
t_idx = index_per_block[b, blk].item()
pos = blk if self.er_num_blocks > 0 else None
err = self.error_buffer.sample(
t_idx, device=result.device, dtype=result.dtype,
block_pos=pos,
)
if err is not None:
start = blk * block_size
end = start + block_size
result[b, start:end] = result[b, start:end] + err
return result
def _inject_noise_error_buffer(self, noise, index, batch_size, num_frame):
"""Inject errors into the noise (E_noise).
Same matching strategy as ``_inject_latent_error_buffer`` but reads
from the dedicated noise buffer.
"""
block_size = self.num_frame_per_block
num_blocks = num_frame // block_size
index_per_block = index[:, ::block_size]
result = noise.clone()
for b in range(batch_size):
for blk in range(num_blocks):
t_idx = index_per_block[b, blk].item()
pos = blk if self.er_num_blocks > 0 else None
err = self.noise_error_buffer.sample(
t_idx, device=result.device, dtype=result.dtype,
block_pos=pos,
)
if err is not None:
start = blk * block_size
end = start + block_size
result[b, start:end] = result[b, start:end] + err
return result
def _gather_errors_for_buffer(
self, buffer, error, index, batch_size, num_frame
):
"""All-gather errors/timesteps across the appropriate group and return
a list of ready-to-add ``(err_block, t_idx, pos_or_None)`` items.
★ This is a COLLECTIVE — every rank MUST reach this call together.
The caller is responsible for invoking it unconditionally during the
warmup window (just like SVI's ``all_gather`` outside the random
``if`` blocks). Random decisions about whether to actually consume
the returned items belong to ``_apply_gathered_items`` instead.
Group selection mirrors SVI's intent:
* **2D (num_blocks > 0)** — DP group only. Other SP ranks' samples
map to positions unreachable by this rank, so cross-SP gather
wastes bandwidth.
* **1D (num_blocks == 0)** — WORLD group (SVI default). Buckets
are pos-agnostic so every rank's errors are valid samples.
"""
import torch.distributed as dist
if not dist.is_initialized() or dist.get_world_size() <= 1:
return self._collect_local_items(buffer, error, index, batch_size, num_frame)
if buffer.num_blocks > 0:
from wan_5b.distributed.sp_training import get_data_parallel_group
comm_group = get_data_parallel_group()
if comm_group is None:
return self._collect_local_items(buffer, error, index, batch_size, num_frame)
comm_size = dist.get_world_size(comm_group)
else:
comm_group = None
comm_size = dist.get_world_size()
if comm_size <= 1:
return self._collect_local_items(buffer, error, index, batch_size, num_frame)
err_local = error.detach().contiguous()
idx_local = index.detach().contiguous()
err_list = [torch.empty_like(err_local) for _ in range(comm_size)]
idx_list = [torch.empty_like(idx_local) for _ in range(comm_size)]
if comm_group is None:
dist.all_gather(err_list, err_local)
dist.all_gather(idx_list, idx_local)
else:
dist.all_gather(err_list, err_local, group=comm_group)
dist.all_gather(idx_list, idx_local, group=comm_group)
block_size = self.num_frame_per_block
num_blocks = num_frame // block_size
items = []
for err_r, idx_r in zip(err_list, idx_list):
idx_per_block = idx_r[:, ::block_size]
err_blocks = err_r.reshape(
batch_size, num_blocks, block_size, *err_r.shape[2:]
)
for b in range(batch_size):
for blk in range(num_blocks):
pos = blk if buffer.num_blocks > 0 else None
items.append((err_blocks[b, blk], idx_per_block[b, blk].item(), pos))
return items
def _collect_local_items(self, buffer, error, index, batch_size, num_frame):
"""Same item-list format as ``_gather_errors_for_buffer`` but with no
collective — used outside the warmup window or when distributed is off."""
block_size = self.num_frame_per_block
num_blocks = num_frame // block_size
idx_per_block = index[:, ::block_size]
error_blocks = error.reshape(
batch_size, num_blocks, block_size, *error.shape[2:]
)
items = []
for b in range(batch_size):
for blk in range(num_blocks):
pos = blk if buffer.num_blocks > 0 else None
items.append((error_blocks[b, blk], idx_per_block[b, blk].item(), pos))
return items
def _apply_gathered_items(self, buffer, items):
"""Pure local: drop ``items`` into ``buffer``. No collective, no
cross-rank coordination — each rank may invoke this independently
(or skip it entirely) without risking a deadlock."""
for err_block, t_idx, pos in items:
buffer.add(err_block, t_idx, block_pos=pos)
def _initialize_inference_pipeline(self):
"""
Lazy initialize the inference pipeline during the first backward simulation run.
Here we encapsulate the inference code with a model-dependent outside function.
We pass our FSDP-wrapped modules into the pipeline to save memory.
"""
self.inference_pipeline = CausalDiffusionInferencePipeline(
args=self.args,
device=self.device,
generator=self.generator,
text_encoder=self.text_encoder,
vae=self.vae
)
+494
View File
@@ -0,0 +1,494 @@
# Adopted from https://github.com/guandeh17/Self-Forcing
# SPDX-License-Identifier: Apache-2.0
import torch.nn.functional as F
from typing import Optional, Tuple
import torch
import time
from model.base import SelfForcingModel
import torch.distributed as dist
from utils.i2v_conditioning import (
_get_i2v_context_frames,
_i2v_loss_mask_like,
_overwrite_i2v_context,
_zero_i2v_context_timestep,
)
class DMD(SelfForcingModel):
def __init__(self, args, device):
"""
Initialize the DMD (Distribution Matching Distillation) module.
This class is self-contained and compute generator and fake score losses
in the forward pass.
"""
super().__init__(args, device)
self.num_frame_per_block = getattr(args, "num_frame_per_block", 1)
self.same_step_across_blocks = getattr(args, "same_step_across_blocks", True)
self.min_num_training_frames = getattr(args, "min_num_training_frames", 21)
self.num_training_frames = getattr(args, "num_training_frames", 21)
if self.num_frame_per_block > 1:
for diffusion_model in (self.generator, self.real_score, self.fake_score):
if hasattr(diffusion_model.model, "num_frame_per_block"):
diffusion_model.model.num_frame_per_block = self.num_frame_per_block
self.independent_first_frame = getattr(args, "independent_first_frame", False)
if self.independent_first_frame and not getattr(args, "i2v", False):
self.generator.model.independent_first_frame = True
if args.gradient_checkpointing:
self.generator.enable_gradient_checkpointing()
self.fake_score.enable_gradient_checkpointing()
# this will be init later with fsdp-wrapped modules
self.inference_pipeline: SelfForcingTrainingPipeline = None
# Step 2: Initialize all dmd hyperparameters
self.num_train_timestep = getattr(args, "num_train_timestep", 1000)
self.min_step = int(0.02 * self.num_train_timestep)
self.max_step = int(0.98 * self.num_train_timestep)
if hasattr(args, "real_guidance_scale"):
self.real_guidance_scale = args.real_guidance_scale
self.fake_guidance_scale = args.fake_guidance_scale
else:
self.real_guidance_scale = args.guidance_scale
self.fake_guidance_scale = 0.0
self.timestep_shift = getattr(args, "timestep_shift", 1.0)
self.ts_schedule = getattr(args, "ts_schedule", True)
self.ts_schedule_max = getattr(args, "ts_schedule_max", False)
self.min_score_timestep = getattr(args, "min_score_timestep", 0)
if getattr(self.scheduler, "alphas_cumprod", None) is not None:
self.scheduler.alphas_cumprod = self.scheduler.alphas_cumprod.to(device)
else:
self.scheduler.alphas_cumprod = None
@staticmethod
def _slice_block_cond_dict(cond_dict, batch_size, new_num_segments):
"""Slice a block-wise conditional dict to keep only the last `new_num_segments` segments."""
pe = cond_dict["prompt_embeds"]
orig_segs = pe.shape[0] // batch_size
if orig_segs > new_num_segments:
pe = pe.reshape(batch_size, orig_segs, *pe.shape[1:])[:, -new_num_segments:]
return {**cond_dict, "prompt_embeds": pe.reshape(batch_size * new_num_segments, *pe.shape[2:])}
return cond_dict
def _compute_kl_grad(
self, noisy_image_or_video: torch.Tensor,
estimated_clean_image_or_video: torch.Tensor,
timestep: torch.Tensor,
conditional_dict: dict, unconditional_dict: dict,
normalization: bool = True,
clean_x: Optional[torch.Tensor] = None
) -> Tuple[torch.Tensor, dict]:
"""
Compute the KL grad (eq 7 in https://arxiv.org/abs/2311.18828).
Input:
- noisy_image_or_video: a tensor with shape [B, F, C, H, W] where the number of frame is 1 for images.
- estimated_clean_image_or_video: a tensor with shape [B, F, C, H, W] representing the estimated clean image or video.
- timestep: a tensor with shape [B, F] containing the randomly generated timestep.
- conditional_dict: a dictionary containing the conditional information (e.g. text embeddings, image embeddings).
- unconditional_dict: a dictionary containing the unconditional information (e.g. null/negative text embeddings, null/negative image embeddings).
- normalization: a boolean indicating whether to normalize the gradient.
Output:
- kl_grad: a tensor representing the KL grad.
- kl_log_dict: a dictionary containing the intermediate tensors for logging.
"""
# Step 1: Compute the fake score
_, pred_fake_image_cond = self.fake_score(
noisy_image_or_video=noisy_image_or_video,
conditional_dict=conditional_dict,
timestep=timestep,
clean_x=clean_x
)
if self.fake_guidance_scale != 0.0:
_, pred_fake_image_uncond = self.fake_score(
noisy_image_or_video=noisy_image_or_video,
conditional_dict=unconditional_dict,
timestep=timestep,
clean_x=clean_x
)
pred_fake_image = pred_fake_image_cond + (
pred_fake_image_cond - pred_fake_image_uncond
) * self.fake_guidance_scale
else:
pred_fake_image = pred_fake_image_cond
# Step 2: Compute the real score
_, pred_real_image_cond = self.real_score(
noisy_image_or_video=noisy_image_or_video,
conditional_dict=conditional_dict,
timestep=timestep,
clean_x=clean_x
)
_, pred_real_image_uncond = self.real_score(
noisy_image_or_video=noisy_image_or_video,
conditional_dict=unconditional_dict,
timestep=timestep,
clean_x=clean_x
)
pred_real_image = pred_real_image_cond + (
pred_real_image_cond - pred_real_image_uncond
) * self.real_guidance_scale
# Step 3: Compute the DMD gradient (DMD paper eq. 7).
grad = (pred_fake_image - pred_real_image)
# NOTE: Changed the normalizer for causal teacher — per-block normalization
if normalization:
p_real = (estimated_clean_image_or_video - pred_real_image)
B, F, C, H, W = p_real.shape
if dist.get_rank() == 0:
print(f"p_real: {p_real.shape}")
if (
self.independent_first_frame
and not getattr(self.args, "i2v", False)
and (F - 1) % self.num_frame_per_block == 0
):
p_real_tail = p_real[:, 1:]
p_real_blocks = p_real_tail.view(
B,
(F - 1) // self.num_frame_per_block,
self.num_frame_per_block,
C,
H,
W,
)
normalizer_tail = torch.abs(p_real_blocks).mean(dim=[2, 3, 4, 5], keepdim=True)
normalizer = torch.ones_like(p_real)
normalizer[:, 1:] = normalizer_tail.expand_as(p_real_blocks).reshape(
B, F - 1, C, H, W
)
else:
p_real_blocks = p_real.view(B, F // self.num_frame_per_block, self.num_frame_per_block, C, H, W)
normalizer = torch.abs(p_real_blocks).mean(dim=[2, 3, 4, 5], keepdim=True)
normalizer = normalizer.expand_as(p_real_blocks).reshape(B, F, C, H, W)
grad = grad / normalizer
grad = torch.nan_to_num(grad)
return grad, {
"dmdtrain_gradient_norm": torch.mean(torch.abs(grad)).detach(),
"timestep": timestep.detach()
}
def compute_distribution_matching_loss(
self,
image_or_video: torch.Tensor,
conditional_dict: dict,
unconditional_dict: dict,
gradient_mask: Optional[torch.Tensor] = None,
denoised_timestep_from: int = 0,
denoised_timestep_to: int = 0,
clean_x: Optional[torch.Tensor] = None,
initial_latent: Optional[torch.Tensor] = None,
) -> Tuple[torch.Tensor, dict]:
"""
Compute the DMD loss (eq 7 in https://arxiv.org/abs/2311.18828).
Input:
- image_or_video: a tensor with shape [B, F, C, H, W] where the number of frame is 1 for images.
- conditional_dict: a dictionary containing the conditional information (e.g. text embeddings, image embeddings).
- unconditional_dict: a dictionary containing the unconditional information (e.g. null/negative text embeddings, null/negative image embeddings).
- gradient_mask: a boolean tensor with the same shape as image_or_video indicating which pixels to compute loss .
Output:
- dmd_loss: a scalar tensor representing the DMD loss.
- dmd_log_dict: a dictionary containing the intermediate tensors for logging.
"""
context_frames = _get_i2v_context_frames(image_or_video, initial_latent)
original_latent = _overwrite_i2v_context(
image_or_video, initial_latent, context_frames
)
if clean_x is not None:
clean_x = _overwrite_i2v_context(clean_x, initial_latent, context_frames)
batch_size, num_frame = image_or_video.shape[:2]
with torch.no_grad():
# Step 1: Randomly sample timestep based on the given schedule and corresponding noise
min_timestep = denoised_timestep_to if self.ts_schedule and denoised_timestep_to is not None else self.min_score_timestep
max_timestep = denoised_timestep_from if self.ts_schedule_max and denoised_timestep_from is not None else self.num_train_timestep
timestep = self._get_timestep(
min_timestep,
max_timestep,
batch_size,
num_frame,
self.num_frame_per_block,
# t2v keeps the original NVFP4 behavior (one shared timestep across
# all frames); i2v uses per-block timesteps.
uniform_timestep=not getattr(self.args, "i2v", False)
)
# TODO:should we change it to `timestep = self.scheduler.timesteps[timestep]`?
if self.timestep_shift > 1:
timestep = self.timestep_shift * \
(timestep / 1000) / \
(1 + (self.timestep_shift - 1) * (timestep / 1000)) * 1000
timestep = timestep.clamp(self.min_step, self.max_step)
timestep = _zero_i2v_context_timestep(timestep, context_frames)
noise = torch.randn_like(image_or_video)
if context_frames > 0:
noise[:, :context_frames] = 0
noisy_latent = self.scheduler.add_noise(
original_latent.flatten(0, 1),
noise.flatten(0, 1),
timestep.flatten(0, 1)
).detach().unflatten(0, (batch_size, num_frame))
noisy_latent = _overwrite_i2v_context(
noisy_latent, initial_latent, context_frames
)
# Step 2: Compute the KL grad
grad, dmd_log_dict = self._compute_kl_grad(
noisy_image_or_video=noisy_latent,
estimated_clean_image_or_video=original_latent,
timestep=timestep,
conditional_dict=conditional_dict,
unconditional_dict=unconditional_dict,
clean_x=clean_x
)
context_mask = _i2v_loss_mask_like(original_latent, context_frames)
if context_mask is not None:
gradient_mask = context_mask if gradient_mask is None else gradient_mask & context_mask
if gradient_mask is not None:
dmd_loss = 0.5 * F.mse_loss(original_latent.double(
)[gradient_mask], (original_latent.double() - grad.double()).detach()[gradient_mask], reduction="mean")
else:
dmd_loss = 0.5 * F.mse_loss(original_latent.double(
), (original_latent.double() - grad.double()).detach(), reduction="mean")
return dmd_loss, dmd_log_dict
def generator_loss(
self,
image_or_video_shape,
conditional_dict: dict,
unconditional_dict: dict,
clean_latent: torch.Tensor,
initial_latent: torch.Tensor = None
) -> Tuple[torch.Tensor, dict]:
"""
Generate image/videos from noise and compute the DMD loss.
The noisy input to the generator is backward simulated.
This removes the need of any datasets during distillation.
See Sec 4.5 of the DMD2 paper (https://arxiv.org/abs/2405.14867) for details.
Input:
- image_or_video_shape: a list containing the shape of the image or video [B, F, C, H, W].
- conditional_dict: a dictionary containing the conditional information (e.g. text embeddings, image embeddings).
- unconditional_dict: a dictionary containing the unconditional information (e.g. null/negative text embeddings, null/negative image embeddings).
- clean_latent: a tensor containing the clean latents [B, F, C, H, W]. Need to be passed when no backward simulation is used.
Output:
- loss: a scalar tensor representing the generator loss.
- generator_log_dict: a dictionary containing the intermediate tensors for logging.
"""
# Step 1: Unroll generator to obtain fake videos
slice_last_frames = getattr(self.args, "slice_last_frames", 21)
_t_gen_start = time.time()
num_gen_frames = image_or_video_shape[1]
sampled_noise = torch.randn(
[image_or_video_shape[0], num_gen_frames, *image_or_video_shape[2:]],
device=self.device, dtype=self.dtype)
pred_image, gradient_mask, denoised_timestep_from, denoised_timestep_to = self._run_generator(
image_or_video_shape=image_or_video_shape,
conditional_dict=conditional_dict,
initial_latent=initial_latent,
slice_last_frames=slice_last_frames,
noise=sampled_noise,
clean_latent=clean_latent
)
gen_time = time.time() - _t_gen_start
# Step 2: Compute the DMD loss
_t_loss_start = time.time()
if getattr(self.args, "teacher_forcing", False):
if getattr(self.args, "backward_simulation", True):
score_clean_x = pred_image.detach()
else:
score_clean_x = clean_latent
else:
score_clean_x = None
_bs = pred_image.shape[0]
_new_segs = pred_image.shape[1] // self.num_frame_per_block
if not getattr(self.args, "generator_is_causal", True):
_new_segs = 1
conditional_dict = self._slice_block_cond_dict(conditional_dict, _bs, _new_segs)
unconditional_dict = self._slice_block_cond_dict(unconditional_dict, _bs, _new_segs)
dmd_loss, dmd_log_dict = self.compute_distribution_matching_loss(
image_or_video=pred_image,
conditional_dict=conditional_dict,
unconditional_dict=unconditional_dict,
gradient_mask=gradient_mask,
denoised_timestep_from=denoised_timestep_from,
denoised_timestep_to=denoised_timestep_to,
clean_x=score_clean_x,
initial_latent=initial_latent if pred_image.shape[1] == image_or_video_shape[1] else None,
)
try:
loss_val = dmd_loss.item()
except Exception:
loss_val = float('nan')
loss_time = time.time() - _t_loss_start
dmd_log_dict.update({
"gen_time": gen_time,
"loss_time": loss_time
})
return dmd_loss, dmd_log_dict
def critic_loss(
self,
image_or_video_shape,
conditional_dict: dict,
unconditional_dict: dict,
clean_latent: torch.Tensor,
initial_latent: torch.Tensor = None
) -> Tuple[torch.Tensor, dict]:
"""
Generate image/videos from noise and train the critic with generated samples.
The noisy input to the generator is backward simulated.
This removes the need of any datasets during distillation.
See Sec 4.5 of the DMD2 paper (https://arxiv.org/abs/2405.14867) for details.
Input:
- image_or_video_shape: a list containing the shape of the image or video [B, F, C, H, W].
- conditional_dict: a dictionary containing the conditional information (e.g. text embeddings, image embeddings).
- unconditional_dict: a dictionary containing the unconditional information (e.g. null/negative text embeddings, null/negative image embeddings).
- clean_latent: a tensor containing the clean latents [B, F, C, H, W]. Need to be passed when no backward simulation is used.
Output:
- loss: a scalar tensor representing the generator loss.
- critic_log_dict: a dictionary containing the intermediate tensors for logging.
"""
slice_last_frames = getattr(self.args, "slice_last_frames", 21)
# Step 1: Run generator on backward simulated noisy input
_t_gen_start = time.time()
with torch.no_grad():
num_gen_frames = image_or_video_shape[1]
sampled_noise = torch.randn(
[image_or_video_shape[0], num_gen_frames, *image_or_video_shape[2:]],
device=self.device, dtype=self.dtype)
generated_image, _, denoised_timestep_from, denoised_timestep_to = self._run_generator(
image_or_video_shape=image_or_video_shape,
conditional_dict=conditional_dict,
initial_latent=initial_latent,
slice_last_frames=slice_last_frames,
noise=sampled_noise,
clean_latent=clean_latent
)
gen_time = time.time() - _t_gen_start
score_initial_latent = (
initial_latent
if initial_latent is not None and generated_image.shape[1] == image_or_video_shape[1]
else None
)
context_frames = _get_i2v_context_frames(generated_image, score_initial_latent)
batch_size, num_frame = generated_image.shape[:2]
_new_segs = num_frame // self.num_frame_per_block
if not getattr(self.args, "generator_is_causal", True):
_new_segs = 1
conditional_dict = self._slice_block_cond_dict(conditional_dict, batch_size, _new_segs)
if getattr(self.args, "teacher_forcing", False):
if getattr(self.args, "backward_simulation", True):
score_clean_x = generated_image
else:
score_clean_x = clean_latent
else:
score_clean_x = None
if score_clean_x is not None:
score_clean_x = _overwrite_i2v_context(
score_clean_x, score_initial_latent, context_frames
)
_t_loss_start = time.time()
# Step 2: Compute the fake prediction
min_timestep = denoised_timestep_to if self.ts_schedule and denoised_timestep_to is not None else self.min_score_timestep
max_timestep = denoised_timestep_from if self.ts_schedule_max and denoised_timestep_from is not None else self.num_train_timestep
critic_timestep = self._get_timestep(
min_timestep,
max_timestep,
batch_size,
num_frame,
self.num_frame_per_block,
# t2v keeps the original NVFP4 behavior (one shared timestep across
# all frames); i2v uses per-block timesteps.
uniform_timestep=not getattr(self.args, "i2v", False)
)
if self.timestep_shift > 1:
critic_timestep = self.timestep_shift * \
(critic_timestep / 1000) / (1 + (self.timestep_shift - 1) * (critic_timestep / 1000)) * 1000
critic_timestep = critic_timestep.clamp(self.min_step, self.max_step)
critic_timestep = _zero_i2v_context_timestep(critic_timestep, context_frames)
critic_noise = torch.randn_like(generated_image)
if context_frames > 0:
critic_noise[:, :context_frames] = 0
noisy_generated_image = self.scheduler.add_noise(
generated_image.flatten(0, 1),
critic_noise.flatten(0, 1),
critic_timestep.flatten(0, 1)
).unflatten(0, (batch_size, num_frame))
noisy_generated_image = _overwrite_i2v_context(
noisy_generated_image, score_initial_latent, context_frames
)
_, pred_fake_image = self.fake_score(
noisy_image_or_video=noisy_generated_image,
conditional_dict=conditional_dict,
timestep=critic_timestep,
clean_x=score_clean_x
)
# Step 3: Compute the denoising loss for the fake critic
if getattr(self.args, "denoising_loss_type", "flow") == "flow":
from utils.wan_5b_wrapper import WanDiffusionWrapper
flow_pred = WanDiffusionWrapper._convert_x0_to_flow_pred(
scheduler=self.scheduler,
x0_pred=pred_fake_image.flatten(0, 1),
xt=noisy_generated_image.flatten(0, 1),
timestep=critic_timestep.flatten(0, 1)
)
pred_fake_noise = None
else:
flow_pred = None
pred_fake_noise = self.scheduler.convert_x0_to_noise(
x0=pred_fake_image.flatten(0, 1),
xt=noisy_generated_image.flatten(0, 1),
timestep=critic_timestep.flatten(0, 1)
).unflatten(0, (batch_size, num_frame))
denoising_loss = self.denoising_loss_func(
x=generated_image.flatten(0, 1),
x_pred=pred_fake_image.flatten(0, 1),
noise=critic_noise.flatten(0, 1),
noise_pred=pred_fake_noise,
alphas_cumprod=self.scheduler.alphas_cumprod,
timestep=critic_timestep.flatten(0, 1),
gradient_mask=(
_i2v_loss_mask_like(generated_image, context_frames).flatten(0, 1)
if context_frames > 0 else None
),
flow_pred=flow_pred,
)
try:
loss_val = denoising_loss.item()
except Exception:
loss_val = float('nan')
loss_time = time.time() - _t_loss_start
# Step 5: Debugging Log
critic_log_dict = {
"critic_timestep": critic_timestep.detach(),
"gen_time": gen_time,
"loss_time": loss_time
}
return denoising_loss, critic_log_dict